diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..333d6134f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.opencode +node_modules +**/node_modules +tmp +dist +**/dist +.env +.env.* diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000000..43b65d6d21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,65 @@ +name: Bug report +description: Report a problem in OpenWork +title: "[Bug]: " +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What's not working / wrong? + validations: + required: true + - type: textarea + id: steps + attributes: + label: To Reproduce + description: "Minimal Steps to reproduce the issue:" + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: A short description of what you expected to happen. + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: A short description of what actually happened. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots (optional) + description: If applicable, add screenshots or a video to help explain your problem. + validations: + required: false + - type: textarea + id: desktop_info + attributes: + label: OW version & Desktop info (optional) + description: | + Include OS and OpenWork version if possible. + OpenWork version: Settings > General. + placeholder: | + - OpenWork version: [e.g. 0.1.166] + - OS: [e.g. macOS Tahoe 26.2] + validations: + required: false + - type: textarea + id: additional_context + attributes: + label: Additional context (optional) + description: Add any other context about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000000..fbbc6fce53 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,68 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: + - feature +body: + - type: textarea + id: summary + attributes: + label: Summary + description: Short description of the request. + placeholder: Add ... + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem / goal + description: What user outcome are you trying to achieve? + validations: + required: true + - type: checkboxes + id: users + attributes: + label: Primary user(s) + options: + - label: Bob (IT / power user) + - label: Susan (non-technical) + - label: Other team roles + - type: textarea + id: opencode_alignment + attributes: + label: OpenCode primitive alignment + description: Is there an existing OpenCode primitive or API that covers this? If not, why is a thin OpenWork layer still needed? + placeholder: session.*, permission.*, skills/plugins, mcp... + validations: + required: true + - type: textarea + id: doc_alignment + attributes: + label: Alignment with VISION/PRINCIPLES/PRODUCT + description: How does this align with `VISION.md`, `PRINCIPLES.md`, and `PRODUCT.md`? + validations: + required: true + - type: textarea + id: testability + attributes: + label: Testability + description: How can we test this? (manual steps, tooling, screenshots) + placeholder: pnpm dev + chrome mcp + screenshots + validations: + required: true + - type: dropdown + id: ready_to_build + attributes: + label: Ready to build it yourself? + options: + - "Yes" + - "No" + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Links, mockups, or related issues. + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..fbf625b38a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,41 @@ +## Summary +- + +## Why +- + +## Issue +- Closes # + +## Scope +- + +## Out of scope +- + +## Testing +### Ran +- `...` + +### Result +- pass/fail: +- if fail, exact files/errors: + +## CI status +- pass: +- code-related failures: +- external/env/auth blockers: + +## Manual verification +1. +2. +3. + +## Evidence +- video/screenshot link, or `N/A (docs-only)` + +## Risk +- + +## Rollback +- diff --git a/.github/workflows/alpha-macos-aarch64.yml b/.github/workflows/alpha-macos-aarch64.yml new file mode 100644 index 0000000000..5405eff284 --- /dev/null +++ b/.github/workflows/alpha-macos-aarch64.yml @@ -0,0 +1,247 @@ +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. Each run also updates a small manifest on the fixed +# `alpha-macos-latest` release so the Electron updater feed stays stable while +# historical alpha artifacts remain available. +# +# See: +# - ARCHITECTURE.md#release-channels +# - .github/workflows/release-macos-aarch64.yml (stable channel) + +on: + push: + branches: + - dev + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: alpha-macos-aarch64-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-alpha-macos-aarch64: + name: Build + Publish alpha (aarch64-apple-darwin) + runs-on: macos-14 + timeout-minutes: 180 + + env: + 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 + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.6" + + - name: Get pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v5 + continue-on-error: true + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: macos-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + macos-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Resolve alpha version + id: alpha-version + shell: bash + env: + GITHUB_RUN_NUMBER: ${{ github.run_number }} + GITHUB_SHA: ${{ github.sha }} + run: | + set -euo pipefail + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require("node:fs"); + const path = "apps/desktop/package.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}`; + const releaseTag = `alpha-macos-v${major}.${minor}.${nextPatch}-alpha.${run}-${sha}`; + console.log(`alpha_version=${alpha}`); + console.log(`base_version=${major}.${minor}.${nextPatch}`); + console.log(`release_tag=${releaseTag}`); + NODE + + - name: Write alpha Electron package version + shell: bash + env: + ALPHA_VERSION: ${{ steps.alpha-version.outputs.alpha_version }} + run: | + set -euo pipefail + node <<'NODE' + const fs = require("node:fs"); + for (const path of ["apps/desktop/package.json", "apps/app/package.json"]) { + const json = JSON.parse(fs.readFileSync(path, "utf8")); + json.version = process.env.ALPHA_VERSION; + fs.writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`); + } + NODE + + - 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: Reject unsigned Electron alpha release + if: env.MACOS_NOTARIZE != 'true' + shell: bash + run: | + echo "Electron alpha artifacts must be signed and notarized. Set MACOS_NOTARIZE=true and provide Apple signing secrets." >&2 + exit 1 + + - name: Build Electron alpha app + if: env.MACOS_NOTARIZE == 'true' + shell: bash + env: + TARGET: aarch64-apple-darwin + run: pnpm --filter @openwork/desktop build:electron + + - name: Package Electron alpha (macOS, signed + notarized) + if: env.MACOS_NOTARIZE == 'true' + env: + CSC_LINK: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + MACOS_NOTARIZE: true + 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 }} + run: | + set -euo pipefail + pnpm --dir apps/desktop exec electron-builder \ + --config electron-builder.yml \ + --mac \ + --arm64 \ + --publish never + + - name: Create immutable alpha prerelease + if: env.MACOS_NOTARIZE == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ALPHA_VERSION: ${{ steps.alpha-version.outputs.alpha_version }} + ALPHA_RUN_RELEASE_TAG: ${{ steps.alpha-version.outputs.release_tag }} + run: | + set -euo pipefail + body="Rolling alpha build for OpenWork (macOS arm64) from ${GITHUB_SHA}." + if gh release view "$ALPHA_RUN_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Alpha prerelease $ALPHA_RUN_RELEASE_TAG already exists; reusing it." + exit 0 + fi + gh release create "$ALPHA_RUN_RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "OpenWork Alpha ${ALPHA_VERSION}" \ + --notes "$body" \ + --target "$GITHUB_SHA" \ + --prerelease + + - name: Upload Electron alpha updater assets + if: env.MACOS_NOTARIZE == 'true' + env: + GH_TOKEN: ${{ github.token }} + ALPHA_RUN_RELEASE_TAG: ${{ steps.alpha-version.outputs.release_tag }} + run: | + set -euo pipefail + shopt -s nullglob + assets=( + apps/desktop/dist-electron/*.dmg + apps/desktop/dist-electron/*.zip + apps/desktop/dist-electron/*.blockmap + apps/desktop/dist-electron/latest-mac.yml + ) + if [ ${#assets[@]} -eq 0 ]; then + echo "No Electron alpha assets found in apps/desktop/dist-electron" >&2 + exit 1 + fi + gh release upload "$ALPHA_RUN_RELEASE_TAG" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + - name: Update alpha updater pointer + if: env.MACOS_NOTARIZE == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ALPHA_RUN_RELEASE_TAG: ${{ steps.alpha-version.outputs.release_tag }} + run: | + set -euo pipefail + POINTER_MANIFEST="$RUNNER_TEMP/latest-mac.yml" + export POINTER_MANIFEST + node <<'NODE' + const fs = require("node:fs"); + const manifestPath = "apps/desktop/dist-electron/latest-mac.yml"; + const releaseTag = process.env.ALPHA_RUN_RELEASE_TAG; + const baseUrl = `https://github.com/different-ai/openwork/releases/download/${releaseTag}`; + const rewritten = fs.readFileSync(manifestPath, "utf8") + .replace(/(url:\s*)(openwork-[^\n]+)/g, `$1${baseUrl}/$2`) + .replace(/(path:\s*)(openwork-[^\n]+)/g, `$1${baseUrl}/$2`); + fs.writeFileSync(process.env.POINTER_MANIFEST, rewritten); + NODE + + if ! gh release view "$ALPHA_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$ALPHA_RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$ALPHA_RELEASE_NAME" \ + --notes "Stable Electron updater pointer for the newest macOS arm64 alpha prerelease." \ + --target "$GITHUB_SHA" \ + --prerelease + fi + + gh release upload "$ALPHA_RELEASE_TAG" "$POINTER_MANIFEST#latest-mac.yml" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber diff --git a/.github/workflows/aur-validate.yml b/.github/workflows/aur-validate.yml new file mode 100644 index 0000000000..d61c8e1158 --- /dev/null +++ b/.github/workflows/aur-validate.yml @@ -0,0 +1,457 @@ +name: AUR Validate + +on: + workflow_dispatch: + inputs: + ref: + description: "Git ref to validate (branch, SHA, or tag)" + required: false + type: string + default: dev + version: + description: "Package version override (e.g., 0.11.160)" + required: false + type: string + arch: + description: "Target architecture" + required: false + type: choice + options: + - x86_64 + default: x86_64 + mode: + description: "Validation mode" + required: false + type: choice + options: + - smoke + - publish-ready + default: smoke + artifact_source: + description: "Where to source desktop .deb" + required: false + type: choice + options: + - local-build-artifact + - release + default: local-build-artifact + release_tag: + description: "Release tag when artifact_source=release (e.g., v0.11.160)" + required: false + type: string + artifact_run_id: + description: "Workflow run ID to download artifact from when artifact_source=local-build-artifact" + required: false + type: string + artifact_name: + description: "Artifact name in artifact_run_id" + required: false + type: string + default: openwork-desktop-linux-amd64-deb + asset_url_x86_64: + description: "Optional explicit public URL for x86_64 .deb" + required: false + type: string + push_to_aur: + description: "Push validated PKGBUILD/.SRCINFO to AUR" + required: false + type: boolean + default: false + aur_repo: + description: "AUR repo name" + required: false + type: string + default: openwork + + workflow_call: + inputs: + ref: + required: false + type: string + default: dev + version: + required: false + type: string + arch: + required: false + type: string + default: x86_64 + mode: + required: false + type: string + default: smoke + artifact_source: + required: false + type: string + default: local-build-artifact + release_tag: + required: false + type: string + artifact_run_id: + required: false + type: string + artifact_name: + required: false + type: string + default: openwork-desktop-linux-amd64-deb + asset_url_x86_64: + required: false + type: string + push_to_aur: + required: false + type: boolean + default: false + aur_repo: + required: false + type: string + default: openwork + secrets: + AUR_SSH_PRIVATE_KEY: + required: false + +permissions: + contents: read + actions: read + +concurrency: + group: aur-validate-${{ github.workflow }}-${{ inputs.ref || github.ref_name }} + cancel-in-progress: true + +jobs: + aur-validate: + name: Validate AUR package (${{ inputs.arch }}, ${{ inputs.mode }}) + runs-on: ubuntu-22.04 + container: + image: archlinux:latest + env: + TARGET_ARCH: ${{ inputs.arch || 'x86_64' }} + MODE: ${{ inputs.mode || 'smoke' }} + ARTIFACT_SOURCE: ${{ inputs.artifact_source || 'local-build-artifact' }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + INPUT_ASSET_URL_X86_64: ${{ inputs.asset_url_x86_64 }} + INPUT_ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} + INPUT_ARTIFACT_NAME: ${{ inputs.artifact_name || 'openwork-desktop-linux-amd64-deb' }} + PUSH_TO_AUR: ${{ inputs.push_to_aur && 'true' || 'false' }} + AUR_REPO: ${{ inputs.aur_repo || 'openwork' }} + steps: + - name: Validate workflow inputs + shell: bash + run: | + set -euo pipefail + + if [ "${TARGET_ARCH}" != "x86_64" ]; then + echo "Only x86_64 is currently supported." >&2 + exit 1 + fi + + if [ "${MODE}" != "smoke" ] && [ "${MODE}" != "publish-ready" ]; then + echo "mode must be smoke or publish-ready" >&2 + exit 1 + fi + + if [ "${ARTIFACT_SOURCE}" != "local-build-artifact" ] && [ "${ARTIFACT_SOURCE}" != "release" ]; then + echo "artifact_source must be local-build-artifact or release" >&2 + exit 1 + fi + + if [ "${ARTIFACT_SOURCE}" = "local-build-artifact" ] && [ -z "${INPUT_ASSET_URL_X86_64:-}" ] && [ -z "${INPUT_ARTIFACT_RUN_ID:-}" ]; then + echo "artifact_run_id is required when artifact_source=local-build-artifact and asset_url_x86_64 is empty" >&2 + exit 1 + fi + + if [ "${PUSH_TO_AUR}" = "true" ] && [ "${ARTIFACT_SOURCE}" = "local-build-artifact" ] && [ -z "${INPUT_ASSET_URL_X86_64:-}" ]; then + echo "For push_to_aur with local-build-artifact, set asset_url_x86_64 to a public URL (or use artifact_source=release)." >&2 + exit 1 + fi + + - name: Install Arch packaging dependencies + shell: bash + run: | + set -euo pipefail + pacman -Syu --noconfirm --needed \ + base-devel \ + curl \ + git \ + jq \ + namcap \ + openssh \ + python \ + sudo \ + xorg-server-xvfb + + - name: Checkout target ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref_name }} + fetch-depth: 0 + + - name: Create non-root makepkg user + shell: bash + run: | + set -euo pipefail + useradd -m -s /bin/bash builder + echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder + chmod 0440 /etc/sudoers.d/builder + chown -R builder:builder "$GITHUB_WORKSPACE" + + - name: Download release asset (.deb) + if: env.ARTIFACT_SOURCE == 'release' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + release_tag="${INPUT_RELEASE_TAG:-}" + if [ -z "$release_tag" ] && [ -n "${INPUT_VERSION:-}" ]; then + release_tag="v${INPUT_VERSION}" + fi + + if [ -z "$release_tag" ]; then + echo "release_tag or version is required when artifact_source=release" >&2 + exit 1 + fi + + case "$release_tag" in + v*) ;; + *) release_tag="v${release_tag}" ;; + esac + + asset_url="${INPUT_ASSET_URL_X86_64:-}" + if [ -z "$asset_url" ]; then + asset_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${release_tag}/openwork-desktop-linux-amd64.deb" + fi + + mkdir -p /tmp/aur-artifacts + curl -fL --retry 5 --retry-all-errors --retry-delay 2 "$asset_url" -o /tmp/aur-artifacts/openwork-desktop-linux-amd64.deb + + echo "RESOLVED_RELEASE_TAG=${release_tag}" >> "$GITHUB_ENV" + echo "RESOLVED_ASSET_URL_X86_64=${asset_url}" >> "$GITHUB_ENV" + + - name: Download local workflow artifact + if: env.ARTIFACT_SOURCE == 'local-build-artifact' && env.INPUT_ASSET_URL_X86_64 == '' + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact_name || 'openwork-desktop-linux-amd64-deb' }} + run-id: ${{ inputs.artifact_run_id }} + github-token: ${{ github.token }} + path: /tmp/aur-artifacts + + - name: Download x86_64 asset URL override + if: env.ARTIFACT_SOURCE == 'local-build-artifact' && env.INPUT_ASSET_URL_X86_64 != '' + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/aur-artifacts + curl -fL --retry 5 --retry-all-errors --retry-delay 2 "${INPUT_ASSET_URL_X86_64}" -o /tmp/aur-artifacts/openwork-desktop-linux-amd64.deb + + - name: Resolve local artifact path + if: env.ARTIFACT_SOURCE == 'local-build-artifact' + shell: bash + run: | + set -euo pipefail + deb_path=$(find /tmp/aur-artifacts -maxdepth 3 -type f -name '*.deb' | head -n 1) + if [ -z "${deb_path:-}" ]; then + echo "No .deb file found in /tmp/aur-artifacts" >&2 + exit 1 + fi + + if [ "$deb_path" != "/tmp/aur-artifacts/openwork-desktop-linux-amd64.deb" ]; then + cp "$deb_path" /tmp/aur-artifacts/openwork-desktop-linux-amd64.deb + fi + + - name: Resolve version, URL, and checksum + id: resolve + shell: bash + run: | + set -euo pipefail + + deb_path="/tmp/aur-artifacts/openwork-desktop-linux-amd64.deb" + if [ ! -f "$deb_path" ]; then + echo "Expected $deb_path to exist" >&2 + exit 1 + fi + + version="${INPUT_VERSION:-}" + if [ -z "$version" ] && [ -n "${RESOLVED_RELEASE_TAG:-}" ]; then + version="${RESOLVED_RELEASE_TAG#v}" + fi + if [ -z "$version" ]; then + version="$(awk -F= '$1 == "pkgver" {print $2; exit}' packaging/aur/PKGBUILD)" + fi + + if [ -z "$version" ]; then + echo "Unable to determine version. Pass inputs.version." >&2 + exit 1 + fi + + sha256="$(sha256sum "$deb_path" | awk '{print $1}')" + + source_url="${RESOLVED_ASSET_URL_X86_64:-}" + if [ -z "$source_url" ] && [ -n "${INPUT_ASSET_URL_X86_64:-}" ]; then + source_url="${INPUT_ASSET_URL_X86_64}" + fi + if [ -z "$source_url" ]; then + source_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}/openwork-desktop-linux-amd64.deb" + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "sha256=${sha256}" >> "$GITHUB_OUTPUT" + echo "source_url=${source_url}" >> "$GITHUB_OUTPUT" + + - name: Patch PKGBUILD for x86_64 validation + shell: bash + env: + RESOLVED_VERSION: ${{ steps.resolve.outputs.version }} + RESOLVED_SHA256: ${{ steps.resolve.outputs.sha256 }} + RESOLVED_SOURCE_URL: ${{ steps.resolve.outputs.source_url }} + run: | + set -euo pipefail + python3 - <<'PY' + import pathlib + import re + import os + + path = pathlib.Path("packaging/aur/PKGBUILD") + text = path.read_text() + + version = os.environ["RESOLVED_VERSION"] + sha256 = os.environ["RESOLVED_SHA256"] + source_url = os.environ["RESOLVED_SOURCE_URL"] + + text = re.sub(r"^pkgver=.*$", f"pkgver={version}", text, flags=re.M) + text = re.sub(r"^pkgrel=.*$", "pkgrel=1", text, flags=re.M) + text = re.sub(r"^arch=.*$", "arch=('x86_64')", text, flags=re.M) + text = re.sub( + r"^source_x86_64=.*$", + "source_x86_64=(\"${pkgname}-${pkgver}.deb::" + source_url + "\")", + text, + flags=re.M, + ) + text = re.sub( + r"^sha256sums_x86_64=.*$", + f"sha256sums_x86_64=('{sha256}')", + text, + flags=re.M, + ) + text = re.sub(r"^source_aarch64=.*\n?", "", text, flags=re.M) + text = re.sub(r"^sha256sums_aarch64=.*\n?", "", text, flags=re.M) + + path.write_text(text) + PY + + - name: Regenerate .SRCINFO + shell: bash + run: | + set -euo pipefail + workspace_path="$GITHUB_WORKSPACE" + sudo -u builder bash -lc "cd '$workspace_path/packaging/aur' && makepkg --printsrcinfo > .SRCINFO" + + - name: Build package with makepkg + shell: bash + run: | + set -euo pipefail + workspace_path="$GITHUB_WORKSPACE" + sudo -u builder bash -lc "cd '$workspace_path/packaging/aur' && makepkg -f --syncdeps --noconfirm --cleanbuild" + + - name: Run namcap checks + if: env.MODE == 'publish-ready' + shell: bash + run: | + set -euo pipefail + cd packaging/aur + namcap PKGBUILD || true + pkg_file=$(ls -1 openwork-*.pkg.tar.* | head -n 1) + namcap "$pkg_file" || true + + - name: Install built package + shell: bash + run: | + set -euo pipefail + cd packaging/aur + pkg_file=$(ls -1 openwork-*.pkg.tar.* | head -n 1) + pacman -U --noconfirm "$pkg_file" + + - name: Smoke launch + shell: bash + run: | + set -euo pipefail + pacman -Ql openwork > /tmp/openwork-installed-files.txt + + if [ ! -s /tmp/openwork-installed-files.txt ]; then + echo "openwork package install listing is empty" >&2 + exit 1 + fi + + launch_bin="" + for candidate in \ + "$(command -v openwork 2>/dev/null || true)" \ + /usr/bin/openwork \ + /opt/openwork/openwork \ + /opt/openwork/openwork-desktop \ + /opt/openwork/opencode + do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + launch_bin="$candidate" + break + fi + done + + if [ -n "$launch_bin" ]; then + xvfb-run -a bash -lc '"$1" --help >/tmp/openwork-help.txt 2>&1 || "$1" --version >/tmp/openwork-version.txt 2>&1 || true' -- "$launch_bin" + else + echo "No runnable desktop binary found; install sanity check passed." + fi + + - name: Publish to AUR + if: env.PUSH_TO_AUR == 'true' + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + RELEASE_TAG: v${{ steps.resolve.outputs.version }} + AUR_SKIP_UPDATE: "1" + shell: bash + run: | + set -euo pipefail + + if [ -z "${AUR_SSH_PRIVATE_KEY:-}" ]; then + echo "AUR_SSH_PRIVATE_KEY not set; cannot push to AUR." >&2 + exit 1 + fi + + mkdir -p "$HOME/.ssh" + touch "$HOME/.ssh/known_hosts" + ssh-keygen -R aur.archlinux.org >/dev/null 2>&1 || true + ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> "$HOME/.ssh/known_hosts" 2>/dev/null + + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + key_path="$tmp_dir/aur.key" + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$key_path" + chmod 600 "$key_path" + + aur_remote="ssh://aur@aur.archlinux.org/${AUR_REPO}.git" + export GIT_SSH_COMMAND="ssh -i $key_path -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + + git clone "$aur_remote" "$tmp_dir/aur" + cp packaging/aur/PKGBUILD "$tmp_dir/aur/PKGBUILD" + cp packaging/aur/.SRCINFO "$tmp_dir/aur/.SRCINFO" + + cd "$tmp_dir/aur" + if git diff --quiet -- PKGBUILD .SRCINFO; then + echo "AUR already up to date for ${AUR_REPO}." + exit 0 + fi + + git add PKGBUILD .SRCINFO + git -c user.name="OpenWork Release Bot" \ + -c user.email="release-bot@users.noreply.github.com" \ + commit -m "chore(aur): update PKGBUILD for ${RELEASE_TAG#v}" + + current_branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)" + if [ -z "$current_branch" ]; then + current_branch="master" + fi + + git push origin "HEAD:${current_branch}" diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml new file mode 100644 index 0000000000..47cf20d8dd --- /dev/null +++ b/.github/workflows/build-desktop.yml @@ -0,0 +1,120 @@ +name: Build Desktop (Linux) + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-linux: + name: Tauri Build (Linux) + # Set OPENWORK_LINUX_X64_RUNNER_LABEL to route this job to a larger GitHub-hosted runner. + runs-on: ${{ vars.OPENWORK_LINUX_X64_RUNNER_LABEL != '' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL || 'ubuntu-22.04' }} + + steps: + - name: Log runner selection + run: | + echo "Requested larger runner label: ${RUNNER_LABEL:-}" + echo "Effective runs-on: ${EFFECTIVE_RUNS_ON}" + env: + RUNNER_LABEL: ${{ vars.OPENWORK_LINUX_X64_RUNNER_LABEL }} + EFFECTIVE_RUNS_ON: ${{ vars.OPENWORK_LINUX_X64_RUNNER_LABEL != '' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL || 'ubuntu-22.04' }} + + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.5 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libglib2.0-dev \ + libayatana-appindicator3-dev \ + libsoup-3.0-dev \ + libwebkit2gtk-4.1-dev \ + libssl-dev \ + rpm \ + libdbus-1-dev \ + librsvg2-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Create CI Tauri config (no updater artifacts) + run: | + node -e "const fs=require('fs'); const configPath='apps/desktop/src-tauri/tauri.conf.json'; const ciPath='apps/desktop/src-tauri/tauri.conf.ci.json'; const config=JSON.parse(fs.readFileSync(configPath,'utf8')); config.bundle={...config.bundle, createUpdaterArtifacts:false}; fs.writeFileSync(ciPath, JSON.stringify(config, null, 2));" + + - name: Download OpenCode sidecar + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }} + run: | + set -euo pipefail + + repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}" + version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" + version="$(echo "$version" | tr -d '\r\n' | sed 's/^v//')" + + if [ -z "$version" ]; then + echo "Unable to resolve OpenCode version from constants.json." >&2 + exit 1 + fi + + opencode_asset="opencode-linux-x64-baseline.tar.gz" + url="https://github.com/${repo}/releases/download/v${version}/${opencode_asset}" + tmp_dir="$RUNNER_TEMP/opencode" + extract_dir="$tmp_dir/extracted" + rm -rf "$tmp_dir" + mkdir -p "$extract_dir" + + curl_headers=() + if [ -n "${GITHUB_TOKEN:-}" ]; then + curl_headers+=( -H "Authorization: Bearer ${GITHUB_TOKEN}" ) + fi + curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 "${curl_headers[@]}" -o "$tmp_dir/$opencode_asset" "$url" + tar -xzf "$tmp_dir/$opencode_asset" -C "$extract_dir" + + if [ -f "$extract_dir/opencode" ]; then + bin_path="$extract_dir/opencode" + else + echo "OpenCode binary not found in archive" + ls -la "$extract_dir" + exit 1 + fi + + target_name="opencode-x86_64-unknown-linux-gnu" + mkdir -p apps/desktop/src-tauri/sidecars + cp "$bin_path" "apps/desktop/src-tauri/sidecars/${target_name}" + chmod 755 "apps/desktop/src-tauri/sidecars/${target_name}" + + - name: Prepare desktop sidecars + run: pnpm -C apps/desktop prepare:sidecar + + - name: Run Rust tests (engine + sidecar resolution) + run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked + + - name: Build desktop app + run: pnpm --filter @openwork/desktop exec tauri build --config src-tauri/tauri.conf.ci.json --target x86_64-unknown-linux-gnu --bundles deb diff --git a/.github/workflows/build-electron-desktop.yml b/.github/workflows/build-electron-desktop.yml new file mode 100644 index 0000000000..9f4364baba --- /dev/null +++ b/.github/workflows/build-electron-desktop.yml @@ -0,0 +1,114 @@ +name: Build Electron Desktop + +on: + workflow_dispatch: + push: + branches: + - electron-notary-test + paths: + - apps/app/** + - apps/desktop/** + - packages/ui/** + - constants.json + - pnpm-lock.yaml + - package.json + - .github/workflows/build-electron-desktop.yml + +jobs: + build-electron: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + artifact: macos + - os: ubuntu-latest + artifact: linux + - os: windows-latest + artifact: windows + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # pnpm must be installed BEFORE setup-node so setup-node can find the + # pnpm binary on PATH. Matches the pattern in build-desktop.yml so + # the two workflows share the same bootstrap story. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Package Electron desktop (unpacked) + run: pnpm --filter @openwork/desktop package:electron:dir + + - name: Upload Electron artifacts + uses: actions/upload-artifact@v4 + with: + name: openwork-electron-${{ matrix.artifact }} + path: apps/desktop/dist-electron/** + + electron-macos-notarization-smoke: + name: Electron macOS notarization smoke + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Write notary API key + 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: Package Electron desktop (signed + notarized, no publish) + env: + CSC_LINK: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + MACOS_NOTARIZE: true + 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 }} + run: | + set -euo pipefail + pnpm --filter @openwork/desktop run build:electron + pnpm --dir apps/desktop exec electron-builder --config electron-builder.yml --mac dmg zip --publish never diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000000..e7d4ae15df --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,118 @@ +name: OpenWork Tests + +on: + pull_request: + branches: + - dev + push: + branches: + - dev + +permissions: + contents: read + +jobs: + openwork-tests: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [blacksmith-4vcpu-ubuntu-2204, macos-14] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Install OpenCode CLI + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }} + run: | + set -euo pipefail + + repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}" + version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" + version="$(echo "$version" | tr -d '\r\n' | sed 's/^v//')" + + if [ -z "$version" ]; then + echo "Unable to resolve OpenCode version from constants.json." >&2 + exit 1 + fi + + arch="$(uname -m)" + case "${RUNNER_OS}" in + Linux) + if [ "$arch" = "aarch64" ] || [ "$arch" = "arm64" ]; then + opencode_asset="opencode-linux-arm64.tar.gz" + else + opencode_asset="opencode-linux-x64-baseline.tar.gz" + fi + ;; + macOS) + if [ "$arch" = "arm64" ]; then + opencode_asset="opencode-darwin-arm64.zip" + else + opencode_asset="opencode-darwin-x64-baseline.zip" + fi + ;; + *) + echo "Unsupported OS: ${RUNNER_OS}" >&2 + exit 1 + ;; + esac + + url="https://github.com/${repo}/releases/download/v${version}/${opencode_asset}" + tmp_dir="$RUNNER_TEMP/opencode" + extract_dir="$tmp_dir/extracted" + rm -rf "$tmp_dir" + mkdir -p "$extract_dir" + + curl_headers=() + if [ -n "${GITHUB_TOKEN:-}" ]; then + curl_headers+=( -H "Authorization: Bearer ${GITHUB_TOKEN}" ) + fi + + curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 "${curl_headers[@]}" -o "$tmp_dir/$opencode_asset" "$url" + + if [[ "$opencode_asset" == *.tar.gz ]]; then + tar -xzf "$tmp_dir/$opencode_asset" -C "$extract_dir" + else + unzip -q "$tmp_dir/$opencode_asset" -d "$extract_dir" + fi + + if [ -f "$extract_dir/opencode" ]; then + bin_path="$extract_dir/opencode" + elif [ -f "$extract_dir/opencode.exe" ]; then + bin_path="$extract_dir/opencode.exe" + else + echo "OpenCode binary not found in archive" >&2 + ls -la "$extract_dir" + exit 1 + fi + + install_dir="$HOME/.opencode/bin" + mkdir -p "$install_dir" + cp "$bin_path" "$install_dir/opencode" + chmod 755 "$install_dir/opencode" + echo "$install_dir" >> "$GITHUB_PATH" + + - name: Verify OpenCode + run: opencode --version + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run e2e tests + run: pnpm --filter @openwork/app test:e2e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..f241cbd263 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + push: + branches: + - dev + pull_request: + branches: + - dev + +permissions: + contents: read + +jobs: + build-web: + name: Build Web + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build web + run: pnpm --filter @openwork-ee/den-web build + + build-den: + name: Build Den services + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build den-api + run: pnpm --filter @openwork-ee/den-api build + + - name: Build den-worker-proxy + run: pnpm --filter @openwork-ee/den-worker-proxy build + + build-orchestrator-binary: + name: Build openwork orchestrator binary + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck openwork-orchestrator + run: pnpm --filter openwork-orchestrator typecheck + + - name: Build openwork orchestrator binary + run: pnpm --filter openwork-orchestrator build:bin + + - name: Validate compiled binary + run: | + ./apps/orchestrator/dist/bin/openwork --version + ./apps/orchestrator/dist/bin/openwork --help >/dev/null diff --git a/.github/workflows/deploy-den.yml b/.github/workflows/deploy-den.yml new file mode 100644 index 0000000000..4ed04072bd --- /dev/null +++ b/.github/workflows/deploy-den.yml @@ -0,0 +1,99 @@ +name: Deploy Den + +on: + workflow_call: + inputs: + daytona_snapshot: + description: "Daytona snapshot name to promote into Render" + required: true + type: string + workflow_dispatch: + inputs: + daytona_snapshot: + description: "Daytona snapshot name to promote into Render" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: deploy-den-${{ inputs.daytona_snapshot }} + cancel-in-progress: false + +jobs: + deploy-den: + name: Update Render Daytona Snapshot + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + RENDER_API_BASE: https://api.render.com/v1 + RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + RENDER_SERVICE_ID: ${{ secrets.RENDER_DEN_CONTROL_PLANE_SERVICE_ID }} + DAYTONA_SNAPSHOT: ${{ inputs.daytona_snapshot }} + steps: + - name: Validate required configuration + shell: bash + run: | + set -euo pipefail + + if [ -z "${DAYTONA_SNAPSHOT:-}" ]; then + echo "daytona_snapshot input is required" >&2 + exit 1 + fi + + if [ -z "${RENDER_API_KEY:-}" ]; then + echo "Missing required secret: RENDER_API_KEY" >&2 + exit 1 + fi + + if [ -z "${RENDER_SERVICE_ID:-}" ]; then + echo "Missing required secret: RENDER_DEN_CONTROL_PLANE_SERVICE_ID" >&2 + exit 1 + fi + + - name: Update DAYTONA_SNAPSHOT on Render + shell: bash + run: | + set -euo pipefail + + payload="$(python3 -c 'import json, sys; print(json.dumps({"value": sys.argv[1]}))' "$DAYTONA_SNAPSHOT")" + response_file="$(mktemp)" + status_code="$(curl -sS -o "$response_file" -w "%{http_code}" \ + -X PUT "${RENDER_API_BASE}/services/${RENDER_SERVICE_ID}/env-vars/DAYTONA_SNAPSHOT" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer ${RENDER_API_KEY}" \ + -H "Content-Type: application/json" \ + --data "$payload")" + + if [ "$status_code" -lt 200 ] || [ "$status_code" -ge 300 ]; then + echo "Failed to update Render DAYTONA_SNAPSHOT (HTTP $status_code)" >&2 + python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).read_text(errors="replace"))' "$response_file" + exit 1 + fi + + echo "Render DAYTONA_SNAPSHOT set to ${DAYTONA_SNAPSHOT}" + + - name: Trigger Render deploy + id: deploy + shell: bash + run: | + set -euo pipefail + + response_file="$(mktemp)" + status_code="$(curl -sS -o "$response_file" -w "%{http_code}" \ + -X POST "${RENDER_API_BASE}/services/${RENDER_SERVICE_ID}/deploys" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer ${RENDER_API_KEY}" \ + -H "Content-Type: application/json" \ + --data '{}')" + + if [ "$status_code" -lt 200 ] || [ "$status_code" -ge 300 ]; then + echo "Failed to trigger Render deploy (HTTP $status_code)" >&2 + python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).read_text(errors="replace"))' "$response_file" + exit 1 + fi + + deploy_id="$(python3 -c 'import json, sys; from pathlib import Path; text = Path(sys.argv[1]).read_text(errors="replace").strip(); data = json.loads(text) if text else {}; print(data.get("id", "") if isinstance(data, dict) else "")' "$response_file")" + + echo "deploy_id=${deploy_id}" >> "$GITHUB_OUTPUT" + echo "Triggered Render deploy ${deploy_id:-} for snapshot ${DAYTONA_SNAPSHOT}" diff --git a/.github/workflows/download-stats.yml b/.github/workflows/download-stats.yml new file mode 100644 index 0000000000..a965d04b70 --- /dev/null +++ b/.github/workflows/download-stats.yml @@ -0,0 +1,43 @@ +name: Download Stats + +on: + schedule: + - cron: "0 12 * * *" # Run daily at 12:00 UTC + workflow_dispatch: + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + stats: + if: github.repository == 'different-ai/openwork' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Run stats script + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + POSTHOG_HOST: https://us.i.posthog.com + POSTHOG_LEGACY_EVENT: download + POSTHOG_V2_EVENT: release_asset_snapshot + POSTHOG_DISTINCT_ID: openwork-download + GITHUB_REPO: different-ai/openwork + run: node scripts/stats.mjs + + - name: Commit stats + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add STATS.md STATS_V2.md + git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)" + git push diff --git a/.github/workflows/opencode-agents.yml b/.github/workflows/opencode-agents.yml new file mode 100644 index 0000000000..b8adb12d75 --- /dev/null +++ b/.github/workflows/opencode-agents.yml @@ -0,0 +1,96 @@ +name: Opencode Agents + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +jobs: + triage-issue: + if: github.event_name == 'issues' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Install opencode + run: | + version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" + curl -fsSL https://opencode.ai/install | bash -s -- --version "$version" --no-modify-path + + - name: Triage issue + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + cat > /tmp/issue_prompt.txt <<'PROMPT_EOF' + The following issue was just opened, triage it: + PROMPT_EOF + printf '\nTitle: %s\n\n%s\n' "$ISSUE_TITLE" "$ISSUE_BODY" >> /tmp/issue_prompt.txt + opencode run --agent triage "$(cat /tmp/issue_prompt.txt)" + + duplicate-prs: + if: github.event_name == 'pull_request_target' && github.event.pull_request.user.login != 'opencode-agent[bot]' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Install opencode + run: | + version="$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" + curl -fsSL https://opencode.ai/install | bash -s -- --version "$version" --no-modify-path + + - name: Build prompt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + { + echo "Check for duplicate PRs related to this new PR:" + echo "" + echo "CURRENT_PR_NUMBER: $PR_NUMBER" + echo "" + echo "Title: $(gh pr view \"$PR_NUMBER\" --json title --jq .title)" + echo "" + echo "Description:" + gh pr view "$PR_NUMBER" --json body --jq .body + } > pr_info.txt + + - name: Check for duplicate PRs + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + opencode run --agent duplicate-pr "$(cat pr_info.txt)" > /tmp/comment_output.txt + + { + echo "_The following comment was made by an LLM, it may be inaccurate:_" + echo "" + cat /tmp/comment_output.txt + } > /tmp/comment_body.txt + gh pr comment "$PR_NUMBER" --body-file /tmp/comment_body.txt diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000000..36de01c42e --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,327 @@ +name: PreRelease App + +on: + push: + branches: + - dev + - feat/windows-sidecar + +permissions: + contents: write + +concurrency: + group: prerelease-${{ github.ref }} + cancel-in-progress: true + +jobs: + prepare-release: + name: Prepare Prerelease + runs-on: blacksmith-4vcpu-ubuntu-2404 + + outputs: + release_tag: ${{ steps.prerelease-meta.outputs.release_tag }} + release_name: ${{ steps.prerelease-meta.outputs.release_name }} + release_body: ${{ steps.prerelease-meta.outputs.release_body }} + + steps: + - name: Set prerelease metadata + id: prerelease-meta + shell: bash + run: | + set -euo pipefail + + short_sha=$(echo "$GITHUB_SHA" | cut -c1-7) + tag="v${short_sha}-dev" + name="OpenWork ${tag}" + body="Automated prerelease from ${GITHUB_REF_NAME} (${GITHUB_SHA})." + + echo "RELEASE_TAG=$tag" >> "$GITHUB_ENV" + echo "RELEASE_NAME=$name" >> "$GITHUB_ENV" + { + echo "RELEASE_BODY<<__OPENWORK_RELEASE_BODY_EOF__" + echo "$body" + echo "__OPENWORK_RELEASE_BODY_EOF__" + } >> "$GITHUB_ENV" + + echo "release_tag=$tag" >> "$GITHUB_OUTPUT" + echo "release_name=$name" >> "$GITHUB_OUTPUT" + { + echo "release_body<<__OPENWORK_RELEASE_BODY_EOF__" + echo "$body" + echo "__OPENWORK_RELEASE_BODY_EOF__" + } >> "$GITHUB_OUTPUT" + + - name: Create prerelease + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + BODY_FILE="$RUNNER_TEMP/release_body.md" + printf '%s\n' "$RELEASE_BODY" > "$BODY_FILE" + + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Prerelease $RELEASE_TAG already exists; skipping create." + exit 0 + fi + + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$RELEASE_NAME" \ + --notes-file "$BODY_FILE" \ + --prerelease + + publish-tauri: + name: Build + Publish (${{ matrix.target }}) + needs: prepare-release + # Set OPENWORK_LINUX_X64_RUNNER_LABEL to route only the Linux x86_64 build to a larger runner. + runs-on: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL != '' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL || matrix.platform }} + timeout-minutes: 360 + + env: + RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }} + RELEASE_NAME: ${{ needs.prepare-release.outputs.release_name }} + RELEASE_BODY: ${{ needs.prepare-release.outputs.release_body }} + MACOS_NOTARIZE: ${{ vars.MACOS_NOTARIZE || 'false' }} + OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }} + + strategy: + fail-fast: false + matrix: + include: + - platform: macos-14 + os_type: macos + target: aarch64-apple-darwin + args: "--target aarch64-apple-darwin --bundles dmg,app" + - platform: macos-14 + os_type: macos + target: x86_64-apple-darwin + args: "--target x86_64-apple-darwin --bundles dmg,app" + - platform: ubuntu-22.04 + os_type: linux + target: x86_64-unknown-linux-gnu + args: "--target x86_64-unknown-linux-gnu --bundles deb,rpm" + - platform: ubuntu-22.04-arm + os_type: linux + target: aarch64-unknown-linux-gnu + args: "--target aarch64-unknown-linux-gnu --bundles deb,rpm" + - platform: windows-2022 + os_type: windows + target: x86_64-pc-windows-msvc + args: "--target x86_64-pc-windows-msvc --bundles msi" + + steps: + - name: Log runner selection + shell: bash + run: | + echo "Requested larger runner label: ${RUNNER_LABEL:-}" + echo "Effective runs-on: ${EFFECTIVE_RUNS_ON}" + env: + RUNNER_LABEL: ${{ vars.OPENWORK_LINUX_X64_RUNNER_LABEL }} + EFFECTIVE_RUNS_ON: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL != '' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL || matrix.platform }} + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + + - name: Enable git long paths (Windows) + if: matrix.os_type == 'windows' + shell: pwsh + run: git config --global core.longpaths true + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.6" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install OpenTUI x64 core (macOS x86_64) + if: matrix.os_type == 'macos' && matrix.target == 'x86_64-apple-darwin' + run: pnpm add -w --ignore-workspace-root-check @opentui/core-darwin-x64@0.1.77 + + - name: Install Linux build dependencies + if: matrix.os_type == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libglib2.0-dev \ + libayatana-appindicator3-dev \ + libsoup-3.0-dev \ + libwebkit2gtk-4.1-dev \ + libssl-dev \ + rpm \ + libdbus-1-dev \ + librsvg2-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Resolve OpenCode version + id: opencode-version + shell: bash + run: | + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require('fs'); + const parsed = JSON.parse(fs.readFileSync('./constants.json', 'utf8')); + const version = String(parsed.opencodeVersion || '').replace(/^v/, '').trim(); + if (!version) { + throw new Error('Pinned OpenCode version is missing from constants.json'); + } + console.log('version=' + version); + NODE + + - name: Download OpenCode sidecar + shell: bash + env: + PINNED_OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }} + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + opencode_asset="opencode-darwin-arm64.zip" + ;; + x86_64-apple-darwin) + opencode_asset="opencode-darwin-x64-baseline.zip" + ;; + x86_64-unknown-linux-gnu) + opencode_asset="opencode-linux-x64-baseline.tar.gz" + ;; + aarch64-unknown-linux-gnu) + opencode_asset="opencode-linux-arm64.tar.gz" + ;; + x86_64-pc-windows-msvc) + opencode_asset="opencode-windows-x64-baseline.zip" + ;; + *) + echo "Unsupported target: ${{ matrix.target }}" + exit 1 + ;; + esac + + repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}" + url="https://github.com/${repo}/releases/download/v${PINNED_OPENCODE_VERSION}/${opencode_asset}" + tmp_dir="$RUNNER_TEMP/opencode" + extract_dir="$tmp_dir/extracted" + rm -rf "$tmp_dir" + mkdir -p "$extract_dir" + curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 -o "$tmp_dir/$opencode_asset" "$url" + + if [[ "$opencode_asset" == *.tar.gz ]]; then + tar -xzf "$tmp_dir/$opencode_asset" -C "$extract_dir" + else + if command -v unzip >/dev/null 2>&1; then + unzip -q "$tmp_dir/$opencode_asset" -d "$extract_dir" + elif command -v 7z >/dev/null 2>&1; then + 7z x "$tmp_dir/$opencode_asset" -o"$extract_dir" >/dev/null + else + echo "No unzip utility available" + exit 1 + fi + fi + + if [ -f "$extract_dir/opencode" ]; then + bin_path="$extract_dir/opencode" + elif [ -f "$extract_dir/opencode.exe" ]; then + bin_path="$extract_dir/opencode.exe" + else + echo "OpenCode binary not found in archive" + ls -la "$extract_dir" + exit 1 + fi + + target_name="opencode-${{ matrix.target }}" + if [ "${{ matrix.os_type }}" = "windows" ]; then + target_name="${target_name}.exe" + fi + + mkdir -p apps/desktop/src-tauri/sidecars + cp "$bin_path" "apps/desktop/src-tauri/sidecars/${target_name}" + chmod 755 "apps/desktop/src-tauri/sidecars/${target_name}" + + - name: Write notary API key + if: matrix.os_type == 'macos' && 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 (notarized) + if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE == 'true' + uses: tauri-apps/tauri-action@v0.5.17 + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Tauri updater signing + 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: + tagName: ${{ env.RELEASE_TAG }} + releaseName: ${{ env.RELEASE_NAME }} + releaseBody: ${{ env.RELEASE_BODY }} + prerelease: true + releaseDraft: false + projectPath: apps/desktop + tauriScript: pnpm exec tauri -vvv + args: ${{ matrix.args }} + retryAttempts: 3 + + - name: Build + upload + if: matrix.os_type != 'macos' || env.MACOS_NOTARIZE != 'true' + uses: tauri-apps/tauri-action@v0.5.17 + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Tauri updater signing + 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 }} + with: + tagName: ${{ env.RELEASE_TAG }} + releaseName: ${{ env.RELEASE_NAME }} + releaseBody: ${{ env.RELEASE_BODY }} + prerelease: true + releaseDraft: false + projectPath: apps/desktop + tauriScript: pnpm exec tauri -vvv + args: ${{ matrix.args }} + retryAttempts: 3 diff --git a/.github/workflows/release-daytona-snapshot.yml b/.github/workflows/release-daytona-snapshot.yml new file mode 100644 index 0000000000..4d8baa3ac1 --- /dev/null +++ b/.github/workflows/release-daytona-snapshot.yml @@ -0,0 +1,172 @@ +name: Release Daytona Snapshot + +on: + workflow_call: + inputs: + tag: + description: "Tag to build from (e.g., v0.11.200). Defaults to current ref." + required: false + type: string + deploy_den: + description: "Whether to promote the published snapshot into the Den Render service" + required: false + type: boolean + default: true + snapshot_name: + description: "Optional explicit Daytona snapshot name" + required: false + type: string + snapshot_region: + description: "Optional Daytona region override for snapshot push" + required: false + type: string + workflow_dispatch: + inputs: + tag: + description: "Tag to build from (e.g., v0.11.200). Defaults to release tag/current ref." + required: false + type: string + deploy_den: + description: "Whether to promote the published snapshot into the Den Render service" + required: false + type: boolean + default: true + snapshot_name: + description: "Optional explicit Daytona snapshot name" + required: false + type: string + snapshot_region: + description: "Optional Daytona region override for snapshot push" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + publish-daytona-snapshot: + name: Build and Push Daytona Snapshot + runs-on: blacksmith-4vcpu-ubuntu-2404 + outputs: + release_tag: ${{ steps.resolve.outputs.release_tag }} + snapshot_name: ${{ steps.resolve.outputs.snapshot_name }} + snapshot_region: ${{ steps.resolve.outputs.snapshot_region }} + steps: + - name: Resolve release tag and snapshot name + id: resolve + shell: bash + env: + INPUT_TAG: ${{ inputs.tag }} + INPUT_SNAPSHOT_NAME: ${{ inputs.snapshot_name }} + INPUT_SNAPSHOT_REGION: ${{ inputs.snapshot_region }} + SNAPSHOT_NAME_BASE: ${{ vars.DAYTONA_SNAPSHOT_NAME_BASE }} + DEFAULT_SNAPSHOT_REGION: ${{ vars.DAYTONA_SNAPSHOT_REGION }} + run: | + set -euo pipefail + + tag="${INPUT_TAG:-}" + if [ -z "$tag" ]; then + tag="${GITHUB_REF_NAME}" + fi + if [[ "$tag" != v* ]]; then + tag="v${tag}" + fi + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $tag" >&2 + exit 1 + fi + + base_name="${SNAPSHOT_NAME_BASE:-openwork}" + if [ -n "${INPUT_SNAPSHOT_NAME:-}" ]; then + snapshot_name="${INPUT_SNAPSHOT_NAME}" + else + snapshot_name="${base_name}-${tag#v}" + fi + snapshot_region="${INPUT_SNAPSHOT_REGION:-${DEFAULT_SNAPSHOT_REGION:-}}" + + echo "release_tag=$tag" >> "$GITHUB_OUTPUT" + echo "snapshot_name=$snapshot_name" >> "$GITHUB_OUTPUT" + echo "snapshot_region=$snapshot_region" >> "$GITHUB_OUTPUT" + + - name: Checkout release source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ steps.resolve.outputs.release_tag }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install Daytona CLI + shell: bash + run: | + set -euo pipefail + + case "$(uname -s)" in + Linux) platform="linux" ;; + Darwin) platform="darwin" ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; + esac + + asset_name="daytona-${platform}-${arch}" + install_dir="$HOME/.local/bin" + mkdir -p "$install_dir" + + release_json="$(curl -fsSL https://api.github.com/repos/daytonaio/daytona/releases/latest)" + asset_url="$(python3 -c 'import json, sys; data = json.load(sys.stdin); name = sys.argv[1]; print(next(asset["browser_download_url"] for asset in data["assets"] if asset["name"] == name))' "$asset_name" <<<"$release_json")" + + curl -fL "$asset_url" -o "$install_dir/daytona" + chmod +x "$install_dir/daytona" + + echo "$install_dir" >> "$GITHUB_PATH" + export PATH="$install_dir:$PATH" + + daytona version + + - name: Build and push snapshot + shell: bash + env: + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} + DAYTONA_API_URL: ${{ vars.DAYTONA_API_URL }} + DAYTONA_TARGET: ${{ vars.DAYTONA_TARGET }} + DAYTONA_SNAPSHOT_REGION: ${{ steps.resolve.outputs.snapshot_region }} + DAYTONA_SNAPSHOT_NAME: ${{ steps.resolve.outputs.snapshot_name }} + run: | + set -euo pipefail + + if [ -z "${DAYTONA_API_KEY:-}" ]; then + echo "Missing required secret: DAYTONA_API_KEY" >&2 + exit 1 + fi + + export DAYTONA_CONFIG_DIR="$RUNNER_TEMP/daytona" + mkdir -p "$DAYTONA_CONFIG_DIR" + daytona login --api-key "$DAYTONA_API_KEY" + + echo "Publishing Daytona snapshot: ${DAYTONA_SNAPSHOT_NAME}" + ./scripts/create-daytona-openwork-snapshot.sh "${DAYTONA_SNAPSHOT_NAME}" + + deploy-den: + name: Promote Daytona Snapshot to Den Render Service + needs: [publish-daytona-snapshot] + if: ${{ inputs.deploy_den }} + uses: ./.github/workflows/deploy-den.yml + with: + daytona_snapshot: ${{ needs.publish-daytona-snapshot.outputs.snapshot_name }} + secrets: inherit diff --git a/.github/workflows/release-macos-aarch64.yml b/.github/workflows/release-macos-aarch64.yml new file mode 100644 index 0000000000..f2c22e31a3 --- /dev/null +++ b/.github/workflows/release-macos-aarch64.yml @@ -0,0 +1,1167 @@ +name: Release App + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to release (e.g., v0.1.2). Leave empty to use current ref." + required: false + type: string + release_name: + description: "Release title (defaults to OpenWork )" + required: false + type: string + release_body: + description: "Release notes body in Markdown (defaults to a short placeholder)" + required: false + type: string + draft: + description: "Create the GitHub Release as a draft" + required: false + type: boolean + default: false + prerelease: + description: "Mark the GitHub Release as a prerelease" + required: false + type: boolean + default: false + notarize: + description: "Notarize macOS builds (requires Apple team configured)" + required: false + type: boolean + default: true + build_tauri: + description: "Build desktop (Tauri) artifacts" + required: false + type: boolean + default: true + publish_sidecars: + description: "Build + upload openwork-orchestrator sidecar release assets" + required: false + type: boolean + default: true + publish_npm: + description: "Publish openwork-orchestrator/openwork-server/opencode-router to npm if versions changed" + required: false + type: boolean + default: true + publish_daytona_snapshot: + description: "Build + push Daytona worker snapshot" + required: false + type: boolean + default: true + publish_electron: + description: "Build + publish Electron desktop artifacts (macOS/Linux/Windows) alongside Tauri" + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + resolve-release: + name: Resolve Release Metadata + runs-on: blacksmith-4vcpu-ubuntu-2404 + outputs: + release_tag: ${{ steps.resolve.outputs.release_tag }} + release_name: ${{ steps.resolve.outputs.release_name }} + release_body: ${{ steps.resolve.outputs.release_body }} + draft: ${{ steps.resolve.outputs.draft }} + prerelease: ${{ steps.resolve.outputs.prerelease }} + notarize: ${{ steps.resolve.outputs.notarize }} + build_tauri: ${{ steps.resolve.outputs.build_tauri }} + publish_sidecars: ${{ steps.resolve.outputs.publish_sidecars }} + publish_npm: ${{ steps.resolve.outputs.publish_npm }} + publish_daytona_snapshot: ${{ steps.resolve.outputs.publish_daytona_snapshot }} + steps: + - name: Resolve metadata + id: resolve + shell: bash + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + INPUT_RELEASE_NAME: ${{ github.event.inputs.release_name }} + INPUT_RELEASE_BODY: ${{ github.event.inputs.release_body }} + INPUT_DRAFT: ${{ github.event.inputs.draft }} + INPUT_PRERELEASE: ${{ github.event.inputs.prerelease }} + INPUT_NOTARIZE: ${{ github.event.inputs.notarize }} + INPUT_BUILD_TAURI: ${{ github.event.inputs.build_tauri }} + INPUT_PUBLISH_SIDECARS: ${{ github.event.inputs.publish_sidecars }} + INPUT_PUBLISH_NPM: ${{ github.event.inputs.publish_npm }} + INPUT_PUBLISH_DAYTONA_SNAPSHOT: ${{ github.event.inputs.publish_daytona_snapshot }} + DEFAULT_PUBLISH_SIDECARS: ${{ vars.RELEASE_PUBLISH_SIDECARS }} + DEFAULT_PUBLISH_NPM: ${{ vars.RELEASE_PUBLISH_NPM }} + DEFAULT_PUBLISH_DAYTONA_SNAPSHOT: ${{ vars.RELEASE_PUBLISH_DAYTONA_SNAPSHOT }} + DEFAULT_NOTARIZE: ${{ vars.MACOS_NOTARIZE }} + DEFAULT_BUILD_TAURI: ${{ vars.RELEASE_BUILD_TAURI }} + run: | + set -euo pipefail + + TAG_INPUT="${INPUT_TAG:-}" + if [ -n "$TAG_INPUT" ]; then + if [[ "$TAG_INPUT" == v* ]]; then + TAG="$TAG_INPUT" + else + TAG="v$TAG_INPUT" + fi + else + TAG="${GITHUB_REF_NAME}" + fi + + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $TAG (expected vX.Y.Z)" >&2 + exit 1 + fi + + RELEASE_NAME_INPUT="${INPUT_RELEASE_NAME:-}" + if [ -n "$RELEASE_NAME_INPUT" ]; then + RELEASE_NAME="$RELEASE_NAME_INPUT" + else + RELEASE_NAME="OpenWork $TAG" + fi + + RELEASE_BODY_INPUT="${INPUT_RELEASE_BODY:-}" + if [ -n "$RELEASE_BODY_INPUT" ]; then + RELEASE_BODY="$RELEASE_BODY_INPUT" + else + printf -v RELEASE_BODY '%s\n\nOpenWork %s desktop release.\n\n%s\n%s\n%s' \ + "## What's new" \ + "$TAG" \ + "- Download the attached installer for your platform." \ + "- Electron builds use the attached latest-mac.yml updater feed when Electron publishing is enabled." \ + "- Tauri builds use the attached latest.json updater manifest." + fi + + draft="${INPUT_DRAFT:-}" + if [ -z "$draft" ]; then + if [ "${GITHUB_EVENT_NAME}" = "push" ]; then + # Keep tag-triggered releases out of /releases/latest until assets + latest.json are ready. + draft="true" + else + draft="false" + fi + fi + prerelease="${INPUT_PRERELEASE:-false}" + notarize="${INPUT_NOTARIZE:-}" + if [ -z "$notarize" ]; then + notarize="${DEFAULT_NOTARIZE:-true}" + fi + + build_tauri="${INPUT_BUILD_TAURI:-}" + if [ -z "$build_tauri" ]; then + build_tauri="${DEFAULT_BUILD_TAURI:-true}" + fi + + publish_sidecars="${INPUT_PUBLISH_SIDECARS:-}" + if [ -z "$publish_sidecars" ]; then + publish_sidecars="${DEFAULT_PUBLISH_SIDECARS:-true}" + fi + publish_npm="${INPUT_PUBLISH_NPM:-}" + if [ -z "$publish_npm" ]; then + publish_npm="${DEFAULT_PUBLISH_NPM:-true}" + fi + + publish_daytona_snapshot="${INPUT_PUBLISH_DAYTONA_SNAPSHOT:-}" + if [ -z "$publish_daytona_snapshot" ]; then + publish_daytona_snapshot="${DEFAULT_PUBLISH_DAYTONA_SNAPSHOT:-true}" + fi + + TAG="${TAG//$'\n'/}" + TAG="${TAG//$'\r'/}" + RELEASE_NAME="${RELEASE_NAME//$'\n'/ }" + RELEASE_NAME="${RELEASE_NAME//$'\r'/ }" + + echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" + echo "release_name=$RELEASE_NAME" >> "$GITHUB_OUTPUT" + echo "draft=$draft" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "notarize=$notarize" >> "$GITHUB_OUTPUT" + echo "build_tauri=$build_tauri" >> "$GITHUB_OUTPUT" + echo "publish_sidecars=$publish_sidecars" >> "$GITHUB_OUTPUT" + echo "publish_npm=$publish_npm" >> "$GITHUB_OUTPUT" + echo "publish_daytona_snapshot=$publish_daytona_snapshot" >> "$GITHUB_OUTPUT" + { + echo "release_body<<__OPENWORK_RELEASE_BODY_EOF__" + printf '%s\n' "$RELEASE_BODY" + echo "__OPENWORK_RELEASE_BODY_EOF__" + } >> "$GITHUB_OUTPUT" + + - name: Create release if missing + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + BODY_FILE="$RUNNER_TEMP/release_body.md" + printf '%s\n' "${{ steps.resolve.outputs.release_body }}" > "$BODY_FILE" + + if gh release view "${{ steps.resolve.outputs.release_tag }}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release ${{ steps.resolve.outputs.release_tag }} already exists; skipping create." + exit 0 + fi + + DRAFT_FLAG="" + PRERELEASE_FLAG="" + if [ "${{ steps.resolve.outputs.draft }}" = "true" ]; then + DRAFT_FLAG="--draft" + fi + if [ "${{ steps.resolve.outputs.prerelease }}" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + fi + + gh release create "${{ steps.resolve.outputs.release_tag }}" \ + --repo "$GITHUB_REPOSITORY" \ + --title "${{ steps.resolve.outputs.release_name }}" \ + --notes-file "$BODY_FILE" \ + $DRAFT_FLAG $PRERELEASE_FLAG + + verify-release: + name: Verify Release Versions + needs: resolve-release + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Verify tag matches app versions + run: node scripts/release/verify-tag.mjs --tag "$RELEASE_TAG" + + - name: Release review (strict) + run: node scripts/release/review.mjs --strict + + publish-tauri: + name: Build + Publish (${{ matrix.target }}) + needs: [resolve-release, verify-release] + if: needs.resolve-release.outputs.build_tauri == 'true' + # Set OPENWORK_LINUX_X64_RUNNER_LABEL to route only the Linux x86_64 build to a larger runner. + runs-on: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL != '' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL || matrix.platform }} + timeout-minutes: 360 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + RELEASE_NAME: ${{ needs.resolve-release.outputs.release_name }} + RELEASE_BODY: ${{ needs.resolve-release.outputs.release_body }} + RELEASE_DRAFT: ${{ needs.resolve-release.outputs.draft }} + RELEASE_PRERELEASE: ${{ needs.resolve-release.outputs.prerelease }} + MACOS_NOTARIZE: ${{ needs.resolve-release.outputs.notarize }} + # Ensure Tauri's beforeBuildCommand (prepare:sidecar) uses our fork. + OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }} + + strategy: + fail-fast: false + matrix: + include: + - platform: macos-14 + os_type: macos + target: aarch64-apple-darwin + args: "--target aarch64-apple-darwin --bundles dmg,app" + - platform: macos-14 + os_type: macos + target: x86_64-apple-darwin + args: "--target x86_64-apple-darwin --bundles dmg,app" + - platform: ubuntu-22.04 + os_type: linux + target: x86_64-unknown-linux-gnu + args: "--target x86_64-unknown-linux-gnu --bundles deb,rpm" + - platform: ubuntu-22.04-arm + os_type: linux + target: aarch64-unknown-linux-gnu + args: "--target aarch64-unknown-linux-gnu --bundles deb,rpm" + - platform: windows-2022 + os_type: windows + target: x86_64-pc-windows-msvc + args: "--target x86_64-pc-windows-msvc --bundles msi" + + steps: + - name: Log runner selection + shell: bash + run: | + echo "Requested larger runner label: ${RUNNER_LABEL:-}" + echo "Effective runs-on: ${EFFECTIVE_RUNS_ON}" + env: + RUNNER_LABEL: ${{ vars.OPENWORK_LINUX_X64_RUNNER_LABEL }} + EFFECTIVE_RUNS_ON: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL != '' && vars.OPENWORK_LINUX_X64_RUNNER_LABEL || matrix.platform }} + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Enable git long paths (Windows) + if: matrix.os_type == 'windows' + shell: pwsh + run: git config --global core.longpaths true + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.6" + + - name: Get pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v5 + continue-on-error: true + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Cache cargo + uses: actions/cache@v5 + continue-on-error: true + with: + path: | + ~/.cargo/registry + ~/.cargo/git + apps/desktop/src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Install OpenTUI x64 core (macOS x86_64) + if: matrix.os_type == 'macos' && matrix.target == 'x86_64-apple-darwin' + run: pnpm add -w --ignore-workspace-root-check @opentui/core-darwin-x64@0.1.77 + + - name: Install Linux build dependencies + if: matrix.os_type == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libglib2.0-dev \ + libayatana-appindicator3-dev \ + libsoup-3.0-dev \ + libwebkit2gtk-4.1-dev \ + libssl-dev \ + rpm \ + libdbus-1-dev \ + librsvg2-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Resolve OpenCode version + id: opencode-version + shell: bash + run: | + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require('fs'); + const parsed = JSON.parse(fs.readFileSync('./constants.json', 'utf8')); + const version = String(parsed.opencodeVersion || '').replace(/^v/, '').trim(); + if (!version) { + throw new Error('Pinned OpenCode version is missing from constants.json'); + } + console.log('version=' + version); + NODE + + - name: Download OpenCode sidecar + shell: bash + env: + PINNED_OPENCODE_VERSION: ${{ steps.opencode-version.outputs.version }} + OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }} + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + opencode_asset="opencode-darwin-arm64.zip" + ;; + x86_64-apple-darwin) + opencode_asset="opencode-darwin-x64-baseline.zip" + ;; + x86_64-unknown-linux-gnu) + opencode_asset="opencode-linux-x64-baseline.tar.gz" + ;; + aarch64-unknown-linux-gnu) + opencode_asset="opencode-linux-arm64.tar.gz" + ;; + x86_64-pc-windows-msvc) + opencode_asset="opencode-windows-x64-baseline.zip" + ;; + *) + echo "Unsupported target: ${{ matrix.target }}" >&2 + exit 1 + ;; + esac + + repo="${OPENCODE_GITHUB_REPO:-anomalyco/opencode}" + url="https://github.com/${repo}/releases/download/v${PINNED_OPENCODE_VERSION}/${opencode_asset}" + tmp_dir="$RUNNER_TEMP/opencode" + extract_dir="$tmp_dir/extracted" + rm -rf "$tmp_dir" + mkdir -p "$extract_dir" + curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 -o "$tmp_dir/$opencode_asset" "$url" + + if [[ "$opencode_asset" == *.tar.gz ]]; then + tar -xzf "$tmp_dir/$opencode_asset" -C "$extract_dir" + else + if command -v unzip >/dev/null 2>&1; then + unzip -q "$tmp_dir/$opencode_asset" -d "$extract_dir" + elif command -v 7z >/dev/null 2>&1; then + 7z x "$tmp_dir/$opencode_asset" -o"$extract_dir" >/dev/null + else + echo "No unzip utility available" >&2 + exit 1 + fi + fi + + if [ -f "$extract_dir/opencode" ]; then + bin_path="$extract_dir/opencode" + elif [ -f "$extract_dir/opencode.exe" ]; then + bin_path="$extract_dir/opencode.exe" + else + echo "OpenCode binary not found in archive" >&2 + ls -la "$extract_dir" + exit 1 + fi + + target_name="opencode-${{ matrix.target }}" + if [ "${{ matrix.os_type }}" = "windows" ]; then + target_name="${target_name}.exe" + fi + + mkdir -p apps/desktop/src-tauri/sidecars + cp "$bin_path" "apps/desktop/src-tauri/sidecars/${target_name}" + chmod 755 "apps/desktop/src-tauri/sidecars/${target_name}" + + - name: Write notary API key + if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE == 'true' + env: + APPLE_NOTARY_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }} + APPLE_NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} + APPLE_NOTARY_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} + APPLE_CODESIGN_CERT_P12_BASE64: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} + APPLE_CODESIGN_CERT_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + set -euo pipefail + + missing=() + for name in \ + APPLE_NOTARY_API_KEY_P8_BASE64 \ + APPLE_NOTARY_API_KEY_ID \ + APPLE_NOTARY_API_ISSUER_ID \ + APPLE_CODESIGN_CERT_P12_BASE64 \ + APPLE_CODESIGN_CERT_PASSWORD \ + APPLE_SIGNING_IDENTITY; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing macOS notarization/signing secrets: %s\n' "${missing[*]}" >&2 + exit 1 + fi + + 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: Reject unnotarized macOS Tauri release + if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE != 'true' + shell: bash + run: | + echo "macOS release assets must be notarized. Re-run with notarize=true or set MACOS_NOTARIZE=true." >&2 + exit 1 + + - name: Build + upload (notarized) + if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE == 'true' + uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Tauri updater signing + 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: + tagName: ${{ env.RELEASE_TAG }} + releaseName: ${{ env.RELEASE_NAME }} + releaseBody: ${{ env.RELEASE_BODY }} + releaseDraft: ${{ env.RELEASE_DRAFT == 'true' }} + prerelease: ${{ env.RELEASE_PRERELEASE == 'true' }} + projectPath: apps/desktop + tauriScript: pnpm exec tauri -vvv + args: ${{ matrix.args }} + retryAttempts: 3 + uploadUpdaterJson: false + updaterJsonPreferNsis: true + releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext] + + - name: Build + upload + if: matrix.os_type != 'macos' + uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Tauri updater signing + 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 }} + with: + tagName: ${{ env.RELEASE_TAG }} + releaseName: ${{ env.RELEASE_NAME }} + releaseBody: ${{ env.RELEASE_BODY }} + releaseDraft: ${{ env.RELEASE_DRAFT == 'true' }} + prerelease: ${{ env.RELEASE_PRERELEASE == 'true' }} + projectPath: apps/desktop + tauriScript: pnpm exec tauri -vvv + args: ${{ matrix.args }} + retryAttempts: 3 + uploadUpdaterJson: false + updaterJsonPreferNsis: true + releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext] + + - name: Verify versions.json bundled (macOS) + if: success() && matrix.os_type == 'macos' + shell: bash + run: | + set -euo pipefail + + tmp_dir="$RUNNER_TEMP/openwork-bundle-verify" + archive_path="apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/macos/OpenWork.app.tar.gz" + + if [ ! -f "$archive_path" ]; then + echo "ERROR: updater archive missing from local build output: $archive_path" >&2 + exit 1 + fi + + rm -rf "$tmp_dir" + mkdir -p "$tmp_dir" + + tar -xzf "$archive_path" -C "$tmp_dir" + + app_path="$tmp_dir/OpenWork.app" + manifest_path="$app_path/Contents/Resources/versions.json" + + if [ ! -f "$manifest_path" ]; then + echo "ERROR: versions.json missing from app bundle: $manifest_path" >&2 + echo "Hint: ensure apps/desktop/src-tauri/tauri.conf.json bundles sidecars/versions.json as a resource" >&2 + exit 1 + fi + + echo "Found bundled versions.json at $manifest_path" + codesign --verify --deep --strict --verbose=2 "$app_path" + spctl -a -vv -t execute "$app_path" + xcrun stapler validate "$app_path" + + publish-updater-json: + name: Publish consolidated latest.json + needs: [resolve-release, verify-release, publish-tauri] + if: needs.resolve-release.outputs.build_tauri == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Generate latest.json from release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node scripts/release/generate-latest-json.mjs \ + --tag "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --output "$RUNNER_TEMP/latest.json" + + - name: Upload latest.json + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/latest.json#latest.json" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + publish-electron: + name: Build + publish Electron (${{ matrix.artifact }}) + # Runs alongside the Tauri jobs so the same release carries both + # latest.json (Tauri updater) AND latest-*.yml (electron-updater). + # Gated on RELEASE_PUBLISH_ELECTRON=true (repo var) OR the workflow + # input of the same name so opt-in during rollout, opt-out if a + # non-migration release doesn't want Electron artifacts. + needs: [resolve-release, verify-release] + if: ${{ vars.RELEASE_PUBLISH_ELECTRON == 'true' || github.event.inputs.publish_electron == 'true' }} + runs-on: ${{ matrix.platform }} + timeout-minutes: 120 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + MACOS_NOTARIZE: ${{ needs.resolve-release.outputs.notarize }} + + strategy: + fail-fast: false + matrix: + include: + - platform: macos-14 + os_type: macos + artifact: electron-macos-arm64 + electron_args: "--mac --arm64" + target_triple: aarch64-apple-darwin + - platform: macos-14 + os_type: macos + artifact: electron-macos-x64 + electron_args: "--mac --x64" + target_triple: x86_64-apple-darwin + - platform: ubuntu-22.04 + os_type: linux + artifact: electron-linux-x64 + electron_args: "--linux --x64" + target_triple: x86_64-unknown-linux-gnu + - platform: ubuntu-22.04-arm + os_type: linux + artifact: electron-linux-arm64 + electron_args: "--linux --arm64" + target_triple: aarch64-unknown-linux-gnu + - platform: windows-2022 + os_type: windows + artifact: electron-windows-x64 + electron_args: "--win --x64" + target_triple: x86_64-pc-windows-msvc + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Enable git long paths (Windows) + if: matrix.os_type == 'windows' + shell: pwsh + run: git config --global core.longpaths true + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Get pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v5 + continue-on-error: true + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-electron-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-electron-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Write Electron notary API key (macOS) + if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE == 'true' + env: + APPLE_NOTARY_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }} + APPLE_NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} + APPLE_NOTARY_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} + APPLE_CODESIGN_CERT_P12_BASE64: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} + APPLE_CODESIGN_CERT_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + run: | + set -euo pipefail + + missing=() + for name in \ + APPLE_NOTARY_API_KEY_P8_BASE64 \ + APPLE_NOTARY_API_KEY_ID \ + APPLE_NOTARY_API_ISSUER_ID \ + APPLE_CODESIGN_CERT_P12_BASE64 \ + APPLE_CODESIGN_CERT_PASSWORD; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing Electron macOS notarization/signing secrets: %s\n' "${missing[*]}" >&2 + exit 1 + fi + + 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: Reject unnotarized macOS Electron release + if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE != 'true' + shell: bash + run: | + echo "macOS Electron release assets must be notarized. Re-run with notarize=true or set MACOS_NOTARIZE=true." >&2 + exit 1 + + - name: Build Electron app + shell: bash + env: + # TARGET tells prepare-sidecar.mjs which architecture's sidecars + # to download (critical for cross-arch builds like x64 on arm64 mac). + TARGET: ${{ matrix.target_triple }} + run: pnpm --filter @openwork/desktop build:electron + + - name: Package + publish Electron (macOS, signed + notarized) + if: matrix.os_type == 'macos' && env.MACOS_NOTARIZE == 'true' + env: + CSC_LINK: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + 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 }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + pnpm --dir apps/desktop exec electron-builder \ + --config electron-builder.yml \ + ${{ matrix.electron_args }} \ + --publish always + + - name: Package + publish Electron (Linux) + if: matrix.os_type == 'linux' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + pnpm --dir apps/desktop exec electron-builder \ + --config electron-builder.yml \ + ${{ matrix.electron_args }} \ + --publish always + + - name: Package + publish Electron (Windows) + if: matrix.os_type == 'windows' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + pnpm --dir apps/desktop exec electron-builder \ + --config electron-builder.yml \ + ${{ matrix.electron_args }} \ + --publish always + + release-orchestrator-sidecars: + name: Build + Upload openwork-orchestrator Sidecars + needs: [resolve-release, verify-release] + if: needs.resolve-release.outputs.publish_sidecars == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.6" + + - name: Get pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v5 + continue-on-error: true + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ubuntu-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ubuntu-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Resolve sidecar versions + id: sidecar-versions + shell: bash + run: | + node -e "const fs=require('fs'); const orchestrator=JSON.parse(fs.readFileSync('apps/orchestrator/package.json','utf8')); const server=JSON.parse(fs.readFileSync('apps/server/package.json','utf8')); const opencodeRouter=JSON.parse(fs.readFileSync('apps/opencode-router/package.json','utf8')); console.log('orchestrator=' + orchestrator.version); console.log('server=' + server.version); console.log('opencodeRouter=' + opencodeRouter.version);" >> "$GITHUB_OUTPUT" + + - name: Resolve SOURCE_DATE_EPOCH + id: source-date + shell: bash + run: | + epoch=$(git show -s --format=%ct "${RELEASE_TAG}") + echo "epoch=$epoch" >> "$GITHUB_OUTPUT" + + - name: Check openwork-orchestrator release + id: orchestrator-release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="openwork-orchestrator-v${{ steps.sidecar-versions.outputs.orchestrator }}" + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build orchestrator release artifacts + env: + SOURCE_DATE_EPOCH: ${{ steps.source-date.outputs.epoch }} + run: | + pnpm --filter openwork-orchestrator build:bin:all + pnpm --filter openwork-orchestrator build:sidecars + + - name: Release review (strict) + env: + SOURCE_DATE_EPOCH: ${{ steps.source-date.outputs.epoch }} + run: node scripts/release/review.mjs --strict + + - name: Create openwork-orchestrator release + if: steps.orchestrator-release.outputs.exists != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + version="${{ steps.sidecar-versions.outputs.orchestrator }}" + tag="openwork-orchestrator-v${version}" + notes="Sidecar bundle for openwork-orchestrator v${version}.\n\nopenwork-server: ${{ steps.sidecar-versions.outputs.server }}\nopencodeRouter: ${{ steps.sidecar-versions.outputs.opencodeRouter }}" + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "openwork-orchestrator v${version}" \ + --notes "$notes" \ + --latest=false + + - name: Upload orchestrator release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="openwork-orchestrator-v${{ steps.sidecar-versions.outputs.orchestrator }}" + gh release upload "$tag" apps/orchestrator/dist/bin/* apps/orchestrator/dist/sidecars/* --repo "$GITHUB_REPOSITORY" --clobber + + publish-npm: + name: Publish npm packages + needs: [resolve-release, verify-release, release-orchestrator-sidecars] + if: | + always() && + needs.resolve-release.result == 'success' && + needs.verify-release.result == 'success' && + (needs.release-orchestrator-sidecars.result == 'success' || needs.release-orchestrator-sidecars.result == 'skipped') && + needs.resolve-release.outputs.publish_npm == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Dispatch-based release recovery uses the workflow ref so fixes to + # npm release automation can run without moving an already-shipped tag. + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.6" + + - name: Get pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v5 + continue-on-error: true + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ubuntu-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ubuntu-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Resolve package versions + id: package-versions + shell: bash + run: | + node -e "const fs=require('fs'); const orchestrator=JSON.parse(fs.readFileSync('apps/orchestrator/package.json','utf8')); const server=JSON.parse(fs.readFileSync('apps/server/package.json','utf8')); const opencodeRouter=JSON.parse(fs.readFileSync('apps/opencode-router/package.json','utf8')); console.log('orchestrator=' + orchestrator.version); console.log('server=' + server.version); console.log('opencodeRouter=' + opencodeRouter.version);" >> "$GITHUB_OUTPUT" + + - name: Check npm versions + id: npm-versions + shell: bash + env: + ORCHESTRATOR_VERSION: ${{ steps.package-versions.outputs.orchestrator }} + SERVER_VERSION: ${{ steps.package-versions.outputs.server }} + OPENCODE_ROUTER_VERSION: ${{ steps.package-versions.outputs.opencodeRouter }} + run: | + set -euo pipefail + # npm view exits non-zero for packages that don't exist yet (404). + # Treat missing packages as "not published" so release can publish them. + orchestrator_current="$(npm view openwork-orchestrator version 2>/dev/null || true)" + server_current="$(npm view openwork-server version 2>/dev/null || true)" + opencodeRouter_current="$(npm view opencode-router version 2>/dev/null || true)" + + if [ "$orchestrator_current" = "$ORCHESTRATOR_VERSION" ]; then + echo "publish_orchestrator=false" >> "$GITHUB_OUTPUT" + else + echo "publish_orchestrator=true" >> "$GITHUB_OUTPUT" + fi + + if [ "$server_current" = "$SERVER_VERSION" ]; then + echo "publish_server=false" >> "$GITHUB_OUTPUT" + else + echo "publish_server=true" >> "$GITHUB_OUTPUT" + fi + + if [ "$opencodeRouter_current" = "$OPENCODE_ROUTER_VERSION" ]; then + echo "publish_opencodeRouter=false" >> "$GITHUB_OUTPUT" + else + echo "publish_opencodeRouter=true" >> "$GITHUB_OUTPUT" + fi + + publish_any=false + if [ "$orchestrator_current" != "$ORCHESTRATOR_VERSION" ] || [ "$server_current" != "$SERVER_VERSION" ] || [ "$opencodeRouter_current" != "$OPENCODE_ROUTER_VERSION" ]; then + publish_any=true + fi + echo "publish_any=$publish_any" >> "$GITHUB_OUTPUT" + + - name: Ensure npm auth + id: npm-auth + shell: bash + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PUBLISH_ANY: ${{ steps.npm-versions.outputs.publish_any }} + run: | + set -euo pipefail + + if [ "${PUBLISH_ANY}" != "true" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -z "${NPM_TOKEN:-}" ]; then + echo "NPM_TOKEN not set; skipping npm publish." + echo "enabled=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + npm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN" + echo "enabled=true" >> "$GITHUB_OUTPUT" + + - name: Publish openwork-server + if: steps.npm-auth.outputs.enabled == 'true' && steps.npm-versions.outputs.publish_server == 'true' + run: pnpm --filter openwork-server publish --access public --no-git-checks + + - name: Publish opencode-router + if: steps.npm-auth.outputs.enabled == 'true' && steps.npm-versions.outputs.publish_opencodeRouter == 'true' + run: pnpm --filter opencode-router publish --access public --no-git-checks + + - name: Publish openwork-orchestrator + if: steps.npm-auth.outputs.enabled == 'true' && steps.npm-versions.outputs.publish_orchestrator == 'true' + env: + GH_TOKEN: ${{ github.token }} + ORCHESTRATOR_VERSION: ${{ steps.package-versions.outputs.orchestrator }} + run: | + set -euo pipefail + tag="openwork-orchestrator-v${ORCHESTRATOR_VERSION}" + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "openwork-orchestrator sidecar release $tag not found. Publish sidecars before openwork-orchestrator." >&2 + exit 1 + fi + pnpm --filter openwork-orchestrator build:bin:all + node apps/orchestrator/scripts/publish-npm.mjs + + publish-daytona-snapshot: + name: Build + Push Daytona Snapshot + needs: [resolve-release, verify-release, publish-npm] + if: | + always() && + needs.resolve-release.result == 'success' && + needs.verify-release.result == 'success' && + (needs.publish-npm.result == 'success' || needs.publish-npm.result == 'skipped') && + needs.resolve-release.outputs.publish_daytona_snapshot == 'true' + uses: ./.github/workflows/release-daytona-snapshot.yml + with: + tag: ${{ needs.resolve-release.outputs.release_tag }} + secrets: inherit + + aur-publish: + name: Publish AUR + needs: [resolve-release, publish-tauri, publish-release] + if: | + always() && + needs.resolve-release.result == 'success' && + (needs.publish-tauri.result == 'success' || needs.publish-tauri.result == 'skipped') && + (needs.publish-release.result == 'success' || needs.publish-release.result == 'skipped') + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: write + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + steps: + - name: Checkout dev + uses: actions/checkout@v6 + with: + ref: dev + fetch-depth: 0 + + - name: Update AUR packaging files + run: scripts/aur/update-aur.sh "$RELEASE_TAG" + + - name: Commit packaging update to dev + shell: bash + run: | + set -euo pipefail + + if ! git status --porcelain -- packaging/aur/PKGBUILD packaging/aur/.SRCINFO | grep -q .; then + echo "AUR packaging already up to date in dev." + exit 0 + fi + + version="${RELEASE_TAG#v}" + git add packaging/aur/PKGBUILD packaging/aur/.SRCINFO + git -c user.name="OpenWork Release Bot" \ + -c user.email="release-bot@users.noreply.github.com" \ + commit -m "chore(aur): update PKGBUILD for ${version}" + git push origin HEAD:dev + + - name: Publish to AUR + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + AUR_REPO: ${{ vars.AUR_REPO || 'openwork' }} + AUR_SKIP_UPDATE: "1" + run: | + set -euo pipefail + if [ -z "${AUR_SSH_PRIVATE_KEY:-}" ]; then + echo "AUR_SSH_PRIVATE_KEY not set; skipping publish to AUR." + exit 0 + fi + scripts/aur/publish-aur.sh "$RELEASE_TAG" + + publish-release: + name: Publish GitHub Release + needs: + - resolve-release + - verify-release + - publish-tauri + - publish-updater-json + - publish-electron + - release-orchestrator-sidecars + - publish-npm + - publish-daytona-snapshot + if: | + always() && + needs.resolve-release.outputs.draft == 'true' && + needs.resolve-release.result == 'success' && + needs.verify-release.result == 'success' && + (needs.publish-tauri.result == 'success' || needs.publish-tauri.result == 'skipped') && + (needs.publish-updater-json.result == 'success' || needs.publish-updater-json.result == 'skipped') && + (needs.publish-electron.result == 'success' || needs.publish-electron.result == 'skipped') && + (needs.release-orchestrator-sidecars.result == 'success' || needs.release-orchestrator-sidecars.result == 'skipped') && + (needs.publish-npm.result == 'success' || needs.publish-npm.result == 'skipped') && + (needs.publish-daytona-snapshot.result == 'success' || needs.publish-daytona-snapshot.result == 'skipped') + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + RELEASE_PRERELEASE: ${{ needs.resolve-release.outputs.prerelease }} + steps: + - name: Publish release after assets are ready + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + if [ "${RELEASE_PRERELEASE}" = "true" ]; then + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease + else + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + fi diff --git a/.github/workflows/windows-signed-artifacts.yml b/.github/workflows/windows-signed-artifacts.yml new file mode 100644 index 0000000000..149290e5d5 --- /dev/null +++ b/.github/workflows/windows-signed-artifacts.yml @@ -0,0 +1,122 @@ +name: Windows Signed Artifacts + +on: + workflow_dispatch: + inputs: + ref: + description: Git ref to build + required: false + type: string + +permissions: + contents: read + +jobs: + build-and-sign-windows: + name: Build and sign Windows artifacts + runs-on: windows-latest + env: + TAURI_TARGET: x86_64-pc-windows-msvc + BUN_TARGET: bun-windows-x64 + WINDOWS_SIGNING_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} + WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL || 'http://timestamp.digicert.com' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.27.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.10 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare sidecars + run: pnpm -C apps/desktop prepare:sidecar + + - name: Import Windows signing certificate + shell: pwsh + env: + WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} + run: | + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERT_PFX_BASE64)) { + throw "WINDOWS_CERT_PFX_BASE64 is required for Windows signing." + } + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERT_PASSWORD)) { + throw "WINDOWS_CERT_PASSWORD is required for Windows signing." + } + $bytes = [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64) + $certPath = Join-Path $env:RUNNER_TEMP "windows-codesign.pfx" + [IO.File]::WriteAllBytes($certPath, $bytes) + "WINDOWS_CERT_PATH=$certPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Sign bundled Windows sidecars + shell: pwsh + run: | + $targets = @( + "apps/desktop/src-tauri/sidecars/opencode-$env:TAURI_TARGET.exe", + "apps/desktop/src-tauri/sidecars/opencode-router-$env:TAURI_TARGET.exe", + "apps/desktop/src-tauri/sidecars/openwork-server-v2-$env:TAURI_TARGET.exe" + ) + foreach ($target in $targets) { + if (!(Test-Path $target)) { + throw "Expected Windows sidecar missing: $target" + } + signtool sign /fd SHA256 /td SHA256 /tr $env:WINDOWS_TIMESTAMP_URL /f $env:WINDOWS_CERT_PATH /p $env:WINDOWS_SIGNING_CERT_PASSWORD $target + } + + - name: Build embedded Server V2 runtime + run: pnpm --filter openwork-server-v2 build:bin:embedded:windows --bundle-dir ../desktop/src-tauri/sidecars + working-directory: apps/server-v2 + + - name: Sign Server V2 executable + shell: pwsh + run: | + $serverPath = "apps/server-v2/dist/bin/openwork-server-v2-$env:BUN_TARGET.exe" + if (!(Test-Path $serverPath)) { + throw "Expected Server V2 executable missing: $serverPath" + } + signtool sign /fd SHA256 /td SHA256 /tr $env:WINDOWS_TIMESTAMP_URL /f $env:WINDOWS_CERT_PATH /p $env:WINDOWS_SIGNING_CERT_PASSWORD $serverPath + signtool verify /pa /v $serverPath + + - name: Build desktop Windows bundle + run: pnpm --filter @openwork/desktop exec tauri build --target x86_64-pc-windows-msvc + + - name: Sign desktop Windows artifacts + shell: pwsh + run: | + $artifacts = Get-ChildItem -Path "apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle" -Recurse -Include *.exe,*.msi + if ($artifacts.Count -eq 0) { + throw "No Windows desktop artifacts were produced to sign." + } + foreach ($artifact in $artifacts) { + signtool sign /fd SHA256 /td SHA256 /tr $env:WINDOWS_TIMESTAMP_URL /f $env:WINDOWS_CERT_PATH /p $env:WINDOWS_SIGNING_CERT_PASSWORD $artifact.FullName + signtool verify /pa /v $artifact.FullName + } + + - name: Upload signed artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-signed-artifacts + path: | + apps/server-v2/dist/bin/openwork-server-v2-*.exe + apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe + apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.msi diff --git a/.gitignore b/.gitignore index 1581ebe143..0e45300eac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,49 @@ +.turbo/ node_modules/ +apps/desktop/dist-electron/ +packages/*/node_modules/ +apps/*/node_modules/ +ee/apps/*/node_modules/ +ee/packages/*/node_modules/ .next/ out/ dist/ +packages/*/dist/ +apps/*/dist/ +ee/apps/*/dist/ +ee/packages/*/dist/ +tmp/ + +# Local git worktrees +_worktrees/ # Tauri/Rust -src-tauri/target/ +packages/desktop/src-tauri/target/ +packages/desktop/src-tauri/sidecars/ +apps/desktop/src-tauri/target/ +apps/desktop/src-tauri/sidecars/ +apps/desktop/resources/sidecars/ # Env .env .env.* +!.env.example +# The migration-release fragment is committed only on the tagged +# migration-release commit and removed by 03-post-migration-cleanup.mjs. +# Allow git to see it so `cut-migration-release` can commit it. +!.env.migration-release + +# Bun build artifacts +*.bun-build +apps/server/cli +apps/server-v2/openapi/openapi.json +packages/openwork-server-sdk/generated/ + +# pnpm store (created by Docker volume mounts) +.pnpm-store/ + +# Docker dev workspace (ephemeral mount point) +packaging/docker/workspace/ # OS .DS_Store @@ -19,3 +54,9 @@ vendor/opencode/ # OpenCode local deps .opencode/node_modules/ .opencode/bun.lock + +# OpenWork workspace-local artifacts +.opencode/openwork/ +.vercel +.env*.local +.claude/* diff --git a/.infisical.json b/.infisical.json new file mode 100644 index 0000000000..c91c16c84f --- /dev/null +++ b/.infisical.json @@ -0,0 +1,5 @@ +{ + "workspaceId": "e9f4542a-8714-46c3-a8fd-99d8cb370aeb", + "defaultEnvironment": "", + "gitBranchToEnvironmentMapping": null +} \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..6c2b9be4c4 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +link-workspace-packages=true +prefer-workspace-packages=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..a45fd52cc5 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.opencode/agent/css.md b/.opencode/agent/css.md new file mode 100644 index 0000000000..d5e68c7bf6 --- /dev/null +++ b/.opencode/agent/css.md @@ -0,0 +1,149 @@ +--- +description: use whenever you are styling a ui with css +--- + +you are very good at writing clean maintainable css using modern techniques + +css is structured like this + +```css +[data-page="home"] { + [data-component="header"] { + [data-slot="logo"] { + } + } +} +``` + +top level pages are scoped using `data-page` + +pages can break down into components using `data-component` + +components can break down into slots using `data-slot` + +structure things so that this hierarchy is followed IN YOUR CSS - you should rarely need to +nest components inside other components. you should NEVER nest components inside +slots. you should NEVER nest slots inside other slots. + +**IMPORTANT: This hierarchy rule applies to CSS structure, NOT JSX/DOM structure.** + +The hierarchy in css file does NOT have to match the hierarchy in the dom - you +can put components or slots at the same level in CSS even if one goes inside another in the DOM. + +Your JSX can nest however makes semantic sense - components can be inside slots, +slots can contain components, etc. The DOM structure should be whatever makes the most +semantic and functional sense. + +It is more important to follow the pages -> components -> slots structure IN YOUR CSS, +while keeping your JSX/DOM structure logical and semantic. + +use data attributes to represent different states of the component + +```css +[data-component="modal"] { + opacity: 0; + + &[data-state="open"] { + opacity: 1; + } +} +``` + +this will allow jsx to control the styling + +avoid selectors that just target an element type like `> span` you should assign +it a slot name. it's ok to do this sometimes where it makes sense semantically +like targeting `li` elements in a list + +in terms of file structure `./src/style/` contains all universal styling rules. +these should not contain anything specific to a page + +`./src/style/token` contains all the tokens used in the project + +`./src/style/component` is for reusable components like buttons or inputs + +page specific styles should go next to the page they are styling so +`./src/routes/about.tsx` should have its styles in `./src/routes/about.css` + +`about.css` should be scoped using `data-page="about"` + +## Example of correct implementation + +JSX can nest however makes sense semantically: + +```jsx +
+
Section Title
+
Content here
+
+``` + +CSS maintains clean hierarchy regardless of DOM nesting: + +```css +[data-page="home"] { + [data-component="screenshots"] { + [data-slot="left"] { + /* styles */ + } + [data-slot="content"] { + /* styles */ + } + } + + [data-component="title"] { + /* can be at same level even though nested in DOM */ + } +} +``` + +## Reusable Components + +If a component is reused across multiple sections of the same page, define it at the page level: + +```jsx + +
+
+

npm

+
+
+

bun

+
+
+ +
+
+
Screenshot Title
+
+
+``` + +```css +[data-page="home"] { + /* Reusable title component defined at page level since it's used in multiple components */ + [data-component="title"] { + text-transform: uppercase; + font-weight: 400; + } + + [data-component="install"] { + /* install-specific styles */ + } + + [data-component="screenshots"] { + /* screenshots-specific styles */ + } +} +``` + +This is correct because the `title` component has consistent styling and behavior across the page. + +## Key Clarifications + +1. **JSX Nesting is Flexible**: Components can be nested inside slots, slots can contain components - whatever makes semantic sense +2. **CSS Hierarchy is Strict**: Follow pages → components → slots structure in CSS +3. **Reusable Components**: Define at the appropriate level where they're shared (page level if used across the page, component level if only used within that component) +4. **DOM vs CSS Structure**: These don't need to match - optimize each for its purpose + +See ./src/routes/index.css and ./src/routes/index.tsx for a complete example. diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md new file mode 100644 index 0000000000..21cfc6a16e --- /dev/null +++ b/.opencode/agent/docs.md @@ -0,0 +1,34 @@ +--- +description: ALWAYS use this when writing docs +color: "#38A3EE" +--- + +You are an expert technical documentation writer + +You are not verbose + +Use a relaxed and friendly tone + +The title of the page should be a word or a 2-3 word phrase + +The description should be one short line, should not start with "The", should +avoid repeating the title of the page, should be 5-10 words long + +Chunks of text should not be more than 2 sentences long + +Each section is separated by a divider of 3 dashes + +The section titles are short with only the first letter of the word capitalized + +The section titles are in the imperative mood + +The section titles should not repeat the term used in the page title, for +example, if the page title is "Models", avoid using a section title like "Add +new models". This might be unavoidable in some cases, but try to avoid it. + +Check out the /packages/web/src/content/docs/docs/index.mdx as an example. + +For JS or TS code snippets remove trailing semicolons and any trailing commas +that might not be needed. + +If you are making a commit prefix the commit message with `docs:` diff --git a/.opencode/agent/duplicate-pr.md b/.opencode/agent/duplicate-pr.md new file mode 100644 index 0000000000..c9c932ef79 --- /dev/null +++ b/.opencode/agent/duplicate-pr.md @@ -0,0 +1,26 @@ +--- +mode: primary +hidden: true +model: opencode/claude-haiku-4-5 +color: "#E67E22" +tools: + "*": false + "github-pr-search": true +--- + +You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs. + +Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature. + +IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself. + +Search using keywords from the PR title and description. Try multiple searches with different relevant terms. + +If you find potential duplicates: + +- List them with their titles and URLs +- Briefly explain why they might be related + +If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups) + +Keep your response concise and actionable. diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md new file mode 100644 index 0000000000..5d1147a885 --- /dev/null +++ b/.opencode/agent/triage.md @@ -0,0 +1,78 @@ +--- +mode: primary +hidden: true +model: opencode/claude-haiku-4-5 +color: "#44BA81" +tools: + "*": false + "github-triage": true +--- + +You are a triage agent responsible for triaging github issues. + +Use your github-triage tool to triage issues. + +## Labels + +### windows + +Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows. + +- Use if they mention WSL too + +#### perf + +Performance-related issues: + +- Slow performance +- High RAM usage +- High CPU usage + +**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness. + +#### desktop + +Desktop app issues: + +- `opencode web` command +- The desktop app itself + +**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues. + +#### nix + +**Only** add if the issue explicitly mentions nix. + +#### zen + +**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". + +If the issue doesn't have "zen" or "opencode black" in it then don't add zen label + +#### docs + +Add if the issue requests better documentation or docs updates. + +#### opentui + +TUI issues potentially caused by our underlying TUI library: + +- Keybindings not working +- Scroll speed issues (too fast/slow/laggy) +- Screen flickering +- Crashes with opentui in the log + +**Do not** add for general TUI bugs. + +When assigning to people here are the following rules: + +adamdotdev: +ONLY assign adam if the issue will have the "desktop" label. + +fwang: +ONLY assign fwang if the issue will have the "zen" label. + +jayair: +ONLY assign jayair if the issue will have the "docs" label. + +In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node. diff --git a/.opencode/commands/browser-setup.md b/.opencode/commands/browser-setup.md new file mode 100644 index 0000000000..240878a971 --- /dev/null +++ b/.opencode/commands/browser-setup.md @@ -0,0 +1,9 @@ +--- +name: browser-setup +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 only). +Keep the user prompt minimal and let the skill drive the setup dance. diff --git a/.opencode/commands/hello-stranger.md b/.opencode/commands/hello-stranger.md new file mode 100644 index 0000000000..47802aa711 --- /dev/null +++ b/.opencode/commands/hello-stranger.md @@ -0,0 +1,4 @@ +--- +description: Say hello stranger +--- +hello stranger diff --git a/.opencode/commands/release.md b/.opencode/commands/release.md new file mode 100644 index 0000000000..9b78a6087e --- /dev/null +++ b/.opencode/commands/release.md @@ -0,0 +1,22 @@ +--- +description: Run the OpenWork release flow +--- + +You are running the OpenWork release flow in this repo. + +Arguments: `$ARGUMENTS` +- If empty, default to a patch release. +- If set to `minor` or `major`, use that bump type. + +Do the following, in order, and stop on any failure: + +1. Sync `dev` and ensure the working tree is clean. +2. Bump app/desktop versions using `pnpm bump:$ARGUMENTS` (or `pnpm bump:patch` if empty). +3. If any dependencies were pinned or changed, run `pnpm install --lockfile-only`. +4. Run `pnpm release:review` and resolve any mismatches. +5. Tag and push: `git tag vX.Y.Z` and `git push origin vX.Y.Z`, then `git push origin dev`. +6. Watch the Release App GitHub Actions workflow to completion. +7. If releasing openwork-orchestrator sidecars, build deterministically with `SOURCE_DATE_EPOCH`, upload assets to `openwork-orchestrator-vX.Y.Z`, and publish `openwork-orchestrator`. +8. If `openwork-server` or `opencode-router` versions changed, publish those packages. + +Report what you changed, the tag created, and the GHA status. diff --git a/.opencode/openwork.json b/.opencode/openwork.json new file mode 100644 index 0000000000..5cf9aba26b --- /dev/null +++ b/.opencode/openwork.json @@ -0,0 +1,5 @@ +{ + "messaging": { + "enabled": true + } +} diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000000..46ddb1c8a0 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.4.9" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.9.tgz", + "integrity": "sha512-tUtPbPs5xP9wonwuz5d/2y8QTrqFR8HOtAVTXvZ6iG26NJfW0dnnw9oTusVOayEIemd5abytCESm7X9ZZOMftQ==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.4.9", + "effect": "4.0.0-beta.48", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.100", + "@opentui/solid": ">=0.1.100" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.9.tgz", + "integrity": "sha512-S8WQLuBFu2WwvSc1wupsV4qskniBA+JN1VaZZs52BPWwiN2zQFTD5/6dMh6oiYOMDtPjKsTFZ6qLFxDvVPNggQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.48", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", + "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", + "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "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/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "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/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "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/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.opencode/skill/release/SKILL.md b/.opencode/skill/release/SKILL.md deleted file mode 100644 index ba630cd348..0000000000 --- a/.opencode/skill/release/SKILL.md +++ /dev/null @@ -1,97 +0,0 @@ -# release - -Create a human-friendly **unsigned macOS DMG** release for OpenWork (or similar Tauri apps), and publish it on GitHub. - -This skill is intentionally lightweight: it’s mostly a checklist + a couple of sanity scripts. - -## What this skill is for - -- You have a Tauri app. -- You want to publish a **DMG** on GitHub Releases. -- You are **not** code signing / notarizing yet (so macOS will warn users). - -## Prereqs - -- `pnpm` -- Rust toolchain (`cargo`, `rustc`) -- `gh` authenticated (`gh auth status`) -- macOS tools: `codesign`, `spctl`, `hdiutil` - -## Release checklist (recommended) - -### 1) Clean working tree - -```bash -git status -``` - -### 2) Bump version everywhere - -- `package.json` (`version`) -- `src-tauri/tauri.conf.json` (`version`) -- `src-tauri/Cargo.toml` (`version`) - -### 3) Validate builds - -```bash -pnpm typecheck -pnpm build:web -cargo check --manifest-path src-tauri/Cargo.toml -``` - -### 4) Build DMG - -```bash -pnpm tauri build --bundles dmg -``` - -This should produce something like: - -- `src-tauri/target/release/bundle/dmg/OpenWork__aarch64.dmg` - -### 5) Verify “unsigned” state - -Unsigned here means: **not Developer ID signed / not notarized**. - -Quick checks: - -```bash -# mount the dmg read-only -hdiutil attach -nobrowse -readonly "src-tauri/target/release/bundle/dmg/.dmg" - -# verify signature details (expect ad-hoc or not notarized) -codesign -dv --verbose=4 "/Volumes//.app" - -# gatekeeper assessment (expect rejected) -spctl -a -vv "/Volumes//.app" || true - -# unmount -hdiutil detach "/Volumes/" -``` - -### 6) Tag + push - -```bash -git commit -am "Prepare vX.Y.Z release" -git tag -a vX.Y.Z -m "OpenWork vX.Y.Z" -git push -git push origin vX.Y.Z -``` - -### 7) Create / update GitHub Release - -```bash -gh release create vX.Y.Z \ - --title "OpenWork vX.Y.Z" \ - --notes "" - -gh release upload vX.Y.Z "src-tauri/target/release/bundle/dmg/.dmg" --clobber -``` - -## Local helper scripts - -- `bun .opencode/skill/release/first-call.ts` checks prerequisites and prints the current version. - -## Notes - -- If you later add signing/notarization, this skill should be updated to include that flow. diff --git a/.opencode/skill/release/client.ts b/.opencode/skill/release/client.ts deleted file mode 100644 index bbc206e104..0000000000 --- a/.opencode/skill/release/client.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { spawn } from "node:child_process"; - -export async function run( - command: string, - args: string[], - options?: { cwd?: string; allowFailure?: boolean }, -): Promise<{ ok: boolean; code: number; stdout: string; stderr: string }> { - const cwd = options?.cwd; - - const child = spawn(command, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - env: process.env, - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - - child.stdout.on("data", (d) => (stdout += d)); - child.stderr.on("data", (d) => (stderr += d)); - - const code = await new Promise((resolve) => { - child.on("close", (c) => resolve(c ?? -1)); - }); - - const ok = code === 0; - if (!ok && !options?.allowFailure) { - throw new Error( - `Command failed (${code}): ${command} ${args.join(" ")}\n${stderr || stdout}`, - ); - } - - return { ok, code, stdout, stderr }; -} diff --git a/.opencode/skill/release/first-call.ts b/.opencode/skill/release/first-call.ts deleted file mode 100644 index 26093d4c5a..0000000000 --- a/.opencode/skill/release/first-call.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { loadEnv } from "./load-env"; -import { run } from "./client"; - -async function main() { - await loadEnv(); - - await run("gh", ["auth", "status"], { allowFailure: false }); - - const pkgRaw = await readFile("package.json", "utf8"); - const pkg = JSON.parse(pkgRaw) as { name?: string; version?: string }; - - console.log( - JSON.stringify( - { - ok: true, - package: pkg.name ?? null, - version: pkg.version ?? null, - next: [ - "pnpm typecheck", - "pnpm tauri build --bundles dmg", - "gh release upload vX.Y.Z --clobber", - ], - }, - null, - 2, - ), - ); -} - -main().catch((e) => { - const message = e instanceof Error ? e.message : String(e); - console.error(message); - process.exit(1); -}); diff --git a/.opencode/skill/release/load-env.ts b/.opencode/skill/release/load-env.ts deleted file mode 100644 index e8fec9d65a..0000000000 --- a/.opencode/skill/release/load-env.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { run } from "./client"; - -const REQUIRED = [ - "pnpm", - "cargo", - "gh", - "hdiutil", - "codesign", - "spctl", -]; - -export async function loadEnv() { - const missing: string[] = []; - - for (const bin of REQUIRED) { - try { - await run("/usr/bin/env", ["bash", "-lc", `command -v ${bin}`], { allowFailure: false }); - } catch { - missing.push(bin); - } - } - - if (missing.length) { - throw new Error(`Missing required tools: ${missing.join(", ")}`); - } - - return { ok: true as const }; -} diff --git a/.opencode/skills/browser-setup-devtools/SKILL.md b/.opencode/skills/browser-setup-devtools/SKILL.md new file mode 100644 index 0000000000..4daca7d74a --- /dev/null +++ b/.opencode/skills/browser-setup-devtools/SKILL.md @@ -0,0 +1,37 @@ +--- +name: browser-setup-devtools +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 (Chrome DevTools MCP) + +## Principles + +- Keep prompts minimal; do as much as possible with tools and commands. +- Use Chrome DevTools MCP only. + +## Workflow + +1. Ask: "Do you have Chrome installed on this computer?" +2. If no or unsure: + - Offer to open the download page yourself and do it if possible. + - Provide a clickable link: https://www.google.com/chrome/ + - Continue after installation is confirmed. +3. Check DevTools MCP availability: + - Call `chrome-devtools_list_pages`. + - If pages exist, select one with `chrome-devtools_select_page`. + - If no pages, create one with `chrome-devtools_new_page` (use https://example.com) and then select it. +4. If DevTools MCP calls fail: + - Ask the user to open Chrome and keep it running. + - Retry `chrome-devtools_list_pages`. + - If it still fails, ensure `opencode.jsonc` includes `mcp["chrome-devtools"]` with command `['npx', '-y', 'chrome-devtools-mcp@latest']` and ask the user to restart OpenWork/OpenCode. + - Retry the DevTools MCP check. +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. + +## Response rules + +- Keep each user prompt to one short sentence when possible. +- Use direct offers like "I can open Chrome now" and follow with tool actions. +- Always present links as clickable URLs. diff --git a/.opencode/skills/cargo-lock-manager/.gitignore b/.opencode/skills/cargo-lock-manager/.gitignore new file mode 100644 index 0000000000..2334d82b84 --- /dev/null +++ b/.opencode/skills/cargo-lock-manager/.gitignore @@ -0,0 +1,2 @@ +.env +*.log diff --git a/.opencode/skills/cargo-lock-manager/SKILL.md b/.opencode/skills/cargo-lock-manager/SKILL.md new file mode 100644 index 0000000000..e7522f8d2d --- /dev/null +++ b/.opencode/skills/cargo-lock-manager/SKILL.md @@ -0,0 +1,67 @@ +--- +name: cargo-lock-manager +description: | + Manages Cargo.lock file updates and resolves --locked flag issues in CI/CD. + + Triggers when user mentions: + - "cargo test --locked failed" + - "cannot update the lock file" + - "Cargo.lock is out of date" + - "PR failed with --locked error" + - "fix Cargo.lock" +--- + +## Quick Usage (Already Configured) + +### Check Cargo.lock status +```bash +cd packages/desktop/src-tauri +cargo check --locked 2>&1 | head -20 +``` + +### Update Cargo.lock locally +```bash +cd packages/desktop/src-tauri +cargo update --workspace +``` + +### Test with --locked after update +```bash +cd packages/desktop/src-tauri +cargo test --locked +``` + +## Common Gotchas + +- The `--locked` flag prevents automatic updates to Cargo.lock, which is good for reproducible builds but fails when dependencies change. +- PRs often fail because the lock file wasn't committed after dependency updates. +- Running `cargo update` without `--workspace` may not update all workspace members. + +## When CI Fails with --locked + +### Option 1: Update lock file and commit (Recommended) +```bash +cd packages/desktop/src-tauri +cargo update --workspace +git add Cargo.lock +git commit -m "chore: update Cargo.lock" +git push +``` + +### Option 2: Use --offline flag (for air-gapped environments) +```bash +cargo test --manifest-path packages/desktop/src-tauri/Cargo.toml --offline +``` + +## First-Time Setup (If Not Configured) + +No setup required. This skill assumes: +- Rust/Cargo is installed +- You're in the openwork repository +- The Tauri app is in `packages/desktop/src-tauri/` + +## Prevention Tips + +- Always run `cargo check` or `cargo build` after modifying `Cargo.toml` files +- Include `Cargo.lock` changes in the same commit as dependency updates +- Consider adding a pre-commit hook to verify lock file is up to date diff --git a/.opencode/skills/cargo-lock-manager/scripts/check-lock.sh b/.opencode/skills/cargo-lock-manager/scripts/check-lock.sh new file mode 100755 index 0000000000..4681a1edb1 --- /dev/null +++ b/.opencode/skills/cargo-lock-manager/scripts/check-lock.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Check if Cargo.lock is up to date with --locked flag +# Exit 0 if OK, exit 1 if needs update + +set -e + +CARGO_TOML="${1:-apps/desktop/src-tauri/Cargo.toml}" + +echo "🔍 Checking Cargo.lock status for: $CARGO_TOML" + +if cargo check --manifest-path "$CARGO_TOML" --locked 2>&1; then + echo "✅ Cargo.lock is up to date" + exit 0 +else + echo "❌ Cargo.lock needs update" + echo "" + echo "To fix, run:" + echo " cd $(dirname "$CARGO_TOML") && cargo update --workspace" + exit 1 +fi diff --git a/.opencode/skills/cargo-lock-manager/scripts/update-lock.sh b/.opencode/skills/cargo-lock-manager/scripts/update-lock.sh new file mode 100755 index 0000000000..dd2bea69ba --- /dev/null +++ b/.opencode/skills/cargo-lock-manager/scripts/update-lock.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Update Cargo.lock for the Tauri workspace + +set -e + +CARGO_TOML="${1:-apps/desktop/src-tauri/Cargo.toml}" +WORKDIR=$(dirname "$CARGO_TOML") + +echo "📦 Updating Cargo.lock in: $WORKDIR" + +cd "$WORKDIR" +cargo update --workspace + +echo "" +echo "✅ Cargo.lock updated" +echo "📝 Don't forget to commit the changes!" diff --git a/.opencode/skills/get-started/SKILL.md b/.opencode/skills/get-started/SKILL.md new file mode 100644 index 0000000000..b8a4848ff2 --- /dev/null +++ b/.opencode/skills/get-started/SKILL.md @@ -0,0 +1,22 @@ +--- +name: get-started +description: Guide users through the get started setup and Chrome DevTools demo. +--- + +## When to use + +- Always load this skill when the user says "get started". + +## What to do + +- Reply with these four lines, exactly and in order: + + 1) hey there welcome this is openwork + 2) we've pre-configured you with a couple tools + 3) Get Started + 4) write "hey go on google.com" + +## Then + +- If the user writes "go on google.com" (or "hey go on google.com"), use the chrome-devtools MCP to open the site. +- After the navigation completes, reply: "I'm on " where is the final URL or page title they asked for. diff --git a/.opencode/skill/opencode-bridge/SKILL.md b/.opencode/skills/opencode-bridge/SKILL.md similarity index 100% rename from .opencode/skill/opencode-bridge/SKILL.md rename to .opencode/skills/opencode-bridge/SKILL.md diff --git a/.opencode/skill/opencode-mirror/SKILL.md b/.opencode/skills/opencode-mirror/SKILL.md similarity index 86% rename from .opencode/skill/opencode-mirror/SKILL.md rename to .opencode/skills/opencode-mirror/SKILL.md index 9d5c579864..cf3359934e 100644 --- a/.opencode/skill/opencode-mirror/SKILL.md +++ b/.opencode/skills/opencode-mirror/SKILL.md @@ -19,5 +19,5 @@ git -C vendor/opencode pull --ff-only ### Clone mirror ```bash -git clone https://github.com/opencode-ai/opencode vendor/opencode +git clone https://github.com/anomalyco/opencode vendor/opencode ``` diff --git a/.opencode/skills/opencode-primitives/SKILL.md b/.opencode/skills/opencode-primitives/SKILL.md new file mode 100644 index 0000000000..c35e478c4e --- /dev/null +++ b/.opencode/skills/opencode-primitives/SKILL.md @@ -0,0 +1,47 @@ +--- +name: opencode-primitives +description: Reference OpenCode docs when implementing skills, plugins, MCPs, or config-driven behavior. +--- + +## Purpose +Use this skill whenever OpenWork behavior is implemented directly on top of OpenCode primitives (skills, plugins, MCP servers, opencode.json config, tools/permissions). It anchors decisions to the official OpenCode documentation and keeps terminology consistent in the UI. + +## Doc Sources (Always cite when relevant) +- Skills: https://opencode.ai/docs/skills +- Plugins: https://opencode.ai/docs/plugins/ +- MCP servers: https://opencode.ai/docs/mcp-servers/ +- Config (opencode.json, locations, precedence): https://opencode.ai/docs/config/ + +## Key Facts To Apply +### Skills +- Skill files live in `.opencode/skills//SKILL.md` or global `~/.config/opencode/skills//SKILL.md`. +- Skills are discovered by walking up to the git worktree and loading any matching `skills/*/SKILL.md` in `.opencode/` or `.claude/skills/`. +- `SKILL.md` requires YAML frontmatter: `name` + `description`. +- Name rules: lowercase alphanumeric with single hyphens (`^[a-z0-9]+(-[a-z0-9]+)*$`), length 1-64, must match directory name. +- Description length: 1-1024 characters. +- Access is governed by `opencode.json` permissions (`permission.skill` allow/deny/ask). + +### Plugins +- Local plugins live in `.opencode/plugins/` (project) or `~/.config/opencode/plugins/` (global). +- npm plugins are listed in `opencode.json` under `plugin` and installed with Bun at startup. +- Load order: global config, project config, global plugins dir, project plugins dir. + +### MCP Servers +- MCP servers are defined in `opencode.json` under `mcp` with unique names. +- Local servers use `type: "local"` + `command` array; remote servers use `type: "remote"` + `url`. +- Servers can be enabled/disabled via `enabled`. +- MCP tools are managed via `tools` in config, including glob patterns. +- OAuth is handled automatically for remote servers; can be pre-registered or disabled. + +### Config (opencode.json) +- Supports JSON and JSONC. +- Precedence order: remote `.well-known/opencode` -> global `~/.config/opencode/opencode.json` -> custom path -> project `opencode.json` -> `.opencode/` directories -> inline env overrides. +- `.opencode` subdirectories are plural by default (`agents/`, `commands/`, `plugins/`, `skills/`, `tools/`, `themes/`), with singular names supported for compatibility. + +## When to Invoke +- Adding or adjusting OpenWork flows that reference skills, plugins, MCP servers, or OpenCode config. +- Designing onboarding guidance that mentions skill/plugin installation, config locations, or permission prompts. +- Implementing UIs that surface OpenCode primitives (skills tab, plugin manager, MCP toggles). + +## Usage +Call `skill({ name: "opencode-primitives" })` before implementing or documenting any OpenWork behavior that maps to OpenCode primitives. diff --git a/.opencode/skill/openwork-core/SKILL.md b/.opencode/skills/openwork-core/SKILL.md similarity index 86% rename from .opencode/skill/openwork-core/SKILL.md rename to .opencode/skills/openwork-core/SKILL.md index cd92a4660e..ac668d2fde 100644 --- a/.opencode/skill/openwork-core/SKILL.md +++ b/.opencode/skills/openwork-core/SKILL.md @@ -6,7 +6,7 @@ description: Core context and guardrails for OpenWork native app ## Quick Usage (Already Configured) ### Orientation -- Read `AGENTS.md` and `design-prd.md` before changing behavior. +- Read `AGENTS.md`, `VISION.md`, `PRINCIPLES.md`, `PRODUCT.md`, and `ARCHITECTURE.md` before changing behavior. - Ensure `vendor/opencode` exists for self-reference. - Use the `tauri-solidjs` skill for stack-specific guidance. @@ -20,6 +20,9 @@ git -C vendor/opencode pull --ff-only pnpm tauri dev # Desktop development pnpm tauri ios dev # iOS development pnpm tauri android dev # Android development + +# Or run directly in the desktop package: +pnpm -C packages/desktop tauri dev ``` ## OpenCode Integration @@ -58,7 +61,7 @@ opencode -p "your prompt" -f json -q ### Clone the OpenCode mirror ```bash -git clone https://github.com/opencode-ai/opencode vendor/opencode +git clone https://github.com/anomalyco/opencode vendor/opencode ``` ### Initialize Tauri project @@ -81,5 +84,5 @@ pnpm tauri android init ### Clone the OpenCode mirror ```bash -git clone https://github.com/opencode-ai/opencode vendor/opencode +git clone https://github.com/anomalyco/opencode vendor/opencode ``` diff --git a/.opencode/skills/openwork-debug/SKILL.md b/.opencode/skills/openwork-debug/SKILL.md new file mode 100644 index 0000000000..f0615605e2 --- /dev/null +++ b/.opencode/skills/openwork-debug/SKILL.md @@ -0,0 +1,59 @@ +--- +name: openwork-debug +description: Debug OpenWork sidecars, config, and audit trail +--- + +## Credential check + +Set these before running the HTTP checks: + +- `OPENWORK_SERVER_URL` +- `OPENWORK_SERVER_TOKEN` +- `OPENWORK_WORKSPACE_ID` (optional; use `/workspaces` to discover) + +## Quick usage (read-only) + +```bash +curl -s "$OPENWORK_SERVER_URL/health" +curl -s "$OPENWORK_SERVER_URL/capabilities" \ + -H "Authorization: Bearer $OPENWORK_SERVER_TOKEN" + +curl -s "$OPENWORK_SERVER_URL/workspaces" \ + -H "Authorization: Bearer $OPENWORK_SERVER_TOKEN" +``` + +## Workspace config snapshot + +```bash +curl -s "$OPENWORK_SERVER_URL/workspace/$OPENWORK_WORKSPACE_ID/config" \ + -H "Authorization: Bearer $OPENWORK_SERVER_TOKEN" +``` + +## Audit log (recent) + +```bash +curl -s "$OPENWORK_SERVER_URL/workspace/$OPENWORK_WORKSPACE_ID/audit?limit=25" \ + -H "Authorization: Bearer $OPENWORK_SERVER_TOKEN" +``` + +## OpenCode engine checks + +```bash +opencode -p "ping" -f json -q +opencode mcp list +opencode mcp debug +``` + +## DB fallback (read-only) + +When the engine API is unavailable, you can inspect the SQLite db: + +```bash +sqlite3 ~/.opencode/opencode.db "select id, title, status from sessions order by updated_at desc limit 5;" +sqlite3 ~/.opencode/opencode.db "select role, content from messages order by created_at desc limit 10;" +``` + +## Notes + +- Audit logs are stored at `.opencode/openwork/audit.jsonl` in the workspace root. +- OpenWork server writes only within approved workspace roots. diff --git a/.opencode/skills/openwork-orchestrator-npm-publish/.env.example b/.opencode/skills/openwork-orchestrator-npm-publish/.env.example new file mode 100644 index 0000000000..8c3e04f3cb --- /dev/null +++ b/.opencode/skills/openwork-orchestrator-npm-publish/.env.example @@ -0,0 +1 @@ +NPM_TOKEN= diff --git a/.opencode/skills/openwork-orchestrator-npm-publish/.gitignore b/.opencode/skills/openwork-orchestrator-npm-publish/.gitignore new file mode 100644 index 0000000000..4c49bd78f1 --- /dev/null +++ b/.opencode/skills/openwork-orchestrator-npm-publish/.gitignore @@ -0,0 +1 @@ +.env diff --git a/.opencode/skills/openwork-orchestrator-npm-publish/SKILL.md b/.opencode/skills/openwork-orchestrator-npm-publish/SKILL.md new file mode 100644 index 0000000000..fb3e5cbedf --- /dev/null +++ b/.opencode/skills/openwork-orchestrator-npm-publish/SKILL.md @@ -0,0 +1,83 @@ +--- +name: openwork-orchestrator-npm-publish +description: | + Publish the openwork-orchestrator npm package with clean git hygiene. + + Triggers when user mentions: + - "openwork-orchestrator npm publish" + - "publish openwork-orchestrator" + - "bump openwork-orchestrator" +--- + +## Quick usage (already configured) + +1. Ensure you are on the default branch and the tree is clean. +2. Bump versions via the shared release bump (this keeps `openwork-orchestrator` aligned with the app/desktop release). + +```bash +pnpm bump:patch +# or: pnpm bump:minor +# or: pnpm bump:major +# or: pnpm bump:set -- X.Y.Z +``` + +3. Commit the bump. +4. Preferred: publish via the "Release App" GitHub Actions workflow by tagging `vX.Y.Z`. + +Manual recovery path (sidecars + npm) below. + +```bash +pnpm --filter openwork-orchestrator build:sidecars +gh release create openwork-orchestrator-vX.Y.Z packages/orchestrator/dist/sidecars/* \ + --repo different-ai/openwork \ + --title "openwork-orchestrator vX.Y.Z sidecars" \ + --notes "Sidecar binaries and manifest for openwork-orchestrator vX.Y.Z" +``` + +5. Build openwork-orchestrator binaries for all supported platforms. + +```bash +pnpm --filter openwork-orchestrator build:bin:all +``` + +6. Publish `openwork-orchestrator` as a meta package + platform packages (optionalDependencies). + +```bash +node packages/orchestrator/scripts/publish-npm.mjs +``` + +7. Verify the published version. + +```bash +npm view openwork-orchestrator version +``` + +--- + +## Scripted publish + +```bash +./.opencode/skills/openwork-orchestrator-npm-publish/scripts/publish-openwork-orchestrator.sh +``` + +--- + +## First-time setup (if not configured) + +Authenticate with npm before publishing. + +```bash +npm login +``` + +Alternatively, export an npm token in your environment (see `.env.example`). + +--- + +## Notes + +- `openwork-orchestrator` is published as: + - `openwork-orchestrator` (wrapper + optionalDependencies) + - `openwork-orchestrator-darwin-arm64`, `openwork-orchestrator-darwin-x64`, `openwork-orchestrator-linux-arm64`, `openwork-orchestrator-linux-x64`, `openwork-orchestrator-windows-x64` (platform binaries) +- `openwork-orchestrator` is versioned in lockstep with OpenWork app/desktop releases. +- openwork-orchestrator downloads sidecars from `openwork-orchestrator-vX.Y.Z` release assets by default. diff --git a/.opencode/skills/openwork-orchestrator-npm-publish/scripts/publish-openwork-orchestrator.sh b/.opencode/skills/openwork-orchestrator-npm-publish/scripts/publish-openwork-orchestrator.sh new file mode 100755 index 0000000000..dc6a6749e5 --- /dev/null +++ b/.opencode/skills/openwork-orchestrator-npm-publish/scripts/publish-openwork-orchestrator.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +cd "$root" + +if [ -n "$(git status --porcelain)" ]; then + echo "Working tree is dirty. Commit or stash before publish." + exit 1 +fi + +version=$(node -p "require('./apps/orchestrator/package.json').version") +echo "Publishing openwork-orchestrator@$version" + +pnpm --filter openwork-orchestrator publish --access public diff --git a/.opencode/skills/release/SKILL.md b/.opencode/skills/release/SKILL.md new file mode 100644 index 0000000000..97bd034bf0 --- /dev/null +++ b/.opencode/skills/release/SKILL.md @@ -0,0 +1,54 @@ +--- +title: Release flow +description: Step through versioning, tagging, and verification +name: release +--- + +## Prepare +Confirm the repo is on `main` and clean. Keep changes aligned with OpenCode primitives like `.opencode`, `opencode.json`, skills, and plugins when relevant. + +--- + +## Bump +Update versions in `packages/app/package.json`, `packages/desktop/package.json`, `packages/orchestrator/package.json` (publishes as `openwork-orchestrator`), `packages/desktop/src-tauri/tauri.conf.json`, and `packages/desktop/src-tauri/Cargo.toml`. Use one of these commands. + +```bash +pnpm bump:patch +pnpm bump:minor +pnpm bump:major +pnpm bump:set -- 0.1.21 +``` + +--- + +## Merge +Merge the version bump into `main`. Make sure no secrets or credentials are committed. + +--- + +## Tag +Create and push the tag to trigger the Release App workflow. + +```bash +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +--- + +## Rerun +If a tag needs a rerun, dispatch the workflow. + +```bash +gh workflow run "Release App" --repo different-ai/openwork -f tag=vX.Y.Z +``` + +--- + +## Verify +Confirm the run and the published release. + +```bash +gh run list --repo different-ai/openwork --workflow "Release App" --limit 5 +gh release view vX.Y.Z --repo different-ai/openwork +``` diff --git a/.opencode/skills/solidjs-patterns/SKILL.md b/.opencode/skills/solidjs-patterns/SKILL.md new file mode 100644 index 0000000000..949a953ded --- /dev/null +++ b/.opencode/skills/solidjs-patterns/SKILL.md @@ -0,0 +1,93 @@ +--- +name: solidjs-patterns +description: SolidJS reactivity + UI state patterns for OpenWork +--- + +## Why this skill exists + +OpenWork’s UI is SolidJS: it updates via **signals**, not React-style rerenders. +Most “UI stuck” bugs are actually **state coupling** bugs (e.g. one global `busy()` disabling an unrelated action), not rerender issues. + +This skill captures the patterns we want to consistently use in OpenWork. + +## Core rules + +- Prefer **fine-grained signals** over shared global flags. +- Keep async actions **scoped** (each action gets its own `pending` state). +- Derive UI state via `createMemo()` instead of duplicating booleans. +- Avoid mutating arrays/objects stored in signals; always create new values. + +## Scoped async actions (recommended) + +When an operation can overlap with others (permissions, installs, background refresh), don’t reuse a global `busy()`. + +Use a dedicated signal per action: + +```ts +const [replying, setReplying] = createSignal(false); + +async function respond() { + if (replying()) return; + setReplying(true); + try { + await doTheThing(); + } finally { + setReplying(false); + } +} +``` + +### Why + +A single `busy()` boolean creates deadlocks: + +- Long-running task sets `busy(true)` +- A permission prompt appears and its buttons are disabled by `busy()` +- The task can’t continue until permission is answered +- The user can’t answer because buttons are disabled + +Fix: permission UI must be disabled only by a **permission-specific** pending state. + +## Signal snapshots in async handlers + +If you read signals inside an async function and you need stable values, snapshot early: + +```ts +const request = activePermission(); +if (!request) return; +const requestID = request.id; + +await respondPermission(requestID, "always"); +``` + +## Derived UI state + +Prefer `createMemo()` for computed disabled states: + +```ts +const canSend = createMemo(() => prompt().trim().length > 0 && !busy()); +``` + +## Lists + +- Use setter callbacks for derived updates: + +```ts +setItems((current) => current.filter((x) => x.id !== id)); +``` + +- Don’t mutate `current` in-place. + +## Practical checklist (SolidJS UI changes) + +- Does any button depend on a global flag that could be true during long-running work? +- Could two async actions overlap and fight over one boolean? +- Is any UI state duplicated (can be derived instead)? +- Do event handlers read signals after an `await` where values might have changed? +- If you refactor props/types, did you update all intermediate component signatures and call sites? + +## References + +- SolidJS: https://www.solidjs.com/docs/latest +- SolidJS signals: https://www.solidjs.com/docs/latest/api#createsignal +- SolidJS memos: https://www.solidjs.com/docs/latest/api#creatememo diff --git a/.opencode/skill/tauri-solidjs/SKILL.md b/.opencode/skills/tauri-solidjs/SKILL.md similarity index 78% rename from .opencode/skill/tauri-solidjs/SKILL.md rename to .opencode/skills/tauri-solidjs/SKILL.md index f3c34e4372..c6a5c469f3 100644 --- a/.opencode/skill/tauri-solidjs/SKILL.md +++ b/.opencode/skills/tauri-solidjs/SKILL.md @@ -34,25 +34,28 @@ pnpm tauri android build ## Project Structure ``` -apps/openwork/ - src-tauri/ - src/ - main.rs # Rust entry point - lib.rs # Tauri commands and state - Cargo.toml # Rust dependencies - tauri.conf.json # Tauri configuration - capabilities/ # Permission capabilities - src/ - App.tsx # SolidJS root component - index.tsx # Entry point - components/ # UI components - stores/ # Solid stores for state - lib/ # Utilities and OpenCode bridge - index.html # HTML template - package.json # Frontend dependencies - vite.config.ts # Vite configuration +openwork/ + packages/ + desktop/ + src-tauri/ + src/ + main.rs # Rust entry point + lib.rs # Tauri commands and state + Cargo.toml # Rust dependencies + tauri.conf.json # Tauri configuration + capabilities/ # Permission capabilities + src/ + App.tsx # SolidJS root component + index.tsx # Entry point + components/ # UI components + stores/ # Solid stores for state + lib/ # Utilities and OpenCode bridge + index.html # HTML template + package.json # Frontend dependencies + vite.config.ts # Vite configuration ``` + ## Key Dependencies ### Frontend (package.json) @@ -88,7 +91,7 @@ serde_json = "1" ## Tauri Commands (Rust -> JS) ```rust -// src-tauri/src/lib.rs +// packages/desktop/src-tauri/src/lib.rs use tauri::Manager; #[tauri::command] diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000000..8de21fa1de --- /dev/null +++ b/.vercelignore @@ -0,0 +1,15 @@ +_archive +_worktrees +.git +.opencode +vendor +**/node_modules +**/.next +**/dist +**/.*.bun-build +packages/agent-lab +packages/desktop +packages/headless +packages/opencode-router +packages/owpenbot +packages/server diff --git a/AGENTS.md b/AGENTS.md index 85537a2006..c80b2be216 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,86 +1,272 @@ # AGENTS.md -OpenWork exists to bring OpenCode's agentic power to non-technical people through an accessible, transparent **native GUI**. It is an open-source competitor to Anthropic's Cowork and must stay faithful to OpenCode's principles: self-building, self-referential, standards-first, and graceful degradation. +OpenWork helps users run agents, skills, and MCP. It is an open-source alternative to Claude Cowork/Codex as a desktop app. + +## What OpenWork Is + +OpenWork is a practical control surface for agentic work: + +* Run local and remote agent workflows from one place. +* Use OpenCode capabilities directly through OpenWork. +* Compose desktop app, server, and messaging connectors without lock-in. +* Treat the OpenWork app as a client of the OpenWork server API surface. +* Connect to hosted workers through a simple user flow: `Add a worker` -> `Connect remote`. + +## Core Philosophy + +* **Local-first, cloud-ready**: OpenWork runs on your machine in one click and can connect to cloud workflows when needed. +* **Server-consumption first**: the app should consume OpenWork server surfaces (self-hosted or hosted), not invent parallel behavior. +* **Composable**: use the desktop app, WhatsApp/Slack/Telegram connectors, or server mode based on the task. +* **Ejectable**: OpenWork is powered by OpenCode, so anything OpenCode can do is available in OpenWork, even before a dedicated UI exists. +* **Sharing is caring**: start solo, then share quickly; one CLI or desktop command can spin up an instantly shareable instance. + +## Core Runtime Model (Updated) + +OpenWork now has three production-grade ways to run the same product surface: + +1. **Desktop-hosted app/server** + - OpenWork app runs locally and can host server functionality on-device. +2. **CLI-hosted server (openwork-orchestrator)** + - OpenWork server surfaces can be provided by the orchestrator/CLI on a trusted machine. +3. **Hosted OpenWork Cloud server** + - OpenWork-hosted infrastructure provisions workers and exposes the same remote-connect semantics. + +User mental model: + +* The app is the UI and control layer. +* The server is the execution/control API layer. +* A worker is a remote runtime destination. +* Connecting to a worker happens through `Add worker` -> `Connect remote` using URL + token (or deep link). + +Read `ARCHITECTURE.md` for runtime flow, server-vs-shell ownership, and architecture behavior. Read `INFRASTRUCTURE.md` for deployment and control-plane details. ## Why OpenWork Exists -1. **OpenCode is powerful but terminal-only.** Non-technical users can't access it. -2. **Cowork is closed-source and locked to Claude Max.** We need an open alternative. -3. **Mobile-first matters.** People want to run tasks from their phones. -4. **Slick UI is non-negotiable.** The experience must feel premium, not utilitarian. +**Cowork is closed-source and locked to Claude Max.** We need an open alternative. +**Mobile-first matters.** People want to run tasks from their phones, including via messaging surfaces like WhatsApp and Telegram through OpenCode Router. +**Slick UI is non-negotiable.** The experience must feel premium, not utilitarian. + +## Agent Guidelines for development + +* **Purpose-first UI**: prioritize clarity, safety, and approachability for non-technical users. +* **Parity with OpenCode**: anything the UI can do must map cleanly to OpenCode tools. +* **Prefer OpenCode primitives**: represent concepts using OpenCode's native surfaces first (folders/projects, `.opencode`, `opencode.json`, skills, plugins) before introducing new abstractions. +* **Web parity**: anything that mutates `.opencode/` should be expressible via the OpenWork server API; Tauri-only filesystem calls are a fallback for host mode, not a separate capability set. +* **Self-referential**: maintain a gitignored mirror of OpenCode at `vendor/opencode` for inspection. +* **Self-building**: prefer prompts, skills, and composable primitives over bespoke logic. +* **Open source**: keep the repo portable; no secrets committed. +* **Slick and fluid**: 60fps animations, micro-interactions, premium feel. +* **Mobile-native**: touch targets, gestures, and layouts optimized for small screens. + +## Task Intake (Required) + +Before making changes, explicitly confirm the target repository in your first task update. + +Required format: + +1. `Target repo: ` (for example: `_repos/openwork`) +2. `Out of scope repos: ` (for example: `_repos/opencode`) +3. `Planned output: ` + +If the user request references multiple repos and the intended edit location is ambiguous, stop after discovery and ask for a single repo target before editing files. + +## New Feature Workflow (Required) + +When the user asks to create a new feature, follow this exact procedure: + +1. Make sure you are up to date on all submodules and repos synced to the head of remotes. +2. Create a worktree. +3. Implement the feature. +4. Start the narrowest supported product stack for the flow under test. +5. Use Chrome MCP to fully test the feature with the relevant flow-specific skill. +6. Take screenshots and put them in the repo. +7. Refer to these screenshots in the PR (only if relevant in the UI). +8. Always test the flow you just implemented. + +If you cannot complete steps 4-8 (Docker, Chrome MCP, missing credentials, or environment limitations), you must say so explicitly and include: + +* which steps you could not run and why +* what you verified instead (tests, logs, manual checks) +* the exact commands/steps the user should run to complete the end-to-end gate + +## Pull Request Expectations (Fast Merge) + +If you open a PR, you must run tests and report what you ran (commands + result). + +To maximize merge speed, include evidence of the end-to-end flow: + +* Ideally: attach a short video/screen recording showing the flow running successfully. +* Otherwise: screenshots are acceptable, but video is preferred. + +If you cannot run tests or capture the video, say so explicitly and explain why, and include the exact commands/steps for the reviewer to reproduce. + +## Living Systems -## Core Expectations +OpenWork aims to be a **living system**: agents, skills, commands, and config are hot-reloadable while sessions are running. This enables agents to create new skills or update their own configuration and have changes take effect immediately, without tearing down active sessions. -- **Purpose-first UI**: prioritize clarity, safety, and approachability for non-technical users. -- **Parity with OpenCode**: anything the UI can do must map cleanly to OpenCode tools. -- **Self-referential**: maintain a gitignored mirror of OpenCode at `vendor/opencode` for inspection. -- **Self-building**: prefer prompts, skills, and composable primitives over bespoke logic. -- **Open source**: keep the repo portable; no secrets committed. -- **Slick and fluid**: 60fps animations, micro-interactions, premium feel. -- **Mobile-native**: touch targets, gestures, and layouts optimized for small screens. +Design principles for hot reload: + +* **Conservative triggers**: only reload when a file that OpenCode reads at startup actually changes inside `.opencode/` or `opencode.json`. Ignore metadata files like `openwork.json`, `.DS_Store`, etc. +* **Workspace-scoped**: reload state is keyed per workspace. Switching workspaces never leaks reload signals from one workspace to another. +* **Session-aware**: when sessions are actively running, queue reload signals. Promote to visible reload (toast or auto-reload) only after all active sessions finish. This avoids interrupting in-flight tool calls. +* **Auto-reload setting**: each workspace can opt into automatic reload via `.opencode/openwork.json` (`reload.auto`). When enabled, the engine reloads automatically once queued signals are ready and no sessions are active. +* **Session continuity**: before reload, capture running session IDs, agents, and models. After reload, optionally relaunch those sessions so the user experiences seamless continuity. +* **Per-workspace isolation**: the desktop file watcher only watches the runtime-connected workspace root and its `.opencode/` directory. This can differ briefly from the UI-selected workspace while the user browses another workspace. The server reload event store is already keyed by `workspaceId`. ## Technology Stack -| Layer | Technology | -|-------|------------| -| Desktop/Mobile shell | Tauri 2.x | -| Frontend | SolidJS + TailwindCSS | -| State | Solid stores + IndexedDB | -| IPC | Tauri commands + events | +| Layer | Technology | +| -------------------- | ------------------------- | +| Desktop/Mobile shell | Tauri 2.x with Electron migration path | +| Frontend | SolidJS + TailwindCSS | +| State | Solid stores + IndexedDB | +| IPC | Tauri commands + events | | OpenCode integration | Spawn CLI or embed binary | ## Repository Guidance -- Always read `design-prd.md` at session start for product intent and user flows. -- Keep `design-prd.md` and `.opencode/skill/*/SKILL.md` updated when behavior changes. -- Use `.opencode/skill/` for repeatable workflows and domain vocabulary. +* Use `VISION.md`, `PRINCIPLES.md`, `PRODUCT.md`, `ARCHITECTURE.md`, and `INFRASTRUCTURE.md` to understand the "why" and requirements so you can guide your decisions. +* Treat `ARCHITECTURE.md` as the authoritative system design source for runtime flow, server ownership, filesystem mutation policy, and agent/runtime boundaries. If those behaviors change, update `ARCHITECTURE.md` in the same task. +* Use `DESIGN-LANGUAGE.md` as the default visual reference for OpenWork app and landing work. +* For OpenWork session-surface details, also reference `packages/docs/orbita-layout-style.mdx`. + +## App Architecture (CUPID) + +For `apps/app/src/app/**`, use CUPID: small public surfaces, intention-revealing names, minimal dependencies, predictable ownership, and domain-based structure. + +* Organize app code by product domain and app behavior, not generic buckets like `pages`, `hooks`, `utils`, or app-wide props. +* Prefer a thin shell, domain modules, and tiny shared primitives. +* Colocate state, UI, helpers, and server/client adapters with the domain that owns the workflow. +* Treat shared utilities as a last resort; promote only after multiple real consumers exist. +* Cross-domain imports should go through a small public API, not another domain's internals. +* Keep global shell code thin and use it for routing, top-level layout, runtime wiring, and shared reload/update surfaces only. +* Domain map: shell, workspace, session, connections, cloud, app-settings, and kernel. +* When changing app architecture, moving ownership, or editing hot spots like `app.tsx`, `pages/dashboard.tsx`, `pages/session.tsx`, or `pages/settings.tsx`, consult the workspace-root skill at `../../.opencode/skills/cupid-app-architecture/SKILL.md` first. + +## Dev Debugging + +* If you change `apps/server/src`, rebuild the OpenWork server binary (`pnpm --filter openwork-server build:bin`) because `openwork` (openwork-orchestrator) runs the compiled server, not the TS sources. ## Local Structure ``` -apps/openwork/ - AGENTS.md # This file - design-prd.md # Exhaustive PRD and user flow map - .gitignore # Ignores vendor/opencode, node_modules, etc. +openwork/ + AGENTS.md # This file + VISION.md # Product vision and positioning + PRINCIPLES.md # Decision framework and guardrails + PRODUCT.md # Requirements, UX, and user flows + ARCHITECTURE.md # Runtime modes and OpenCode integration + .gitignore # Ignores vendor/opencode, node_modules, etc. .opencode/ - skill/ # Skills for product workflows - vendor/ - opencode/ # Gitignored OpenCode mirror for self-inspection - src-tauri/ # Rust backend (Tauri) - src/ # SolidJS frontend - package.json # Frontend dependencies - Cargo.toml # Rust dependencies + apps/ + app/ + src/ + public/ + pr/ + prd/ + package.json + desktop/ + src-tauri/ + package.json + server/ + src/ + package.json ``` ## OpenCode SDK Usage OpenWork integrates with OpenCode via: -1. **Non-interactive mode**: `opencode -p "prompt" -f json -q` -2. **Database access**: Read `.opencode/opencode.db` for sessions and messages. -3. **MCP bridge**: OpenWork as an MCP server for real-time permissions and streaming. +1. **Non-interactive mode**: `opencode -p "prompt" -f json -q` +2. **Database access**: Read `.opencode/opencode.db` for sessions and messages. Key primitives to expose: -- `session.Service` — Task runs, history -- `message.Service` — Chat bubbles, tool calls -- `agent.Service` — Task execution, progress -- `permission.Service` — Permission prompts -- `tools.BaseTool` — Step-level actions + +* `session.Service` — Task runs, history +* `message.Service` — Chat bubbles, tool calls +* `agent.Service` — Task execution, progress +* `permission.Service` — Permission prompts +* `tools.BaseTool` — Step-level actions ## Safety + Accessibility -- Default to least-privilege permissions and explicit user approvals. -- Provide transparent status, progress, and reasoning at every step. -- Use progressive disclosure for advanced controls. -- WCAG 2.1 AA compliance. -- Screen reader labels for all interactive elements. +* Default to least-privilege permissions and explicit user approvals. +* Provide transparent status, progress, and reasoning at every step. +* WCAG 2.1 AA compliance. +* Screen reader labels for all interactive elements. ## Performance Targets -| Metric | Target | -|--------|--------| -| First contentful paint | <500ms | -| Time to interactive | <1s | -| Animation frame rate | 60fps | -| Interaction latency | <100ms | -| Bundle size (JS) | <200KB gzipped | +| Metric | Target | +| ---------------------- | -------------- | +| First contentful paint | <500ms | +| Time to interactive | <1s | +| Animation frame rate | 60fps | +| Interaction latency | <100ms | +| Bundle size (JS) | <200KB gzipped | + +## Skill: SolidJS Patterns + +When editing SolidJS UI (`apps/app/src/**/*.tsx`), consult: + +* `.opencode/skills/solidjs-patterns/SKILL.md` + +This captures OpenWork’s preferred reactivity + UI state patterns (avoid global `busy()` deadlocks; use scoped async state). + +## Skill: Trigger a Release + +OpenWork releases are built by GitHub Actions (`Release App`). A release is triggered by pushing a `v*` tag (e.g. `v0.1.6`). +`Release App` can also publish openwork-orchestrator sidecars and npm packages when enabled via workflow inputs or repo vars (`RELEASE_PUBLISH_SIDECARS`, `RELEASE_PUBLISH_NPM`). + +### Standard release (recommended) + +1. Ensure `main` is green and up to date. +2. Bump versions (keep these in sync): + +* `apps/app/package.json` (`version`) +* `apps/desktop/package.json` (`version`) +* `apps/orchestrator/package.json` (`version`, publishes as `openwork-orchestrator`) +* `apps/desktop/src-tauri/tauri.conf.json` (`version`) +* `apps/desktop/src-tauri/Cargo.toml` (`version`) + +You can bump all three non-interactively with: + +* `pnpm bump:patch` +* `pnpm bump:minor` +* `pnpm bump:major` +* `pnpm bump:set -- 0.1.21` + +3. Merge the version bump to `main`. +4. Create and push a tag: + * `git tag vX.Y.Z` + * `git push origin vX.Y.Z` + +This triggers the workflow automatically (`on: push.tags: v*`). + +### Re-run / repair an existing release + +If the workflow needs to be re-run for an existing tag (e.g. notarization retry), use workflow dispatch: + +* `gh workflow run "Release App" --repo different-ai/openwork -f tag=vX.Y.Z` + +### Verify + +* Runs: `gh run list --repo different-ai/openwork --workflow "Release App" --limit 5` +* Release: `gh release view vX.Y.Z --repo different-ai/openwork` + +Confirm the DMG assets are attached and versioned correctly. + +## Skill: Publish openwork-orchestrator (npm) + +This is usually covered by `Release App` when `publish_sidecars` + `publish_npm` are enabled. Use `.opencode/skills/openwork-orchestrator-npm-publish/SKILL.md` for manual recovery or one-off publishing. + +1. Ensure the default branch is up to date and clean. +2. Bump `apps/orchestrator/package.json` (`version`). +3. Commit the bump. +4. Build and upload sidecar assets for the same version tag: + * `pnpm --filter openwork-orchestrator build:sidecars` + * `gh release create openwork-orchestrator-vX.Y.Z apps/orchestrator/dist/sidecars/* --repo different-ai/openwork` +5. Publish: + * `pnpm --filter openwork-orchestrator publish --access public` +6. Verify: + * `npm view openwork-orchestrator version` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000000..81d6dd1d61 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,515 @@ +# OpenWork Architecture + +## Design principle: Predictable > Clever + +OpenWork optimizes for **predictability** over "clever" auto-detection. Users should be able to form a correct mental model of what will happen. + +Guidelines: + +- Prefer **explicit configuration** (a single setting or env var) over heuristics. +- Auto-detection is acceptable as a convenience, but must be: + - explainable (we can tell the user what we tried) + - overrideable (one obvious escape hatch) + - safe (no surprising side effects) +- When a prerequisite is missing, surface the **exact failing check** and a concrete next step. + +### Example: Docker-backed sandboxes (desktop) + +When enabling Docker-backed sandbox mode, prefer an explicit, single-path override for the Docker client binary: + +- `OPENWORK_DOCKER_BIN` (absolute path to `docker`) + +This keeps behavior predictable across environments where GUI apps do not inherit shell PATH (common on macOS). + +Auto-detection can exist as a convenience, but should be tiered and explainable: + +1. Honor `OPENWORK_DOCKER_BIN` if set. +2. Try the process PATH. +3. On macOS, try the login PATH from `/usr/libexec/path_helper`. +4. Last-resort: try well-known locations (Homebrew, Docker Desktop bundle) and validate the binary exists. + +The readiness check should be a clear, single command (e.g. `docker info`) and the UI should show the exact error output when it fails. + +## Minimal use of Tauri +We move most of the functionality to the openwork server which interfaces mostly with FS and proxies to opencode. + + + +## Filesystem mutation policy + +OpenWork should route filesystem mutations through the OpenWork server whenever possible. + +Why: + +- the server is the one place that can apply the same behavior for both local and remote workspaces +- server-routed writes keep permission checks, approvals, audit trails, and reload events consistent +- Tauri-only filesystem mutations only work in desktop host mode and break parity with remote execution + +Guidelines: + +- Any UI feature that changes workspace files or config should call an OpenWork server endpoint first. +- Local Tauri filesystem commands are a host-mode fallback, not the primary product surface. +- If a feature cannot yet write through the OpenWork server, treat that as an architecture gap and close it before depending on direct local writes. +- Reads can fall back locally when necessary, but writes should be designed around the OpenWork server path. + +## Agent authority map + +When OpenWork is edited from `openwork-enterprise`, architecture and runtime behavior should be sourced from this document. + +| Entry point | Role | Architecture authority | +| --- | --- | --- | +| `openwork-enterprise/AGENTS.md` | OpenWork Factory multi-repo orchestration | Defers OpenWork runtime flow, server-vs-shell ownership, and filesystem mutation behavior to `_repos/openwork/ARCHITECTURE.md`. | +| `openwork-enterprise/.opencode/agents/openwork-surgeon.md` | Surgical fix agent for `_repos/openwork` | Uses `_repos/openwork/ARCHITECTURE.md` as the runtime and architecture source of truth before changing product behavior. | +| `_repos/openwork/AGENTS.md` | Product vocabulary, audience, and repo-local development guidance | Refers to `ARCHITECTURE.md` for runtime flow, server ownership, and architectural boundaries. | +| Skills / commands / agents that mutate workspace state | Capability layer on top of the product runtime | Should assume the OpenWork server path is canonical for workspace creation, config writes, `.opencode/` mutation, and reload signaling. | + +### Agent access to server-owned behavior + +Agents, skills, and commands should model the following as OpenWork server behavior first: + +- workspace creation and initialization +- writes to `.opencode/`, `opencode.json`, and `opencode.jsonc` +- OpenWork workspace config writes (`.opencode/openwork.json`) +- share-bundle publish/fetch flows for supported OpenWork capability bundles such as skills +- reload event generation after config or capability changes +- other filesystem-backed capability changes that must work across desktop host mode and remote clients + +Tauri or other native shell behavior remains the fallback or shell boundary for: + +- file and folder picking +- reveal/open-in-OS affordances +- updater and window management +- host-side process supervision and native runtime bootstrapping + +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 Tauri bundles plus a `latest.json` updater manifest at `https://github.com/different-ai/openwork/releases/latest/download/latest.json`; when Electron publishing is enabled, the same release also carries signed, notarized Electron macOS assets plus `latest-mac.yml` at `https://github.com/different-ai/openwork/releases/latest/download/latest-mac.yml`. +- **Alpha** (macOS arm64 only, rolling): every merge to `dev` publishes signed, notarized Tauri and Electron builds to the rolling GitHub release tagged `alpha-macos-latest`. The Tauri alpha updater manifest lives at `https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest.json`; Electron alpha assets include `latest-mac.yml` at `https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest-mac.yml` on the same release. + +Guidelines: + +- The Tauri alpha channel is an opt-in preference (`LocalPreferences.releaseChannel`). The normal Updates 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"`. +- The Electron alpha channel is Debug-only during the migration window. Migrated Electron users can switch feeds from Settings → Debug → Electron alpha channel; the normal Updates page stays on the selected Electron feed and defaults to stable. +- 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; `alpha-macos-aarch64.yml` fails closed unless `MACOS_NOTARIZE=true`, and the `Release App` Electron job reuses the same Tauri Apple signing/notary secrets. +- 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` +- Electron alpha resolver: `apps/app/src/app/lib/electron-alpha.ts` +- Preference plumbing: `apps/app/src/react-app/kernel/local-provider.tsx`, `apps/app/src/react-app/domains/settings/pages/updates-view.tsx`, `apps/app/src/react-app/domains/settings/pages/debug-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. + +Key pieces: + +- `createSystemState()` owns the raw queued-reload state. +- `reloadPending()` means a reload is currently queued for the active workspace. +- `markReloadRequired(reason, trigger)` queues the reload and records the source that caused it. +- `app.tsx` exposes `reloadRequired(...sources)` as a small helper for UI filtering. It is used to decide whether the shared reload popup should show for a given trigger type. + +Use this flow when a change mutates startup-loaded OpenCode inputs, for example: + +- `opencode.json` +- `.opencode/skills/**` +- `.opencode/agents/**` +- `.opencode/commands/**` +- MCP definitions or plugin lists that OpenCode only loads at startup + +Do not invent a separate reload banner per feature. New UI that needs restart semantics should: + +1. perform the config or filesystem mutation +2. call `markReloadRequired(...)` +3. rely on the shared reload popup to explain and execute the restart path + +Current examples that should use this shared flow include MCP changes, auto context compaction, default model changes, authorized folder updates, plugin changes, and other `opencode.json` writes. + +When the desktop shell asks the OpenWork server to manage OpenCode, the managed +OpenCode process starts from a shell-owned local workdir under app data instead +of the user's selected workspace. Workspace-specific file access still flows +through the OpenWork server and `x-opencode-directory`, but startup no longer +depends on opening a project `opencode.json` from slow cloud-synced folders such +as iCloud Drive. + +## opencode primitives +how to pick the right extension abstraction for +@opencode + +opencode has a lot of extensibility options: +mcp / plugins / skills / bash / agents / commands + +- mcp +use when you need authenticated third-party flows (oauth) and want to expose that safely to end users +good fit when "auth + capability surface" is the product boundary +downside: you're limited to whatever surface area the server exposes + +- bash / raw cli +use only for the most advanced users or internal power workflows +highest risk, easiest to get out of hand (context creep + permission creep + footguns) +great for power users and prototyping, terrifying as a default for non-tech users + +- plugins +use when you need real tools in code and want to scope permissions around them +good middle ground: safer than raw cli, more flexible than mcp, reusable and testable +basically "guardrails + capability packaging" + +- skills +use when you want reliable plain-english patterns that shape behavior +best for repeatability and making workflows legible +pro tip: pair skills with plugins or cli (i literally embed skills inside plugins right now and expose commands like get_skills / retrieve) + +- agents +use when you need to create tasks that are executed by different models than the main one and might have some extra context to find skills or interact with mcps. + +- commands +`/` commands that trigger tools + +These are all opencode primitives you can read the docs to find out exactly how to set them up. + +## Core Concepts of OpenWork + +- uses all these primitives +- uses native OpenCode commands for reusable flows (markdown files in `.opencode/commands`) +- adds a new abstraction "workspace" is a project folder and a simple .json file that includes a list of opencode primitives that map perfectly to an opencode workdir (not fully implemented) + - openwork can open a workpace.json and decide where to populate a folder with thse settings (not implemented today + +## Repository/component map + +- `/apps/app/`: OpenWork app UI (desktop/mobile/web client experience layer). +- `/apps/desktop/`: Tauri desktop shell that hosts the app UI and manages native process lifecycles. +- `/apps/server/`: OpenWork server (API/control layer consumed by the app). +- `/apps/orchestrator/`: OpenWork orchestrator CLI/daemon. In `start`/`serve` host mode it manages OpenWork server + OpenCode; in daemon mode it manages worker/sandbox lifecycle. +- `/apps/share/`: share-link publisher service for OpenWork bundle imports. +- `/ee/apps/landing/`: OpenWork landing page surfaces. +- `/ee/apps/den-web/`: Den web UI for sign-in, worker creation, and future user-management flows. +- `/ee/apps/den-api/`: Den control plane API (formerly `/ee/apps/den-controller/`) that provisions/spins up worker runtimes. +- `/ee/apps/den-worker-proxy/`: proxy layer that keeps Daytona API keys server-side, refreshes signed worker preview URLs, and forwards worker traffic so users do not manage provider keys directly. +- `/ee/apps/den-worker-runtime/`: worker runtime packaging (including Docker/runtime artifacts) deployed to Daytona sandboxes. + +## Core Architecture + +OpenWork is a client experience that consumes OpenWork server surfaces. + +OpenWork supports two product runtime modes for users: + +- desktop +- web/cloud (also usable from mobile clients) + +OpenWork therefore has two runtime connection modes: + +### Mode A - Desktop + +- OpenWork runs on a desktop/laptop and can host OpenWork server surfaces locally. +- The OpenCode server runs on loopback (default `127.0.0.1:4096`). +- The OpenWork server also defaults to loopback-only access. Remote sharing is an explicit opt-in that rebinds the OpenWork server to `0.0.0.0` while keeping OpenCode on loopback. +- OpenWork UI connects via the official SDK and listens to events. +- OpenWork server is the local API/control layer for this mode and owns the managed OpenCode child lifecycle. + +### Mode B - Web/Cloud (can be mobile) + +- User signs in to hosted OpenWork web/app surfaces (including mobile browser/client access). +- User launches a cloud worker from hosted control plane. +- OpenWork returns remote connect credentials (`/w/ws_*` URL + access token). +- User connects from OpenWork app using `Add a worker` -> `Connect remote`. + +This model keeps the user experience consistent across self-hosted and hosted paths while preserving OpenCode parity. + +### Mode A composition (Tauri shell + local services) + +- `/apps/app/` runs as the product UI; on desktop it is hosted inside `/apps/desktop/` (Tauri webview). +- `/apps/desktop/` exposes native commands (`engine_*`, `orchestrator_*`, `openwork_server_*`) to start/stop local services and report status to the UI. +- `/apps/desktop/` is also the source of truth for desktop bootstrap config that must survive updates, including Den server targeting and forced-sign-in startup behavior. The shell reads a predictable external `desktop-bootstrap.json` from the host config directory (or `OPENWORK_DESKTOP_BOOTSTRAP_PATH` when explicitly overridden). Default builds consume that file when present; custom builds seed or overwrite it when their bundled bootstrap differs from the standard default. +- Desktop host runtime is server-managed: the shell starts OpenWork server with managed OpenCode enabled, and the UI consumes OpenWork server APIs. +- OpenWork server (`/apps/server/`) is the API surface consumed by the UI; it proxies OpenCode routes for the active workspace. +- Desktop-launched OpenCode credentials are always random, per-launch values generated by OpenWork. OpenCode stays on loopback and is intended to be reached through OpenWork server rather than exposed directly. + +```text +/apps/app UI + | + v +/apps/desktop (Tauri shell) + | + +--> /apps/server (OpenWork API + proxy surface) + | + +--> OpenCode +``` + +### Mode B composition (Web/Cloud services) + +- `/ee/apps/den-web/` is the hosted web control surface (sign-in, worker create, upcoming user management). +- `/ee/apps/den-api/` (formerly `/ee/apps/den-controller/`) is the cloud control plane API (auth/session + worker CRUD + provisioning orchestration). +- Desktop org runtime config is fetched from Den after sign-in and is treated as server-owned runtime policy. It is stored per organization in Den (`organization.desktop_app_restrictions`) as sparse negative restriction flags (for example `blockZenModel`) and managed from the cloud org settings UI, while install/bootstrap config remains shell-owned in the external bootstrap file and only contains base URL, optional API base URL, and the `forceSignin` startup flag. +- Daytona-backed workers mount a single shared provider volume and isolate each worker's persistent data by subpaths (`workers//workspace` and `workers//data`) rather than creating dedicated provider volumes per worker. +- `/ee/apps/den-worker-runtime/` defines the runtime packaging and boot path used inside cloud workers (including Docker/snapshot artifacts and `openwork serve` startup assumptions). +- `/ee/apps/den-worker-proxy/` fronts Daytona worker preview URLs, refreshes signed links with provider credentials, and proxies traffic to the worker runtime. +- The OpenWork app (desktop or mobile client) connects to worker OpenWork server surfaces via URL + token (`/w/ws_*` when available). + +```text +/ee/apps/den-web + | + v +/ee/apps/den-api (formerly /ee/apps/den-controller) + | + +--> Daytona/Render provisioning + | | + | v + | /ee/apps/den-worker-runtime -> openwork serve + OpenCode + | + +--> /ee/apps/den-worker-proxy (signed preview + proxy) + +OpenWork app/mobile client + -> Connect remote (URL + token) + -> worker OpenWork server surface +``` + +## Messaging Bridge + +OpenWork no longer starts or proxies an app-owned local messaging bridge in the desktop host runtime. Messaging surfaces must be provided by an external server/worker surface rather than Tauri, Electron, or OpenWork server launching a local `opencode-router` child. + +Terminology clarification: + +- `selected workspace` is a UI concept: the workspace the user is currently viewing and where compose/config actions should target. +- `runtime active workspace` is a backend concept: the workspace the local server/orchestrator currently reports as active. +- `watched workspace` is the desktop-host/runtime concept for which workspace root local file watching is currently attached to. +- These states must be treated separately. UI selection can change without implying that the backend has switched roots yet. +- In practice, `selected workspace` and `runtime active workspace` often converge once the user sends work, but they are allowed to diverge briefly while the UI is browsing another workspace. + +Desktop local OpenWork server ports: + +- Desktop-hosted local OpenWork server instances do not assume a fixed `8787` port. +- Each workspace gets a persistent preferred localhost port in the `48000-51000` range. +- On restart, desktop tries to reuse that workspace's saved port first. +- If that port is unavailable, desktop picks another free port in the same range and avoids ports already reserved by other known workspaces. + +```text +Shared-root case + +router root: /Users/me/projects + + /Users/me/projects/a OK + /Users/me/projects/b OK + /Users/me/projects/c OK + +Unrelated-root case + +router root: /Users/me/projects/a + + /Users/me/projects/a OK + /Users/me/other/b rejected + /tmp/c rejected +``` + +This is intentional for now: predictable scoping beats clever cross-root auto-routing. + +## Cloud Worker Connect Flow (Canonical) + +1. Authenticate in OpenWork Cloud control surface. +2. Launch worker (with checkout/paywall when needed). +3. Wait for provisioning and health. +4. Generate/retrieve connect credentials. +5. Connect in OpenWork app via deep link or manual URL + token. + +Technical note: + +- Default connect URL should be workspace-scoped (`/w/ws_*`) when available. +- Technical diagnostics (host URL, worker ID, raw logs) should be progressive disclosure, not default UI. + +## Web Parity + Filesystem Actions + +The browser runtime cannot read or write arbitrary local files. Any feature that: + +- reads skills/commands/plugins from `.opencode/` +- edits `SKILL.md` / command templates / `opencode.json` +- opens folders / reveals paths + +must be routed through a host-side service. + +In OpenWork, the long-term direction is: + +- Use the OpenWork server (`/apps/server/`) as the single API surface for filesystem-backed operations. +- Treat Tauri-only file operations as an implementation detail / convenience fallback, not a separate feature set. + +This ensures the same UI flows work on desktop, mobile, and web clients, with approvals and auditing handled centrally. + +## OpenCode Integration (Exact SDK + APIs) + +OpenWork uses the official JavaScript/TypeScript SDK: + +- Package: `@opencode-ai/sdk/v2` (UI should import `@opencode-ai/sdk/v2/client` to avoid Node-only server code) +- Purpose: type-safe client generated from OpenAPI spec + +### Engine Lifecycle + +#### Start server + client (Host mode) + +Use `createOpencode()` to launch the OpenCode server and create a client. + +```ts +import { createOpencode } from "@opencode-ai/sdk/v2"; + +const opencode = await createOpencode({ + hostname: "127.0.0.1", + port: 4096, + timeout: 5000, + config: { + model: "anthropic/claude-3-5-sonnet-20241022", + }, +}); + +const { client } = opencode; +// opencode.server.url is available +``` + +#### Connect to an existing server (Client mode) + +```ts +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; + +const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: "/path/to/project", +}); +``` + +### Health + Version + +- `client.global.health()` + - Used for startup checks, compatibility warnings, and diagnostics. + +### Event Streaming (Real-time UI) + +OpenWork must be real-time. It subscribes to SSE events: + +- `client.event.subscribe()` + +The UI uses these events to drive: + +- streaming assistant responses +- step-level tool execution timeline +- permission prompts +- session lifecycle changes + +### Sessions (Primary Primitive) + +OpenWork maps a "Task Run" to an OpenCode **Session**. + +Core methods: + +- `client.session.create()` +- `client.session.list()` +- `client.session.get()` +- `client.session.messages()` +- `client.session.prompt()` +- `client.session.abort()` +- `client.session.summarize()` + +### Files + Search + +OpenWork's file browser and "what changed" UI are powered by: + +- `client.find.text()` +- `client.find.files()` +- `client.find.symbols()` +- `client.file.read()` +- `client.file.status()` + +### Permissions + +OpenWork must surface permission requests clearly and respond explicitly. + +- Permission response API: + - `client.permission.reply({ requestID, reply })` (where `reply` is `once` | `always` | `reject`) + +OpenWork UI should: + +1. Show what is being requested (scope + reason). +2. Provide choices (allow once / allow for session / deny). +3. Post the response to the server. +4. Record the decision in the run's audit log. + +### Config + Providers + +OpenWork's settings pages use: + +- `client.config.get()` +- `client.config.providers()` +- `client.auth.set()` (optional flow to store keys) + +### Extensibility - Skills + Plugins + +OpenWork exposes two extension surfaces: + +1. **Skills** + - Installed into `.opencode/skills/*`. + - Skills can be imported from local directories or installed from curated lists. + +2. **Plugins (OpenCode)** + - Plugins are configured via `opencode.json` in the workspace. + - The format is the same as OpenCode CLI uses today. + - OpenWork should show plugin status and instructions; a native plugin manager is planned. + +### Engine reload (config refresh) + +- OpenWork server exposes `POST /workspace/:id/engine/reload`. +- It calls OpenCode `POST /instance/dispose` with the workspace directory to force a config re-read. +- Use after skills/plugins/MCP/config edits; reloads can interrupt active sessions. +- Reload requests follow OpenWork server approval rules. + +### Skill Registry (Current + Future) + +- Today, OpenWork only supports **curated lists + manual sources**. +- Future goals: + - in-app registry search + - curated list sync (e.g. Awesome Claude Skills) + - frictionless publishing without signup + +## Projects + Path + +- `client.project.list()` / `client.project.current()` +- `client.path.get()` + +OpenWork conceptually treats "workspace" as the current project/path. + +## Optional TUI Control (Advanced) + +The SDK exposes `client.tui.*` methods. OpenWork can optionally provide a "Developer Mode" screen to: + +- append/submit prompt +- open help/sessions/themes/models +- show toast + +This is optional and not required for non-technical MVP. + +## Folder Authorization Model + +OpenWork enforces folder access through **two layers**: + +1. **OpenWork UI authorization** + - user explicitly selects allowed folders via native picker + - OpenWork remembers allowed roots per profile + +2. **OpenCode server permissions** + - OpenCode requests permissions as needed + - OpenWork intercepts requests via events and displays them + +Rules: + +- Default deny for anything outside allowed roots. +- "Allow once" never expands persistent scope. +- "Allow for session" applies only to the session ID. +- "Always allow" (if offered) must be explicit and reversible. + +## Open Questions + +- Best packaging strategy for Host mode engine (bundled vs user-installed Node/runtime). +- Best remote transport for mobile client (LAN only vs optional tunnel). diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..fb9c64f38c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,55 @@ +# Code of Conduct + +## Our commitment + +We are committed to making participation in the OpenWork community a harassment-free +experience for everyone, regardless of age, body size, disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, education, +socio-economic status, nationality, personal appearance, race, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, +inclusive, and healthy community. + +## Our standards + +Examples of behavior that contributes to a positive environment include: + +- Demonstrating empathy and kindness toward other people. +- Being respectful of differing opinions, viewpoints, and experiences. +- Giving and gracefully accepting constructive feedback. +- Taking responsibility and apologizing to those affected by our mistakes. +- Focusing on what is best for the community. + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery and sexual attention or advances. +- Trolling, insulting or derogatory comments, and personal or political attacks. +- Public or private harassment. +- Publishing others' private information without explicit permission. +- Other conduct that could reasonably be considered inappropriate in a professional + setting. + +## Enforcement responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in response +to behavior they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, including GitHub issues, +pull requests, discussions, and direct interactions in official OpenWork channels. + +## Reporting + +If you experience or witness unacceptable behavior, report it to +`benjamin.shafii@gmail.com` with as much context as possible. + +All reports will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1. + +[homepage]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ diff --git a/DESIGN-LANGUAGE.md b/DESIGN-LANGUAGE.md new file mode 100644 index 0000000000..2e06374211 --- /dev/null +++ b/DESIGN-LANGUAGE.md @@ -0,0 +1,871 @@ +# OpenWork Design Language + +This is the definitive visual system for OpenWork product and landing work. + +OpenWork should feel like a premium work tool: calm, useful, technical, and trustworthy. The design should read as software first, not a flashy marketing site. The goal is clarity with taste, not visual noise. + +--- + +## 1. Core Design Position + +OpenWork design is: + +- quiet +- premium +- operational +- flat-first +- structured by typography, spacing, and borders +- atmospheric only in controlled places + +OpenWork design is **not**: + +- glossy +- glassy +- beige +- aggressively gradient-heavy +- border-heavy +- shadow-led +- decorative for its own sake + +The basic rule: + +> Use structure before effects. + +If something needs emphasis, prefer this order: + +1. layout +2. spacing +3. typography +4. opacity +5. background tint +6. border +7. shadow + +Shadow should almost never be the first tool. + +--- + +## 2. The OpenWork Mood + +The product should feel like: + +- a serious desktop tool +- a clean command center +- a modern open-source alternative to Claude Cowork +- something you would trust with real workflows, team sharing, and remote workers + +Tone: + +- polished, but restrained +- modern, but not trendy +- friendly, but not cute +- futuristic through discipline, not chrome + +--- + +## 3. Color + Surface Rules + +### Base page color + +- Default page/background base: very light cool neutral (`#f6f9fc` or equivalent) +- Prefer white and near-white surfaces over tinted beige panels +- Avoid warm paper/beige backgrounds unless there is a very strong reason + +### Surface hierarchy + +Use only a few layers: + +1. **Page background** +2. **Primary white surface** +3. **Soft secondary surface** +4. **Interactive selected state** + +Do not create lots of micro-layers. + +### Preferred surface treatments + +#### Flat app surface + +For most application UI: + +- white or near-white background +- 1px subtle border +- no visible shadow or only the smallest shadow possible + +#### Soft shell + +Use for landing sections that need grouping but should still feel calm. + +- `landing-shell-soft` style direction +- near-white background +- subtle edge definition +- **no box shadow by default** + +This is no longer landing-only in spirit. For app surfaces like modals, package builders, +and share flows, the same shell language is often the right starting point when the surface +represents a workflow object instead of generic settings chrome. + +#### Elevated showcase shell + +Use only when a hero/demo needs one extra level of emphasis. + +- may use `landing-shell` +- still soft +- never dark or “floating card everywhere” +- should be rare, not the default wrapper for all sections + +### Background imagery + +Allowed only when all of the following are true: + +- it sits behind content, not under core text blocks directly +- it is subtle +- it fades away or is spatially constrained +- it does not compete with reading + +Pattern/background image rules: + +- top-of-page background patterns should be low-opacity and fade out down the page +- section-specific image backgrounds are allowed for showcase frames +- content cards that sit on top of image backgrounds should still be white and legible +- use images as atmosphere, not content + +--- + +## 4. Borders + +Borders are one of the main structure tools in OpenWork. + +### Border philosophy + +- prefer soft gray borders +- prefer low contrast +- prefer consistency over emphasis + +### What not to do + +- do **not** use harsh black borders for selection +- do **not** outline selected cards with strong dark strokes +- do **not** stack border + heavy shadow + tint all at once + +### Good border usage + +- `border-gray-200` +- `border-gray-300` for stronger but still soft selection +- low-alpha white borders for translucent landing shells +- soft shell borders like `#eceef1` for app sidebars and large rounded utility panels + +Do not use a dark or high-contrast outline as the main styling for a small icon tile, +badge shell, or compact decorative container. If the element is just carrying an icon, +prefer a soft filled tile over an outlined chip. + +Selection should usually feel like: + +- soft neutral fill +- darker text +- optional tiny border or tiny shadow only when needed + +not: + +- dark outline +- glow +- hard stroke + +--- + +## 5. Shadows + +Shadows must be restrained. + +### General rule + +- App UI: almost flat +- Landing UI: soft and selective +- Selection states: tiny shadow only + +### Approved shadow levels + +#### None + +Default for most grouped surfaces. + +#### Tiny control shadow + +Use for active pills and secondary buttons: + +```css +0 0 0 1px rgba(0,0,0,0.06), +0 1px 2px 0 rgba(0,0,0,0.04) +``` + +#### Light card shadow + +Use sparingly for a main demo shell or one hero card. + +#### Strong CTA shadow + +Reserved for the primary CTA only. + +### Never do + +- large ambient shadows across many cards on one page +- floaty SaaS-marketing shadows everywhere +- using shadow as the main selected-state signal +- glassmorphism blur shadows in the app + +--- + +## 6. Geometry + Radius + +OpenWork should have a small set of radii and use them consistently. + +### Radius system + +- **Pills / buttons / chips:** `rounded-full` +- **Small controls / rows / compact cards:** `rounded-xl` +- **Medium panels / embedded demos:** `rounded-2xl` +- **Large showcase wrappers:** `rounded-3xl` or `rounded-[2.5rem]` +- **Sidebar/app shell wrappers:** `rounded-[2rem]` to `rounded-[2.5rem]` + +### Rules + +- Don’t mix too many different radii in one section +- If the outer shell is very rounded, inner panels should step down cleanly +- Pills should look intentional, not bubbly + +--- + +## 7. Typography + +Typography does most of the hierarchy work. + +### General tone + +- clean sans-serif +- medium weight for important labels +- gray text for explanatory copy +- no overly stylized headings + +### Hierarchy + +#### Eyebrows + +- uppercase +- tracked +- small (`text-[11px]`) +- muted gray + +#### Headlines + +- medium weight +- tight tracking +- dark ink (`#011627` or equivalent) +- large enough to lead, not shout + +#### Body + +- `text-sm` or `text-base` +- relaxed line height +- `text-gray-500` or `text-gray-600` + +#### Active explanatory text + +If paired with an active state (like a selected workflow descriptor), the copy may move from muted gray to dark ink. + +### Avoid + +- giant type jumps +- ultra-light weights +- loud uppercase body copy +- dense paragraphs without breathing room + +--- + +## 7.5 Copy Direction + +OpenWork copy should feel as disciplined as the UI. + +### General tone + +- concise +- product-led +- operational +- calm +- confident without overselling + +### Good copy behavior + +- lead with the main user value, not the implementation detail +- prefer one clear idea per sentence +- keep interface copy shorter than marketing copy +- make support text explain utility, not restate the headline in different words + +### Avoid + +- repetitive copy that says the same thing three ways +- enterprise filler words like "provisioned setups" when a simpler phrase exists +- admin-heavy or billing-heavy framing when the main value is team workflow +- overdescribing secondary features + +### Preferred OpenWork Cloud framing + +For OpenWork Cloud, the primary story is: + +1. share setup across the team/org +2. keep everything in sync +3. background agents are secondary / alpha +4. custom LLM providers are tertiary / coming soon + +Do not make the product read like: + +- a billing page first +- a hosting toggle first +- an equal split between desktop and Cloud + +It should read like: + +- team setup sharing first +- operational consistency second +- advanced/cloud extensions after that + +### Preferred terminology + +Use: + +- **OpenWork Cloud** +- **Shared setups** +- **Shared templates** +- **Custom LLM providers** +- **Background agents** + +Prefer: + +- "Manage your team’s setup, invite teammates, and keep everything in sync." +- "Create and update shared templates your team can use right away." +- "Standardize provider access for your team." + +Avoid: + +- "Den" in user-facing copy +- "Provisioned setups" +- "Configured setups" +- "Choose how to run..." when the real goal is to explain team value + +### Hierarchy rules for product pages + +For sign-in, checkout, and dashboard copy: + +- headline should state the core team value +- subcopy should explain the workflow benefit in one sentence +- supporting bullets/cards should not compete equally with the main value +- desktop should often appear as a fallback or secondary path, not a co-equal hero choice + +### Docs CTA language + +When linking to supporting documentation, prefer short utility labels: + +- **Learn how** +- **How sharing works** +- **Read the guide** + +These should feel like helpful follow-through, not a second headline. + +--- + +## 8. Buttons + +There are only a few button families in OpenWork. + +### 8.1 Primary button + +Use for the main action only. + +Characteristics: + +- dark fill (`#011627`) +- white text +- fully rounded pill +- slightly stronger shadow than the rest of the system +- feels decisive but still clean + +Canonical pattern: `doc-button` + +Use for: + +- Download +- Run task +- other main conversion/action moments + +### 8.2 Secondary button + +Use for support actions. + +Characteristics: + +- white fill +- no hard border +- tiny ring + small shadow +- black/dark text +- fully rounded pill + +Canonical pattern: `secondary-button` + +This is also the reference style for: + +- active segmented controls +- selected pills inside a track + +### 8.3 Tertiary / text actions + +Use for less important actions. + +Characteristics: + +- no heavy box treatment +- rely on text color and hover only + +### Button rules + +- Do not invent many new button styles +- Reuse the primary and secondary button logic whenever possible +- If a selector pill is active, it should usually resemble the secondary button family + +--- + +## 9. Selectors, Tabs, and Pills + +This is now one of the clearest OpenWork patterns. + +### Track pattern + +Use a soft segmented track: + +- light border +- subtle gray background +- full pill radius +- tiny inset padding + +Example structure: + +- track: `border border-gray-200 bg-gray-50/50 rounded-full p-1` +- active item: white pill + tiny shadow +- inactive item: muted text only + +### Active state + +Active tab/pill should look like: + +- white pill +- soft ring/shadow +- dark text + +### Inactive state + +Inactive tab/pill should look like: + +- no card chrome +- muted gray text +- stronger text on hover + +### Do not + +- use harsh dark borders for selection +- create heavy segmented controls with thick strokes +- use loud fills for tabs + +### Flat selected row pattern + +For app navigation, especially dashboard sidebars: + +- selected state should usually be a soft gray fill (`bg-gray-100` / `bg-slate-100` family) +- selected items should not default to white floating pills inside a white or near-white shell +- rely on fill + text weight before adding chrome +- hover state should usually be one step lighter than selected, not a different visual language + +--- + +## 10. Lists and Row Systems + +OpenWork has two primary list patterns. + +### 10.1 Operational row list + +Use for sessions, workspaces, activity rows, and compact app lists. + +Pattern: + +- flat container +- rounded-xl row +- light hover tint +- selected row uses a subtle fill and stronger text +- metadata remains quiet + +Good signals: + +- `font-medium` +- subtle background tint +- tiny status accent if needed +- rounded-2xl row inside a softer outer shell when the list is acting as a primary sidebar + +Bad signals: + +- white card floating above white page +- hard selected outline +- large shadows on list rows + +### 10.1a Sidebar shell pattern + +Use for app/dashboard sidebars when the sidebar itself should feel like a calm standalone object. + +Pattern: + +- outer shell uses a near-white neutral background, not pure white +- shell gets a large radius (`rounded-[2rem]` range) +- shell uses a faint border, often enough without any visible shadow +- internal rows stay flatter than the outer shell +- selected row uses a soft gray fill, not a stronger border treatment +- footer actions may appear as floating white pills/cards inside the shell if they need separation + +This is the right pattern for: + +- workspace sidebars +- Cloud dashboard sidebars +- utility navigation that should feel product-like rather than admin-like + +### 10.2 Text-led preview list + +Use when a list controls a larger preview panel to the right. + +Pattern: + +- no boxed cards for each item +- text blocks stacked vertically +- inactive items use lower opacity +- active item uses full opacity and darker copy + +This is the right pattern for: + +- feature explanation lists next to a demo panel +- “build / import / ready” style narratives + +--- + +## 11. Cards and Section Layouts + +### Explanatory cards + +Use only when the card itself is the unit of information. + +Should be: + +- simple +- lightly bordered +- white +- softly rounded + +### When not to use cards + +If the user is just choosing between three conceptual options, don’t force every option into a boxed card. Use: + +- pill selector +- text-only list +- opacity-driven stacked copy + +### Product object cards + +Use when the UI is presenting a reusable worker, template, integration, or packaged setup. + +Pattern: + +- soft shell or near-white card +- generous padding +- title first +- one short supporting sentence +- compact status pill in the top-right if needed +- actions inline underneath or within the card + +These should feel like curated product objects, not admin rows. + +### Icon tiles inside cards + +When a card uses an icon block: + +- use a soft filled tile (`bg-slate-50` / similar) +- prefer no visible border by default +- let size, radius, and fill define the tile +- if a muted version is needed, use a quieter fill rather than an outline + +Do not: + +- put a dark stroke around the icon tile +- make the icon tile look like a separate outlined button unless it actually is one +- introduce standalone black/ink borders for decorative icon wrappers + +### Section composition + +Most sections should follow one of these layouts: + +1. **Headline + supporting copy + CTA** +2. **Selector on left + live descriptor on right** +3. **Text list on left + preview/demo on right** +4. **Three-column summary cards** + +Do not mix too many interaction models in one section. + +--- + +## 12. Demo and Mockup Styling + +Embedded product demos should feel like software, not like illustrations. + +### Demo shell rules + +- white inner content area +- subtle chrome +- soft border +- restrained shadow +- clear spacing + +### If the outer frame is atmospheric + +Then the inner mockup must become simpler. + +Meaning: + +- image/pattern on outer background is okay +- inner card should stay clean and white +- do not combine colorful outer frame with complex inner effects + +### Content in demos + +- use real-looking interaction states +- keep labels readable +- emphasize utility over visual flourish + +### Packaged workflow surfaces + +When showing a workflow like share/package/export: + +- prefer a soft shell over default modal chrome +- make the core object the hero (template, worker, integration, package) +- reduce the number of nested bordered panels +- use one or two strong cards, then flatter supporting sections +- present actions as intentional product actions, not generic form controls + +--- + +## 13. Selection States + +Selection should usually be shown through one or more of: + +- darker text +- stronger opacity +- soft neutral fill +- soft gray border +- tiny shadow + +Selection should **not** usually be shown through: + +- black outline +- bright accent fill +- glow +- thick stroke + +OpenWork selection should feel confident, not loud. + +When a selected item sits inside a soft app shell, prefer: + +- tinted gray fill first +- then weight and text color +- then at most a tiny white badge or tiny control shadow for supporting UI + +Avoid making the selected state look like a separate floating card unless the interface is explicitly using segmented pills. + +--- + +## 13.5 Modal Surfaces + +Not every modal should look like a system dialog. + +For workflow modals (share, package, connect, publish, save to team): + +- use a large soft shell with a near-white background +- keep the header airy and typographic +- avoid harsh header separators unless they add real structure +- prefer one scrollable content region inside the shell +- use soft cards for major choices +- reduce mini-panels and stacked utility boxes + +Good modal direction: + +- feels like a product surface +- can contain object cards and actions +- uses soft hierarchy and breathing room + +Bad modal direction: + +- dense settings sheet +- too many small bordered sub-panels +- generic dialog chrome with no product feel + +--- + +## 14. Motion + +Motion should be tight and purposeful. + +### Allowed motion + +- pill transitions with spring +- short opacity transitions +- tiny translateY on primary CTA hover +- soft content crossfades + +### Avoid + +- floaty delayed animations everywhere +- scale-heavy hover effects +- decorative motion on non-interactive surfaces + +### Timing + +- interactions should feel immediate +- most transitions should live around `150ms–300ms` +- spring motion should be controlled, not bouncy + +--- + +## 15. OpenWork App vs Landing + +The app and the landing share one system, but not the same degree of atmosphere. + +### App + +- flatter +- more structural +- almost no decorative shadow +- almost no background texture +- strong emphasis on state clarity and density + +### Landing + +- may use soft shells +- may use one atmospheric background image/pattern in a controlled region +- may use more spacing and larger radii +- still must obey the same button, border, and selection rules + +Landing should feel like the same product family, not a separate visual brand. + +--- + +## 16. Anti-Patterns + +Do not introduce these: + +- beige canvases as default backgrounds +- harsh black selected borders +- random glassmorphism +- multiple heavy shadow systems on one screen +- over-rounded cards everywhere +- boxed selectors when text or pills would be clearer +- giant gradients behind readable text +- decorative badges/counters with no functional meaning +- hiding anchor labels just to show hover actions +- outlined icon chips that read darker than the card they sit inside + +If something looks “designed” before it looks “useful,” it is probably wrong. + +--- + +## 17. Canonical Component Patterns + +### Primary CTA + +- dark pill +- white text +- slight elevation + +### Secondary CTA / active segmented pill + +- white pill +- tiny ring + tiny shadow +- dark text + +### Selector track + +- light gray border +- soft neutral background +- internal padding +- active item is white + +### Text-led feature list + +- no cards +- stacked copy +- inactive items at reduced opacity +- active item at full opacity + +### Operational list row + +- rounded-xl +- subtle hover tint +- selected row uses fill/weight, not loud chrome + +### App sidebar shell + +- large rounded outer shell +- faint neutral background +- subtle border +- flat internal rows +- selected row uses soft gray fill +- floating footer action can be white if it needs separation from the shell + +### Share/package modal + +- soft shell modal +- object cards for reusable templates or integrations +- compact status pills +- strong dark primary CTA +- white secondary CTA with tiny ring/shadow +- avoid form-heavy utility styling unless the step is truly form-driven + +### Landing shell + +- reserved for hero/showcase moments +- use sparingly + +### Landing soft shell + +- flat, near-white, subtle border +- no shadow by default + +--- + +## 18. Design Decision Tests + +Before shipping a UI change, ask: + +1. Is this relying on layout and typography first, or on effects first? +2. Is the selected state soft and obvious, rather than harsh? +3. Are we reusing the existing primary/secondary button language? +4. Does this section need cards, or would pills / text / opacity be cleaner? +5. Is the shadow doing real work, or is it just decoration? +6. Would this still feel like OpenWork if all colors were muted? +7. Does this feel like one coherent product across app and landing? + +If the answer to those is not clearly yes, simplify. + +--- + +## 19. Canonical References in This Repo + +Use these as implementation references: + +- Landing button + shell primitives: `_repos/openwork/ee/apps/landing/app/globals.css` +- Landing hero and selector patterns: `_repos/openwork/ee/apps/landing/components/landing-home.tsx` +- Landing demo list rhythm: `_repos/openwork/ee/apps/landing/components/landing-app-demo-panel.tsx` +- Cloud dashboard sidebar shell + selected state: `_repos/openwork/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx` +- Share/package modal direction: `_repos/openwork/apps/app/src/app/components/share-workspace-modal.tsx` +- App workspace/session list rhythm: `_repos/openwork/apps/app/src/app/components/session/workspace-session-list.tsx` + +When in doubt, prefer the calmer version. diff --git a/DESIGN-SYSTEM.md b/DESIGN-SYSTEM.md new file mode 100644 index 0000000000..efc252707c --- /dev/null +++ b/DESIGN-SYSTEM.md @@ -0,0 +1,474 @@ +# OpenWork Design System + +This document turns the visual direction in `DESIGN-LANGUAGE.md` into an implementation system that can unify: + +- `apps/app` (OpenWork app) +- `ee/apps/den-web` (OpenWork Cloud / Den web surfaces) +- `ee/apps/landing` (marketing + product storytelling) + +The goal is not to create three similar styles. The goal is one OpenWork design system with a few environment-specific expressions. + +--- + +## 1. Why this exists + +Today the product already has the beginnings of a system, but it is split across: + +- app-specific CSS variables in `apps/app/src/app/index.css` +- Tailwind theme setup in `apps/app/tailwind.config.ts` +- Radix color tokens in `apps/app/src/styles/colors.css` +- repeated utility-class decisions across app, Cloud, and landing + +That creates three problems: + +1. the app and Cloud can feel related but not identical +2. visual decisions are made at the screen level instead of the system level +3. tokens, primitives, and page composition rules are not clearly separated + +This file defines the missing structure. + +--- + +## 2. System model + +OpenWork should use a three-layer design system: + +### Layer 1: Foundations + +Raw design tokens: + +- color +- typography +- spacing +- radius +- shadow +- motion + +These are the only values components should depend on directly. + +### Layer 2: Semantic tokens + +Product-meaning tokens: + +- `surface.page` +- `surface.panel` +- `surface.sidebar` +- `text.primary` +- `text.secondary` +- `border.subtle` +- `action.primary.bg` +- `state.hover` +- `state.selected` + +These should map foundation tokens into product meaning. + +### Layer 3: Component primitives + +Reusable building blocks: + +- Button +- Card +- Input +- Modal shell +- Sidebar shell +- List row +- Status pill +- Section header +- Empty state + +Pages should mostly compose these primitives, not invent their own visual logic. + +--- + +## 3. Relationship to existing docs + +- `DESIGN-LANGUAGE.md` = visual philosophy and qualitative rules +- `DESIGN-SYSTEM.md` = implementation structure and migration plan + +If there is a conflict: + +1. `DESIGN-LANGUAGE.md` decides what the product should feel like +2. `DESIGN-SYSTEM.md` decides how to encode that in tokens and primitives + +--- + +## 4. Core principle: one system, three expressions + +OpenWork has three main UI contexts: + +1. **App expression** — denser, flatter, operational +2. **Cloud expression** — still operational, slightly more editorial and roomy +3. **Landing expression** — more atmospheric, but still clearly the same product family + +These should differ mostly in: + +- spacing density +- shell scale +- amount of atmosphere +- page composition + +They should **not** differ in: + +- brand color logic +- button language +- border philosophy +- type hierarchy +- selection behavior + +--- + +## 5. Canonical token architecture + +We should converge on a small token set that works everywhere. + +### 5.1 Foundation color tokens + +Use Radix as the raw palette source, but not as the public API for product styling. + +Raw palette source: + +- Radix gray/slate/sage for neutrals +- Radix red/amber/green/blue for semantic states + +### 5.2 Semantic color tokens + +Canonical semantic token set: + +- `--ow-color-page` +- `--ow-color-surface` +- `--ow-color-surface-subtle` +- `--ow-color-surface-sidebar` +- `--ow-color-border` +- `--ow-color-border-strong` +- `--ow-color-text` +- `--ow-color-text-muted` +- `--ow-color-text-subtle` +- `--ow-color-accent` +- `--ow-color-accent-hover` +- `--ow-color-hover` +- `--ow-color-active` +- `--ow-color-success` +- `--ow-color-warning` +- `--ow-color-danger` + +These should become the shared API across app and Cloud. + +### 5.3 Current mapping from app tokens + +Existing app tokens already point in the right direction: + +- `--dls-app-bg` -> `--ow-color-page` +- `--dls-surface` -> `--ow-color-surface` +- `--dls-sidebar` -> `--ow-color-surface-sidebar` +- `--dls-border` -> `--ow-color-border` +- `--dls-text-primary` -> `--ow-color-text` +- `--dls-text-secondary` -> `--ow-color-text-muted` +- `--dls-accent` -> `--ow-color-accent` +- `--dls-accent-hover` -> `--ow-color-accent-hover` + +We should migrate by aliasing first, not by breaking everything at once. + +--- + +## 6. Typography system + +Typography should be systemized into roles, not ad hoc text sizes. + +### Roles + +- **display** — rare marketing or hero usage +- **headline** — page and section headers +- **title** — card and object titles +- **body** — default reading text +- **meta** — labels, helper copy, secondary information +- **micro** — pills, badges, tiny metadata + +### Shared rules + +- one main sans family across product surfaces +- medium weight does the majority of hierarchy work +- muted text is the default support color +- avoid large type jumps inside the app + +--- + +## 7. Spacing system + +OpenWork should use a consistent spacing scale instead of one-off values. + +Recommended base scale: + +- 4 +- 8 +- 12 +- 16 +- 20 +- 24 +- 32 +- 40 +- 48 +- 64 + +### Usage guidance + +- micro control padding: 8–12 +- row padding: 12–16 +- card padding: 20–24 +- major section padding: 32–48 +- page rhythm: 48–64 on roomy surfaces, 24–32 in dense app surfaces + +--- + +## 8. Radius system + +Canonical radius roles: + +- `--ow-radius-control` — small controls and rows +- `--ow-radius-card` — cards and panels +- `--ow-radius-shell` — sidebars, large grouped containers, modal shells +- `--ow-radius-pill` — buttons, tabs, chips + +Suggested mapping: + +- control: 12px +- card: 16px +- shell: 24px–32px +- pill: 9999px + +--- + +## 9. Shadow system + +Shadow should be a named system with very few levels. + +- `--ow-shadow-none` +- `--ow-shadow-control` +- `--ow-shadow-card` +- `--ow-shadow-shell` + +Default behavior: + +- app: mostly `none` or `control` +- Cloud: mostly `none`, `control`, occasional `card` +- landing: selective `card` or `shell` + +--- + +## 10. Component primitive families + +We should explicitly define a small primitive set shared across product surfaces. + +### 10.1 Action primitives + +- Primary button +- Secondary button +- Ghost button +- Destructive button +- Segmented pill / tab item + +### 10.2 Structure primitives + +- Page shell +- Sidebar shell +- Card +- Quiet card +- Modal shell +- Section divider + +### 10.3 Input primitives + +- Text input +- Textarea +- Select +- Checkbox/radio treatment +- Inline field group + +### 10.4 Navigation primitives + +- Sidebar row +- List row +- Topbar item +- Breadcrumb / section tab + +### 10.5 Feedback primitives + +- Status pill +- Banner +- Empty state +- Toast + +--- + +## 11. System-first implementation rules + +### Rule 1: prefer semantic tokens over raw utility colors + +Prefer: + +- `bg-[var(--ow-color-surface)]` +- `text-[var(--ow-color-text-muted)]` + +Over: + +- `bg-white` +- `text-gray-500` + +Raw grays are still acceptable for temporary legacy usage, but new primitives should use semantic tokens. + +### Rule 2: page code should not define new visual language + +Page files can compose primitives and choose layouts. +They should not invent new button styles, new shadow rules, or new selection patterns. + +### Rule 3: Radix stays underneath the system + +Radix is the palette source. +OpenWork tokens are the product API. + +### Rule 4: app and Cloud should share primitives even if frameworks differ + +Even when implementations differ, the primitive names and behaviors should match. + +Example: + +- `Button` in app +- `Button` in den-web + +Both should resolve to the same token logic and visual rules. + +--- + +## 12. Migration strategy + +Do not redesign everything at once. +Use this sequence. + +### Phase 1: lock the foundations + +1. create canonical semantic tokens +2. alias current app tokens to the new token names +3. document primitive families and approved variants + +### Phase 2: unify the most reused primitives + +Start with: + +1. Button +2. Card +3. Input +4. Sidebar row +5. Modal shell + +These give the largest visual consistency gain. + +### Phase 3: unify shell patterns + +Standardize: + +- page background +- sidebar shell +- panel/card shell +- list row selection +- headers and section spacing + +### Phase 4: refactor high-traffic screens + +Prioritize: + +- workspace/session surfaces in `apps/app` +- Cloud dashboard shells in `ee/apps/den-web` +- share/package/connect flows in `apps/app` + +### Phase 5: remove local style drift + +As primitives stabilize: + +- reduce repeated one-off class recipes +- replace raw gray classes in repeated patterns +- collapse duplicate card/button/input styles into primitives + +--- + +## 13. Recommended initial source of truth files + +If we implement this system, the likely canonical files should be: + +- `DESIGN-LANGUAGE.md` — philosophy +- `DESIGN-SYSTEM.md` — system rules and migration plan +- `apps/app/src/app/index.css` — initial token host for app runtime +- `apps/app/tailwind.config.ts` — Tailwind token exposure +- `apps/app/src/app/components/button.tsx` — canonical action primitive start +- `apps/app/src/app/components/card.tsx` — canonical surface primitive start +- `apps/app/src/app/components/text-input.tsx` — canonical field primitive start + +Later, a shared package may make sense, but not before the token model is stable. + +--- + +## 14. Recommended file plan for the next step + +The smallest safe implementation path is: + +### Step A + +Introduce canonical `--ow-*` aliases in `apps/app/src/app/index.css` without removing `--dls-*` yet. + +### Step B + +Refactor `Button`, `Card`, and `TextInput` to consume shared semantic tokens. + +### Step C + +Use the Den dashboard shell as the reference for: + +- sidebar shell +- row selection +- neutral panel rhythm + +### Step D + +Restyle one OpenWork app screen fully using the system to prove the direction. + +Recommended pilot screens: + +- `apps/app/src/app/pages/settings.tsx` +- session/workspace sidebar surfaces +- share workspace modal + +--- + +## 15. What a successful system looks like + +We will know this is working when: + +1. app, Cloud, and landing feel obviously from the same product family +2. a new screen can be built mostly from existing primitives +3. visual changes happen by adjusting tokens or primitives, not by editing many pages +4. selection, buttons, cards, and inputs behave consistently everywhere +5. raw color classes become uncommon outside truly local exceptions + +--- + +## 16. Anti-goals + +This system should not: + +- introduce a trendy visual reboot disconnected from the current product +- replace the OpenWork mood described in `DESIGN-LANGUAGE.md` +- depend on a large new dependency just to manage styling +- force a shared package too early +- block incremental improvements until a perfect system exists + +The correct approach is a strong design system built through small, boring, compounding steps. + +--- + +## 17. Immediate next recommendation + +If continuing from this doc, the best next change is: + +1. add `--ow-*` semantic token aliases in `apps/app/src/app/index.css` +2. standardize `Button`, `Card`, and `TextInput` +3. then restyle one app shell to match the calmer Den dashboard direction + +That gives a real system foothold without a broad rewrite. diff --git a/INFRASTRUCTURE.md b/INFRASTRUCTURE.md new file mode 100644 index 0000000000..018c6b1f61 --- /dev/null +++ b/INFRASTRUCTURE.md @@ -0,0 +1,103 @@ +# OpenWork Infrastructure Principles + +OpenWork is an experience layer. `opencode` is the engine. This document defines how infrastructure is built so every component is usable on its own, composable as a sidecar, and easy to automate. + +## Core Principles + +1. CLI-first, always + +* Every infrastructure component must be runnable via a single CLI command. +* The OpenWork UI may wrap these, but never replace or lock them out. + +2. Unix-like interfaces + +* Prefer simple, composable boundaries: JSON over stdout, flags, and env vars. +* Favor readable logs and predictable exit codes. + +3. Sidecar-composable + +* Any component must run as a sidecar without special casing. +* The UI should connect to the same surface area the CLI exposes. + +4. Clear boundaries + +* OpenCode remains the engine; OpenWork adds a thin config + UX layer. +* When OpenCode exposes a stable API, use it instead of re-implementing. + +5. Local-first, graceful degradation + +* Default to local execution. +* Hosted cloud is a first-class option, not a separate product. +* If a sidecar is missing or offline, the UI falls back to read-only or explicit user guidance. + +6. Portable configuration + +* Use config files + env vars; avoid hidden state. +* Keep credentials outside git and outside the repo. + +7. Observability by default + +* Provide health endpoints and structured logs. +* Record audit events for every config mutation. + +8. Security + scoping + +* All filesystem access is scoped to explicit workspace roots. +* Writes require explicit host approval when requested remotely. + +9. Debuggable by agents + Agents like (you?) make tool calls tool calls can do a variety of things form using chrome + to calling curl, using the cli, using bun, making scripts. + +You're not afraid to run the program on your OS but to benefit from it you need to design the arch +so these things are callable. + +E.g. it is very hard to call a things from the desktop app (you have not a lot of control). + +But what you can do is: + +* run the undelrying clis (since they are implented as sidecar) +* run against real opencode value +* use bash to test endpionts of these various servers/etc +* if needed don't hestiate to ask for credentialse.g. to test telegram or other similar flow + -you should be able to test 99% of the flow on your own + +## Applied to Current Components + +### opencode Engine + +* Always usable via `opencode` CLI. +* OpenWork never replaces the CLI; it only connects to the engine. + +### OpenWork Server + +* Runs standalone via `openwork-server` CLI. +* Provides filesystem-backed config surfaces (skills, plugins, MCP, commands). +* Sidecar lifecycle is described in `packages/app/pr/openwork-server.md`. +* Can also be consumed as a hosted OpenWork Cloud control surface for remote worker lifecycle. + +### OpenWork Cloud Control Plane + +* Hosted deployment of OpenWork server capabilities for worker provisioning and remote connect. +* Must preserve the same user-level contract as self-hosted paths: + - launch worker + - get connect credentials (URL + token) + - connect via `Add worker` -> `Connect remote` +* Should not require a separate mental model for users moving between local and hosted modes. + +### OpenCode Router + +* Runs standalone via `opencode-router` CLI. +* Must be able to use OpenWork server for config and approvals. + +## Non-goals + +* Replacing OpenCode primitives with custom abstractions. +* Forcing cloud-only lock-in (self-hosted desktop/CLI paths must remain valid). + +## References + +* `VISION.md` +* `PRINCIPLES.md` +* `ARCHITECTURE.md` +* `packages/app/pr/openwork-server.md` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..546b09b5df --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2026-present Different AI, Inc. + +Portions of this software are licensed as follows: + +* All content that resides under the /ee directory of this repository is licensed under the license defined in "ee/LICENSE" (Fair Source License). +* All third party components incorporated into the OpenWork Software are licensed under the original license provided by the owner of the applicable component. +* Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below. + +MIT License + +Copyright (c) 2026 Different AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRINCIPLES.md b/PRINCIPLES.md new file mode 100644 index 0000000000..6bcb80d845 --- /dev/null +++ b/PRINCIPLES.md @@ -0,0 +1,31 @@ +# OpenWork Principles + +## Decision framework for adding new features or fixing bugs: + +- is it easy to test? how can we make it more easy ? (e.g. we can use the chrome mcp and pnpm:dev to test ui take screenshots) +- is there an existing opencode equivalent for this feature? (we should use it if we can) if not how does it map to a better user experience for bob *or* susan (see below) +- if it's a bug what were you testing? what were you trying to achieve? what did you observe we can't move on before having a core undesrtanding + +## Constraints + +- Work with **only the folders the user authorizes**. +- Treat **plugins + skills + commands + mcp** as the primary extensibility system. These are native to OpenCode and OpenWork must be a thin layer on top of them. They're mostly fs based. + +## Principles + +- **Parity**: UI actions map to OpenCode server APIs. +- **Server-consumption first**: OpenWork app consumes OpenWork server surfaces (desktop-hosted, orchestrator-hosted, or cloud-hosted) instead of inventing parallel behavior. +- **Transparency**: plans, steps, tool calls, permissions are visible. +- **Least privilege**: only user-authorized folders + explicit approvals. +- **Prompt is the workflow**: product logic lives in prompts, rules, and skills. +- **Graceful degradation**: if access is missing, guide the user. +- **Progressive disclosure by default**: non-technical users should see clear primary actions first; IDs/URLs/diagnostics stay behind explicit "manual" or "advanced" sections. +- **Cloud + self-hosted consistency**: the same connect mental model (`Add worker` -> `Connect remote`) should work regardless of where the server runs. + +## Security & Privacy + +- Local-first by default. +- No secrets in git. +- Use OS keychain for credentials. +- Clear, explicit permissions. +- Exportable audit logs. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000000..610040c5a0 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,60 @@ +## Product + +OpenWork helps individual create, consume, and maintain their agentic workflows. + +OpenWork helps companies share their agentic workflows and provision their entire team. + +The chat interfaces is where people consume the workflows. + +Interfaces for consuming workflows: +- Desktop app +- Slack +- Telegram + +What is a "agentic workflow": +- LLM providers +- Skills +- MCP +- Agents +- Plugins +- Tools +- Background Agents + +Where are workflows created: +- Desktop app (using slash commands like `/create-skills`) +- Web App +- [We need better places for this to happen[ + +Where are workflows maintain: +- In OpenWork Cloud (internal name is Den). + +Where are workflow hosted: +- Local Machine +- Remote via a OpenWork Host (CLI or desktop) +- Remote on OpenWork Cloud (via Den sandbox workers) + +## Current OpenWork Cloud flow + +- Users can sign in with the standard web auth providers or accept an org invite through the hosted join flow. +- Invite signup keeps the invited email fixed, verifies the user by email code, and then drops them into the org join path. +- Cloud workers are a paid flow: users complete checkout before they can launch hosted workers. +- After a worker is ready, the user connects from the OpenWork app with `Add a worker` -> `Connect remote`, or opens the generated deep link directly. + +## Team distribution + +- Organizations can publish shared skill hubs so members discover approved skills from one managed place instead of collecting local-only installs by hand. + +## Actors +Bob IT guy makes the config. +Susan the accountant consumes the config. + +Constraints: +- We use standards were possible +- We use opencode where possible +- We stay platform agnostic + + +How to decide if OpenWork should do something: +- Does it help Bob share config more easily? +- Does it help Susan consume shared workflows more easily? +- Is this something that is coding specific? diff --git a/README.md b/README.md index 0c095c8b14..73eb8eafc7 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,241 @@ -# OpenWork +> OpenWork is the open source alternative to Claude Cowork/Codex (desktop app). -OpenWork is an **extensible, open-source “Claude Work” style system for knowledge workers**. -It’s a native desktop app (Tauri) that runs **OpenCode** under the hood, but presents it as a clean, guided workflow: -- pick a workspace -- start a run -- watch progress + plan updates -- approve permissions when needed -- reuse what works (templates + skills) +## Core Philosophy -The goal: make “agentic work” feel like a product, not a terminal. +- Local-first, cloud-ready: OpenWork runs on your machine in one click. Send a message instantly. +- Composable: desktop app, Slack/Telegram connector, or server. Use what fits, no lock-in. +- Ejectable: OpenWork is powered by OpenCode, so everything OpenCode can do works in OpenWork, even without a UI yet. +- Sharing is caring: start solo on localhost, then explicitly opt into remote sharing when you need it. + +

+ OpenWork demo +

+ +OpenWork is designed around the idea that you can easily ship your agentic workflows for your team as a repeatable, productized process. + +> [!TIP] +> **Looking for an [Enterprise Plan](https://openworklabs.com/enterprise)?** [Speak with our Sales Team today](https://calendar.app.google/86QpCENvhfEzDFLu5) +> +> Get enhanced capabilities including feature prioritization, SSO, SLA support, LTS versions, and more. + +## Alternate UIs +- **OpenWork Orchestrator (CLI host)**: run OpenCode + OpenWork server without the desktop UI. + - install: `npm install -g openwork-orchestrator` + - run: `openwork start --workspace /path/to/workspace --approval auto` + - docs: [apps/orchestrator/README.md](./apps/orchestrator/README.md) + +## Quick start + +Download the desktop app from [openworklabs.com/download](https://openworklabs.com/download), grab the latest [GitHub release](https://github.com/different-ai/openwork/releases), or install from source below. + +- macOS and Linux downloads are available directly. +- Windows access is currently handled through the paid support plan on [openworklabs.com/pricing#windows-support](https://openworklabs.com/pricing#windows-support). +- Hosted OpenWork Cloud workers are launched from the web app after checkout, then connected from the desktop app via `Add a worker` -> `Connect remote`. ## Why -Knowledge workers don’t want to learn a CLI, fight config sprawl, or rebuild the same workflows in every repo. +Current CLI and GUIs for opencode are anchored around developers. That means a focus on file diffs, tool names, and hard to extend capabilities without relying on exposing some form of cli. + OpenWork is designed to be: -- **Extensible**: skills and workflows are installable modules. + +- **Extensible**: skill and opencode plugins are installable modules. - **Auditable**: show what happened, when, and why. -- **Permissioned**: explicit user approval for risky actions. -- **Portable**: keep logic in prompts/skills, not bespoke code. +- **Permissioned**: access to privileged flows. +- **Local/Remote**: OpenWork works locally as well as can connect to remote servers. -## What’s Included (v0.1) +## What’s Included -- **Host mode**: start `opencode serve` locally in a chosen folder. +- **Host mode**: runs opencode locally on your computer - **Client mode**: connect to an existing OpenCode server by URL. - **Sessions**: create/select sessions and send prompts. - **Live streaming**: SSE `/event` subscription for realtime updates. - **Execution plan**: render OpenCode todos as a timeline. - **Permissions**: surface permission requests and reply (allow once / always / deny). - **Templates**: save and re-run common workflows (stored locally). +- **Debug exports**: copy or export the runtime debug report and developer log stream from Settings -> Debug when you need to file a bug. - **Skills manager**: - - list installed `.opencode/skill` folders - - install from OpenPackage (`opkg install ...`) - - import a local skill folder into `.opencode/skill/` + - list installed `.opencode/skills` folders + - import a local skill folder into `.opencode/skills/` + +## Skill Manager + +image + +## Works on local computer or servers + +Screenshot 2026-01-13 at 7 05 16 PM ## Quick Start ### Requirements - Node.js + `pnpm` -- Rust toolchain (for Tauri): `cargo`, `rustc` +- Rust toolchain (for Tauri): install via `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` +- Tauri CLI: `cargo install tauri-cli` - OpenCode CLI installed and available on PATH: `opencode` +### Local Dev Prerequisites (Desktop) + +Before running `pnpm dev`, ensure these are installed and active in your shell: + +- Node + pnpm (repo uses `pnpm@10.27.0`) +- **Bun 1.3.9+** (`bun --version`) +- Rust toolchain (for Tauri), with Cargo from current `rustup` stable (supports `Cargo.lock` v4) +- Xcode Command Line Tools (macOS) +- On Linux, WebKitGTK 4.1 development packages so `pkg-config` can resolve `webkit2gtk-4.1` and `javascriptcoregtk-4.1` + +### One-minute sanity check + +Run from repo root: + +```bash +git checkout dev +git pull --ff-only origin dev +pnpm install --frozen-lockfile + +which bun +bun --version +pnpm --filter @openwork/desktop exec tauri --version +``` + ### Install ```bash pnpm install ``` +OpenWork now lives in `apps/app` (UI) and `apps/desktop` (desktop shell). + ### Run (Desktop) ```bash pnpm dev ``` +`pnpm dev` now enables `OPENWORK_DEV_MODE=1` automatically, so desktop dev uses an isolated OpenCode state instead of your personal global config/auth/data. + ### Run (Web UI only) ```bash -pnpm dev:web +pnpm dev:ui +``` + +All repo `dev` entrypoints now opt into the same dev-mode isolation so local testing uses the OpenWork-managed OpenCode state consistently. + +### Arch Users: + +```bash +sudo pacman -S --needed webkit2gtk-4.1 +curl -fsSL https://opencode.ai/install | bash -s -- --version "$(node -e "const fs=require('fs'); const parsed=JSON.parse(fs.readFileSync('constants.json','utf8')); process.stdout.write(String(parsed.opencodeVersion||'').trim().replace(/^v/,''));")" --no-modify-path ``` ## Architecture (high-level) -- In **Host mode**, OpenWork spawns: - - `opencode serve --hostname 127.0.0.1 --port ` - - with your selected project folder as the process working directory. +- In **Host mode**, OpenWork runs a local host stack and connects the UI to it. + - Default runtime: `openwork` (installed from `openwork-orchestrator`), which orchestrates `opencode`, `openwork-server`, and optionally `opencode-router`. + - Fallback runtime: `direct`, where the desktop app spawns `opencode serve --hostname 127.0.0.1 --port ` directly. + +When you select a project folder, OpenWork runs the host stack locally using that folder and connects the desktop UI. +This lets you run agentic workflows, send prompts, and see progress entirely on your machine without a remote server. + - The UI uses `@opencode-ai/sdk/v2/client` to: - connect to the server - list/create sessions - send prompts - - subscribe to SSE events + - subscribe to SSE events(Server-Sent Events are used to stream real-time updates from the server to the UI.) - read todos and permission requests ## Folder Picker The folder picker uses the Tauri dialog plugin. Capability permissions are defined in: -- `src-tauri/capabilities/default.json` -## OpenPackage Notes +- `apps/desktop/src-tauri/capabilities/default.json` -If `opkg` is not installed globally, OpenWork falls back to: +## OpenCode Plugins -```bash -pnpm dlx opkg install +Plugins are the **native** way to extend OpenCode. OpenWork now manages them from the Skills tab by +reading and writing `opencode.json`. + +- **Project scope**: `/opencode.json` +- **Global scope**: `~/.config/opencode/opencode.json` (or `$XDG_CONFIG_HOME/opencode/opencode.json`) + +You can still edit `opencode.json` manually; OpenWork uses the same format as the OpenCode CLI: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-wakatime"] +} ``` ## Useful Commands ```bash +pnpm dev +pnpm dev:ui pnpm typecheck -pnpm build:web +pnpm build +pnpm build:ui pnpm test:e2e ``` +## Troubleshooting + +If you need to report a desktop or session bug, open Settings -> Debug and export both the runtime debug report and developer logs before filing an issue. + +### Linux / Wayland (Hyprland) + +If OpenWork crashes on launch with WebKitGTK errors like `Failed to create GBM buffer`, disable dmabuf or compositing before launch. Try one of the following environment flags. + +```bash +WEBKIT_DISABLE_DMABUF_RENDERER=1 openwork +``` + +```bash +WEBKIT_DISABLE_COMPOSITING_MODE=1 openwork +``` + ## Security Notes - OpenWork hides model reasoning and sensitive tool metadata by default. - Host mode binds to `127.0.0.1` by default. +## Contributing + +- Review `AGENTS.md` plus `VISION.md`, `PRINCIPLES.md`, `PRODUCT.md`, and `ARCHITECTURE.md` to understand the product goals before making changes. +- Ensure Node.js, `pnpm`, the Rust toolchain, and `opencode` are installed before working inside the repo. +- Run `pnpm install` once per checkout, then verify your change with `pnpm typecheck` plus `pnpm test:e2e` (or the targeted subset of scripts) before opening a PR. +- Use `.github/pull_request_template.md` when opening PRs and include exact commands, outcomes, manual verification steps, and evidence. +- If CI fails, classify failures in the PR body as either code-related regressions or external/environment/auth blockers. +- Add new PRDs to `apps/app/pr/.md` following the `.opencode/skills/prd-conventions/SKILL.md` conventions described in `AGENTS.md`. + +Community docs: + +- `CODE_OF_CONDUCT.md` +- `SECURITY.md` +- `SUPPORT.md` +- `TRIAGE.md` + +First contribution checklist: + +- [ ] Run `pnpm install` and baseline verification commands. +- [ ] Confirm your change has a clear issue link and scope. +- [ ] Add/update tests for behavioral changes. +- [ ] Include commands run and outcomes in your PR. +- [ ] Add screenshots/video for user-facing flow changes. + +## Supported Languages + +Translated READMEs: [`translated_readmes/`](./translated_readmes/README.md), available in English, 简体中文, 繁體中文, 日本語. + +The App is available in the following languages: English (`en`), Japanese (`ja`), Simplified Chinese (`zh`), Vietnamese (`vi`), Brazilian Portuguese (`pt-BR`). + +## For Teams & Businesses + +Interested in using OpenWork in your organization? We'd love to hear from you — reach out at [ben@openworklabs.com](mailto:ben@openworklabs.com) to chat about your use case. + ## License -TBD. +MIT — see `LICENSE`. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..135da4bbd5 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,64 @@ +# Release checklist + +OpenWork releases should be deterministic, easy to reproduce, and fully verifiable with CLI tooling. + +## Preflight + +- Sync the default branch (currently `dev`). +- Run `pnpm release:review` and fix any mismatches. +- If you are building sidecar assets, set `SOURCE_DATE_EPOCH` to the tag timestamp for deterministic manifests. + +## App release (desktop) + +1. Bump versions (app + desktop + Tauri + Cargo): + - `pnpm bump:patch` or `pnpm bump:minor` or `pnpm bump:major` +2. Re-run `pnpm release:review`. +3. Build sidecars for the desktop bundle: + - `pnpm --filter @different-ai/openwork prepare:sidecar` +4. Commit the version bump. +5. Tag and push: + - `git tag vX.Y.Z` + - `git push origin vX.Y.Z` + +## openwork-orchestrator (npm + sidecars) + +1. Bump versions (includes `packages/orchestrator/package.json`): + - `pnpm bump:patch` or `pnpm bump:minor` or `pnpm bump:major` +2. Build sidecar assets and manifest: + - `pnpm --filter openwork-orchestrator build:sidecars` +3. Create the GitHub release for sidecars: + - `gh release create openwork-orchestrator-vX.Y.Z packages/orchestrator/dist/sidecars/* --repo different-ai/openwork` +4. Publish the package: + - `pnpm --filter openwork-orchestrator publish --access public` + +## openwork-server + opencode-router (if version changed) + +- `pnpm --filter openwork-server publish --access public` +- `pnpm --filter opencode-router publish --access public` + +## Verification + +- `openwork start --workspace /path/to/workspace --check --check-events` +- `gh run list --repo different-ai/openwork --workflow "Release App" --limit 5` +- `gh release view vX.Y.Z --repo different-ai/openwork` + +Use `pnpm release:review --json` when automating these checks in scripts or agents. + +## AUR + +`Release App` publishes the Arch AUR package automatically after the Linux `.deb` asset is uploaded. + +For local AMD64 Arch builds without Docker, see `packaging/aur/README.md`. + +Required repo config: + +- GitHub Actions secret: `AUR_SSH_PRIVATE_KEY` (SSH key with push access to the AUR package repo) +- Optional repo variable: `AUR_REPO` (defaults to `openwork`) + +## npm publishing + +If you want `Release App` to publish `openwork-orchestrator`, `openwork-server`, and `opencode-router` to npm, configure: + +- GitHub Actions secret: `NPM_TOKEN` (npm automation token) + +If `NPM_TOKEN` is not set, the npm publish job is skipped. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..b8593f097e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security Policy + +## Supported versions + +OpenWork is under active development and we prioritize fixes on the latest release and +the current `dev` branch. + +## Reporting a vulnerability + +Please do not open public GitHub issues for security vulnerabilities. + +Instead, report vulnerabilities privately to: + +- Email: `ben@openworklabs.com` +- Subject: `[OpenWork security] ` + +Please include: + +- A clear description of the issue +- Reproduction steps or proof of concept +- Impact assessment +- Suggested remediation (if known) + +## Response expectations + +- We will acknowledge receipt within 3 business days. +- We will provide an initial triage status within 7 business days. +- We will share remediation or mitigation guidance as soon as available. + +## Disclosure guidance + +Please keep details private until a fix or mitigation is available and maintainers +confirm public disclosure timing. diff --git a/STATS.md b/STATS.md new file mode 100644 index 0000000000..db9ef41f17 --- /dev/null +++ b/STATS.md @@ -0,0 +1,106 @@ +# Download Stats + +Legacy cumulative release-asset totals. For classified v2 buckets, see `STATS_V2.md`. + +| Date | GitHub Downloads | Total | +|------|------------------|-------| +| 2026-01-24 | 15,879 (+15,879) | 15,879 (+15,879) | +| 2026-01-24 | 17,254 (+1,375) | 17,254 (+1,375) | +| 2026-01-24 | 17,254 (+0) | 17,254 (+0) | +| 2026-01-26 | 19,869 (+2,615) | 19,869 (+2,615) | +| 2026-01-27 | 23,489 (+3,620) | 23,489 (+3,620) | +| 2026-01-28 | 25,238 (+1,749) | 25,238 (+1,749) | +| 2026-01-29 | 26,939 (+1,701) | 26,939 (+1,701) | +| 2026-01-30 | 28,718 (+1,779) | 28,718 (+1,779) | +| 2026-01-31 | 30,070 (+1,352) | 30,070 (+1,352) | +| 2026-02-01 | 31,383 (+1,313) | 31,383 (+1,313) | +| 2026-02-02 | 33,206 (+1,823) | 33,206 (+1,823) | +| 2026-02-03 | 35,064 (+1,858) | 35,064 (+1,858) | +| 2026-02-04 | 38,330 (+3,266) | 38,330 (+3,266) | +| 2026-02-05 | 41,657 (+3,327) | 41,657 (+3,327) | +| 2026-02-06 | 44,561 (+2,904) | 44,561 (+2,904) | +| 2026-02-07 | 47,783 (+3,222) | 47,783 (+3,222) | +| 2026-02-08 | 51,070 (+3,287) | 51,070 (+3,287) | +| 2026-02-09 | 54,793 (+3,723) | 54,793 (+3,723) | +| 2026-02-10 | 58,605 (+3,812) | 58,605 (+3,812) | +| 2026-02-11 | 62,536 (+3,931) | 62,536 (+3,931) | +| 2026-02-12 | 66,149 (+3,613) | 66,149 (+3,613) | +| 2026-02-13 | 69,528 (+3,379) | 69,528 (+3,379) | +| 2026-02-14 | 72,204 (+2,676) | 72,204 (+2,676) | +| 2026-02-15 | 74,561 (+2,357) | 74,561 (+2,357) | +| 2026-02-16 | 77,144 (+2,583) | 77,144 (+2,583) | +| 2026-02-17 | 79,817 (+2,673) | 79,817 (+2,673) | +| 2026-02-18 | 83,020 (+3,203) | 83,020 (+3,203) | +| 2026-02-19 | 86,687 (+3,667) | 86,687 (+3,667) | +| 2026-02-20 | 90,491 (+3,804) | 90,491 (+3,804) | +| 2026-02-21 | 94,409 (+3,918) | 94,409 (+3,918) | +| 2026-02-22 | 99,076 (+4,667) | 99,076 (+4,667) | +| 2026-02-23 | 103,810 (+4,734) | 103,810 (+4,734) | +| 2026-02-24 | 108,788 (+4,978) | 108,788 (+4,978) | +| 2026-02-25 | 113,976 (+5,188) | 113,976 (+5,188) | +| 2026-02-26 | 119,570 (+5,594) | 119,570 (+5,594) | +| 2026-02-27 | 125,213 (+5,643) | 125,213 (+5,643) | +| 2026-02-28 | 130,766 (+5,553) | 130,766 (+5,553) | +| 2026-03-01 | 133,877 (+3,111) | 133,877 (+3,111) | +| 2026-03-02 | 139,092 (+5,215) | 139,092 (+5,215) | +| 2026-03-03 | 144,346 (+5,254) | 144,346 (+5,254) | +| 2026-03-04 | 148,772 (+4,426) | 148,772 (+4,426) | +| 2026-03-05 | 152,105 (+3,333) | 152,105 (+3,333) | +| 2026-03-06 | 155,629 (+3,524) | 155,629 (+3,524) | +| 2026-03-07 | 157,784 (+2,155) | 157,784 (+2,155) | +| 2026-03-07 | 158,107 (+323) | 158,107 (+323) | +| 2026-03-08 | 159,616 (+1,509) | 159,616 (+1,509) | +| 2026-03-09 | 162,103 (+2,487) | 162,103 (+2,487) | +| 2026-03-10 | 165,406 (+3,303) | 165,406 (+3,303) | +| 2026-03-11 | 168,897 (+3,491) | 168,897 (+3,491) | +| 2026-03-12 | 172,707 (+3,810) | 172,707 (+3,810) | +| 2026-03-13 | 176,511 (+3,804) | 176,511 (+3,804) | +| 2026-03-14 | 177,484 (+973) | 177,484 (+973) | +| 2026-03-15 | 178,354 (+870) | 178,354 (+870) | +| 2026-03-16 | 179,050 (+696) | 179,050 (+696) | +| 2026-03-17 | 180,297 (+1,247) | 180,297 (+1,247) | +| 2026-03-18 | 181,354 (+1,057) | 181,354 (+1,057) | +| 2026-03-19 | 182,208 (+854) | 182,208 (+854) | +| 2026-03-20 | 183,136 (+928) | 183,136 (+928) | +| 2026-03-21 | 184,156 (+1,020) | 184,156 (+1,020) | +| 2026-03-22 | 184,744 (+588) | 184,744 (+588) | +| 2026-03-23 | 185,371 (+627) | 185,371 (+627) | +| 2026-03-24 | 186,649 (+1,278) | 186,649 (+1,278) | +| 2026-03-25 | 187,746 (+1,097) | 187,746 (+1,097) | +| 2026-03-26 | 193,858 (+6,112) | 193,858 (+6,112) | +| 2026-03-27 | 200,722 (+6,864) | 200,722 (+6,864) | +| 2026-03-28 | 206,754 (+6,032) | 206,754 (+6,032) | +| 2026-03-29 | 211,210 (+4,456) | 211,210 (+4,456) | +| 2026-03-30 | 217,507 (+6,297) | 217,507 (+6,297) | +| 2026-03-31 | 225,120 (+7,613) | 225,120 (+7,613) | +| 2026-04-01 | 232,042 (+6,922) | 232,042 (+6,922) | +| 2026-04-02 | 251,721 (+19,679) | 251,721 (+19,679) | +| 2026-04-03 | 300,714 (+48,993) | 300,714 (+48,993) | +| 2026-04-04 | 345,411 (+44,697) | 345,411 (+44,697) | +| 2026-04-05 | 388,231 (+42,820) | 388,231 (+42,820) | +| 2026-04-06 | 428,311 (+40,080) | 428,311 (+40,080) | +| 2026-04-07 | 471,893 (+43,582) | 471,893 (+43,582) | +| 2026-04-08 | 517,274 (+45,381) | 517,274 (+45,381) | +| 2026-04-09 | 560,586 (+43,312) | 560,586 (+43,312) | +| 2026-04-10 | 602,133 (+41,547) | 602,133 (+41,547) | +| 2026-04-11 | 638,258 (+36,125) | 638,258 (+36,125) | +| 2026-04-12 | 671,199 (+32,941) | 671,199 (+32,941) | +| 2026-04-13 | 705,072 (+33,873) | 705,072 (+33,873) | +| 2026-04-14 | 725,846 (+20,774) | 725,846 (+20,774) | +| 2026-04-15 | 731,872 (+6,026) | 731,872 (+6,026) | +| 2026-04-16 | 738,176 (+6,304) | 738,176 (+6,304) | +| 2026-04-17 | 743,397 (+5,221) | 743,397 (+5,221) | +| 2026-04-18 | 747,542 (+4,145) | 747,542 (+4,145) | +| 2026-04-19 | 751,067 (+3,525) | 751,067 (+3,525) | +| 2026-04-20 | 755,765 (+4,698) | 755,765 (+4,698) | +| 2026-04-21 | 762,220 (+6,455) | 762,220 (+6,455) | +| 2026-04-22 | 767,840 (+5,620) | 767,840 (+5,620) | +| 2026-04-23 | 773,380 (+5,540) | 773,380 (+5,540) | +| 2026-04-24 | 778,287 (+4,907) | 778,287 (+4,907) | +| 2026-04-25 | 782,587 (+4,300) | 782,587 (+4,300) | +| 2026-04-26 | 786,791 (+4,204) | 786,791 (+4,204) | +| 2026-04-27 | 791,564 (+4,773) | 791,564 (+4,773) | +| 2026-04-28 | 795,811 (+4,247) | 795,811 (+4,247) | +| 2026-04-29 | 799,679 (+3,868) | 799,679 (+3,868) | +| 2026-04-30 | 805,584 (+5,905) | 805,584 (+5,905) | +| 2026-05-01 | 809,415 (+3,831) | 809,415 (+3,831) | diff --git a/STATS_V2.md b/STATS_V2.md new file mode 100644 index 0000000000..17846a0437 --- /dev/null +++ b/STATS_V2.md @@ -0,0 +1,62 @@ +# Download Stats V2 + +Classified GitHub release asset snapshots. `Manual installs` counts installer downloads (`.dmg`, `.msi`, `.deb`, `.rpm`). `Updater` counts updater artifacts (`latest.json`, macOS updater bundles, updater signatures). `Other` captures signatures, sidecars, and uncategorized assets. + +| Date | Manual Installs | Updater | Other | All Release Assets | +|------|-----------------|---------|-------|--------------------| +| 2026-03-07 | 54,446 (+54,446) | 89,201 (+89,201) | 14,460 (+14,460) | 158,107 (+158,107) | +| 2026-03-08 | 54,727 (+281) | 90,315 (+1,114) | 14,574 (+114) | 159,616 (+1,509) | +| 2026-03-09 | 55,242 (+515) | 92,073 (+1,758) | 14,788 (+214) | 162,103 (+2,487) | +| 2026-03-10 | 56,051 (+809) | 94,325 (+2,252) | 15,030 (+242) | 165,406 (+3,303) | +| 2026-03-11 | 56,914 (+863) | 96,683 (+2,358) | 15,300 (+270) | 168,897 (+3,491) | +| 2026-03-12 | 57,703 (+789) | 99,150 (+2,467) | 15,854 (+554) | 172,707 (+3,810) | +| 2026-03-13 | 58,605 (+902) | 101,229 (+2,079) | 16,677 (+823) | 176,511 (+3,804) | +| 2026-03-14 | 58,838 (+233) | 101,875 (+646) | 16,771 (+94) | 177,484 (+973) | +| 2026-03-15 | 59,168 (+330) | 102,281 (+406) | 16,905 (+134) | 178,354 (+870) | +| 2026-03-16 | 59,363 (+195) | 102,655 (+374) | 17,032 (+127) | 179,050 (+696) | +| 2026-03-17 | 59,631 (+268) | 103,431 (+776) | 17,235 (+203) | 180,297 (+1,247) | +| 2026-03-18 | 59,845 (+214) | 104,136 (+705) | 17,373 (+138) | 181,354 (+1,057) | +| 2026-03-19 | 60,045 (+200) | 104,667 (+531) | 17,496 (+123) | 182,208 (+854) | +| 2026-03-20 | 60,221 (+176) | 105,278 (+611) | 17,637 (+141) | 183,136 (+928) | +| 2026-03-21 | 60,558 (+337) | 105,839 (+561) | 17,759 (+122) | 184,156 (+1,020) | +| 2026-03-22 | 60,687 (+129) | 106,219 (+380) | 17,838 (+79) | 184,744 (+588) | +| 2026-03-23 | 60,848 (+161) | 106,545 (+326) | 17,978 (+140) | 185,371 (+627) | +| 2026-03-24 | 61,247 (+399) | 107,230 (+685) | 18,172 (+194) | 186,649 (+1,278) | +| 2026-03-25 | 61,477 (+230) | 107,957 (+727) | 18,312 (+140) | 187,746 (+1,097) | +| 2026-03-26 | 63,032 (+1,555) | 112,084 (+4,127) | 18,742 (+430) | 193,858 (+6,112) | +| 2026-03-27 | 64,244 (+1,212) | 117,236 (+5,152) | 19,242 (+500) | 200,722 (+6,864) | +| 2026-03-28 | 65,441 (+1,197) | 121,574 (+4,338) | 19,739 (+497) | 206,754 (+6,032) | +| 2026-03-29 | 66,202 (+761) | 125,041 (+3,467) | 19,967 (+228) | 211,210 (+4,456) | +| 2026-03-30 | 67,249 (+1,047) | 129,987 (+4,946) | 20,271 (+304) | 217,507 (+6,297) | +| 2026-03-31 | 68,732 (+1,483) | 135,648 (+5,661) | 20,740 (+469) | 225,120 (+7,613) | +| 2026-04-01 | 69,871 (+1,139) | 140,959 (+5,311) | 21,212 (+472) | 232,042 (+6,922) | +| 2026-04-02 | 70,782 (+911) | 159,313 (+18,354) | 21,626 (+414) | 251,721 (+19,679) | +| 2026-04-03 | 71,365 (+583) | 207,310 (+47,997) | 22,039 (+413) | 300,714 (+48,993) | +| 2026-04-04 | 71,953 (+588) | 251,008 (+43,698) | 22,450 (+411) | 345,411 (+44,697) | +| 2026-04-05 | 72,498 (+545) | 292,876 (+41,868) | 22,857 (+407) | 388,231 (+42,820) | +| 2026-04-06 | 73,191 (+693) | 331,794 (+38,918) | 23,326 (+469) | 428,311 (+40,080) | +| 2026-04-07 | 73,774 (+583) | 374,167 (+42,373) | 23,952 (+626) | 471,893 (+43,582) | +| 2026-04-08 | 74,644 (+870) | 417,934 (+43,767) | 24,696 (+744) | 517,274 (+45,381) | +| 2026-04-09 | 75,240 (+596) | 460,144 (+42,210) | 25,202 (+506) | 560,586 (+43,312) | +| 2026-04-10 | 75,755 (+515) | 500,754 (+40,610) | 25,624 (+422) | 602,133 (+41,547) | +| 2026-04-11 | 76,295 (+540) | 535,996 (+35,242) | 25,967 (+343) | 638,258 (+36,125) | +| 2026-04-12 | 76,990 (+695) | 567,970 (+31,974) | 26,239 (+272) | 671,199 (+32,941) | +| 2026-04-13 | 77,567 (+577) | 600,843 (+32,873) | 26,662 (+423) | 705,072 (+33,873) | +| 2026-04-14 | 78,520 (+953) | 620,193 (+19,350) | 27,133 (+471) | 725,846 (+20,774) | +| 2026-04-15 | 79,422 (+902) | 624,874 (+4,681) | 27,576 (+443) | 731,872 (+6,026) | +| 2026-04-16 | 80,356 (+934) | 629,753 (+4,879) | 28,067 (+491) | 738,176 (+6,304) | +| 2026-04-17 | 81,107 (+751) | 633,807 (+4,054) | 28,483 (+416) | 743,397 (+5,221) | +| 2026-04-18 | 81,864 (+757) | 636,892 (+3,085) | 28,786 (+303) | 747,542 (+4,145) | +| 2026-04-19 | 82,486 (+622) | 639,536 (+2,644) | 29,045 (+259) | 751,067 (+3,525) | +| 2026-04-20 | 83,222 (+736) | 643,271 (+3,735) | 29,272 (+227) | 755,765 (+4,698) | +| 2026-04-21 | 84,271 (+1,049) | 648,249 (+4,978) | 29,700 (+428) | 762,220 (+6,455) | +| 2026-04-22 | 85,162 (+891) | 652,627 (+4,378) | 30,051 (+351) | 767,840 (+5,620) | +| 2026-04-23 | 86,133 (+971) | 656,777 (+4,150) | 30,470 (+419) | 773,380 (+5,540) | +| 2026-04-24 | 86,915 (+782) | 660,538 (+3,761) | 30,834 (+364) | 778,287 (+4,907) | +| 2026-04-25 | 87,706 (+791) | 663,737 (+3,199) | 31,144 (+310) | 782,587 (+4,300) | +| 2026-04-26 | 88,514 (+808) | 666,704 (+2,967) | 31,573 (+429) | 786,791 (+4,204) | +| 2026-04-27 | 89,347 (+833) | 670,293 (+3,589) | 31,924 (+351) | 791,564 (+4,773) | +| 2026-04-28 | 90,158 (+811) | 672,622 (+2,329) | 33,031 (+1,107) | 795,811 (+4,247) | +| 2026-04-29 | 91,297 (+1,139) | 674,416 (+1,794) | 33,966 (+935) | 799,679 (+3,868) | +| 2026-04-30 | 92,816 (+1,519) | 677,341 (+2,925) | 35,427 (+1,461) | 805,584 (+5,905) | +| 2026-05-01 | 93,823 (+1,007) | 678,963 (+1,622) | 36,629 (+1,202) | 809,415 (+3,831) | diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000000..37b51dd6ef --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,23 @@ +# Support + +## Where to ask for help + +Use the right channel to get faster help: + +- **Questions / usage help**: open a GitHub issue and mark it as a question. +- **Bug reports**: use the Bug issue template. +- **Feature requests**: use the Feature issue template. +- **Security reports**: follow `SECURITY.md` and report privately. + +## Before opening an issue + +- Search existing issues to avoid duplicates. +- Include exact OpenWork/OpenCode versions, OS, and reproduction steps. +- For desktop, worker, or session bugs, open Settings -> Debug and include both: + - the runtime debug report + - the developer log export +- Add screenshots when they help explain the flow or failure state. + +## Maintainer triage + +Maintainers use the rubric in `TRIAGE.md` to label and route issues. diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md new file mode 100644 index 0000000000..92c0a41aa2 --- /dev/null +++ b/TRANSLATIONS.md @@ -0,0 +1,27 @@ +# Help Translate OpenWork + +We are actively looking for contributors to translate OpenWork to your own native language. + +## README translations + +Translated README variants live in `translated_readmes/`, so adding a new language only touches the index there plus the supported languages list in the root `README.md`. + +If you want to add a new README language: + +1. Copy `README.md` to a new file like `translated_readmes/README_.md`. +2. Translate the content. +3. Add your new language link to `translated_readmes/README.md`. +4. Add your language name to the supported languages list at the bottom of `README.md`. +5. Open a PR. + +## App UI translations (i18n) + +You can also help translate the app UI via: + +- `packages/app/src/i18n/` + +Currently available app UI locales: English (`en`), Japanese (`ja`), Simplified Chinese (`zh`), Vietnamese (`vi`), Brazilian Portuguese (`pt-BR`). + +Locale files live in `packages/app/src/i18n/locales/`. + +If you are unsure where to start, open an issue and mention the language you want to contribute. diff --git a/TRIAGE.md b/TRIAGE.md new file mode 100644 index 0000000000..ec4981f225 --- /dev/null +++ b/TRIAGE.md @@ -0,0 +1,35 @@ +# Issue Triage Rubric + +This document defines how maintainers triage issues consistently. + +## Core type labels + +- `bug`: behavior does not match expected behavior. +- `enhancement`: improvement to existing behavior. +- `question`: support or usage request. + +Apply exactly one core type label whenever possible. + +## Contribution-oriented labels + +- `good first issue`: small, well-scoped, low-risk, and has clear acceptance criteria. +- `help wanted` or `help needed`: maintainers welcome external contributions. +- `needs-info`: issue cannot be actioned until reporter provides missing details. + +## Suggested triage flow + +1. Confirm issue template fields are complete. +2. Add a core type label (`bug`, `enhancement`, or `question`). +3. Add scope/difficulty labels if helpful. +4. Add `needs-info` when reproduction details are missing. +5. Add `good first issue` or `help wanted/help needed` only when issue is ready to build. + +## Closing guidance + +Close as: + +- `duplicate`: issue already tracked elsewhere. +- `invalid`: report is not actionable or not a product issue. +- `wontfix`: acknowledged but not planned. + +When closing, include a short reason and a link to related issue/docs when available. diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000000..817d5524c0 --- /dev/null +++ b/VISION.md @@ -0,0 +1,31 @@ +# OpenWork Vision + +**Mission:** Make your company feel 1000× more productive. + +**How:** We give AI agents the tools your team already uses and let them learn from your behavior. The more you use OpenWork, the more connected your tools become, the more knowledge accumulates, and the bigger the chunks of work you can automate. + +**Today:** OpenWork is the simplest interface to `opencode` and OpenWork server surfaces. Double-click, pick a folder, and you get three things instantly: + +1. **Zero-friction setup** — your existing opencode configuration just works, no migration needed +2. **Chat access** — WhatsApp and Telegram ready to go (one token, done) +3. **Cloud-ready** — every app doubles as a client; connect to hosted workers from anywhere + +Current cloud mental model: + +- OpenWork app is the experience layer. +- OpenWork server is the control/API layer. +- OpenWork worker is the runtime destination. +- Connect flow is intentionally simple: `Add a worker` -> `Connect remote`. + +OpenWork helps users ship agentic workflows to their team. It works on top of opencode (opencode.ai) an agentic coding platform that exposes apis and sdks. We care about maximally using the opencode primitives. And build the thinest possible layer - always favoring opencode apis over custom built ones. + +In other words: +- OpenCode is the **engine**. +- OpenWork is the **experience** : onboarding, safety, permissions, progress, artifacts, and a premium-feeling UI. + +OpenWork competes directly with Anthropic's Cowork conceptually, but stays open, local-first, and standards-based. + +## Non-Goals + +- Replacing OpenCode's CLI/TUI. +- Creating bespoke "magic" capabilities that don't map to OpenCode APIs. diff --git a/app-demo.gif b/app-demo.gif new file mode 100644 index 0000000000..b4618fd1dd Binary files /dev/null and b/app-demo.gif differ diff --git a/apps/app/.env.migration-release b/apps/app/.env.migration-release new file mode 100644 index 0000000000..c47d147dfa --- /dev/null +++ b/apps/app/.env.migration-release @@ -0,0 +1,9 @@ +# Generated by scripts/migration/01-cut-migration-release.mjs. +# Consumed by apps/app Vite build during the v0.12.0 release only. +VITE_OPENWORK_MIGRATION_RELEASE=1 +VITE_OPENWORK_MIGRATION_VERSION=0.13.3 +VITE_OPENWORK_MIGRATION_MAC_ARM64_URL=https://github.com/different-ai/openwork/releases/download/v0.13.3/openwork-mac-arm64-0.13.3.zip +VITE_OPENWORK_MIGRATION_MAC_X64_URL=https://github.com/different-ai/openwork/releases/download/v0.13.3/openwork-mac-x64-0.13.3.zip +VITE_OPENWORK_MIGRATION_WINDOWS_X64_URL=https://github.com/different-ai/openwork/releases/download/v0.13.3/openwork-win-x64-0.13.3.exe +VITE_OPENWORK_MIGRATION_LINUX_ARM64_URL=https://github.com/different-ai/openwork/releases/download/v0.13.3/openwork-linux-arm64-0.13.3.AppImage +VITE_OPENWORK_MIGRATION_LINUX_X64_URL=https://github.com/different-ai/openwork/releases/download/v0.13.3/openwork-linux-x86_64-0.13.3.AppImage \ No newline at end of file diff --git a/apps/app/index.html b/apps/app/index.html new file mode 100644 index 0000000000..7ac4f5640d --- /dev/null +++ b/apps/app/index.html @@ -0,0 +1,31 @@ + + + + + + OpenWork + + + + + + + + +
+ + + diff --git a/apps/app/package.json b/apps/app/package.json new file mode 100644 index 0000000000..4d32d41a5d --- /dev/null +++ b/apps/app/package.json @@ -0,0 +1,82 @@ +{ + "name": "@openwork/app", + "private": true, + "version": "0.13.3", + "type": "module", + "scripts": { + "dev": "OPENWORK_DEV_MODE=1 vite", + "dev:windows": "vite", + "prebuild": "pnpm --dir ../../packages/ui build", + "build": "vite build", + "dev:web": "OPENWORK_DEV_MODE=1 vite", + "prebuild:web": "pnpm --dir ../../packages/ui build", + "build:web": "vite build", + "preview": "vite preview", + "pretypecheck": "pnpm --dir ../../packages/ui build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test:health": "node scripts/health.mjs", + "test:mention-send": "node scripts/mention-send.mjs", + "test:sessions": "node scripts/sessions.mjs", + "test:refactor": "pnpm typecheck && pnpm test:health && pnpm test:sessions", + "test:events": "node scripts/events.mjs", + "test:todos": "node scripts/todos.mjs", + "test:permissions": "node scripts/permissions.mjs", + "test:dev-log": "bun scripts/dev-log.ts", + "test:session-error-recovery": "bun scripts/session-error-recovery.ts", + "test:session-scope": "bun scripts/session-scope.ts", + "test:session-switch": "node scripts/session-switch.mjs", + "test:fs-engine": "node scripts/fs-engine.mjs", + "test:local-file-path": "node scripts/local-file-path.mjs", + "test:browser-entry": "node scripts/browser-entry.mjs", + "test:e2e": "pnpm test:local-file-path && node scripts/e2e.mjs && node scripts/session-switch.mjs && node scripts/fs-engine.mjs && node scripts/browser-entry.mjs", + "bump:patch": "node scripts/bump-version.mjs patch", + "bump:minor": "node scripts/bump-version.mjs minor", + "bump:major": "node scripts/bump-version.mjs major", + "bump:set": "node scripts/bump-version.mjs --set" + }, + "dependencies": { + "@ai-sdk/react": "^3.0.148", + "@codemirror/commands": "^6.8.0", + "@codemirror/lang-markdown": "^6.3.3", + "@codemirror/language": "^6.11.0", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.38.0", + "@lexical/react": "^0.35.0", + "@opencode-ai/sdk": "^1.4.9", + "@openwork/types": "workspace:*", + "@openwork/ui": "workspace:*", + "@radix-ui/colors": "^3.0.0", + "@tanstack/react-query": "^5.90.3", + "@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", + "@tauri-apps/plugin-http": "~2.5.6", + "@tauri-apps/plugin-opener": "^2.5.3", + "@tauri-apps/plugin-process": "~2.3.1", + "@tauri-apps/plugin-updater": "~2.9.0", + "ai": "^6.0.146", + "fuzzysort": "^3.1.0", + "jsonc-parser": "^3.2.1", + "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", + "streamdown": "^2.5.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.0.4", + "tailwindcss": "^4.1.18", + "typescript": "^5.6.3", + "vite": "^6.0.1" + }, + "packageManager": "pnpm@10.27.0" +} diff --git a/apps/app/pr/cloud-settings-dark-after.png b/apps/app/pr/cloud-settings-dark-after.png new file mode 100644 index 0000000000..f7ea309b7f Binary files /dev/null and b/apps/app/pr/cloud-settings-dark-after.png differ diff --git a/apps/app/pr/cloud-settings-dark-before.png b/apps/app/pr/cloud-settings-dark-before.png new file mode 100644 index 0000000000..32d75f6e31 Binary files /dev/null and b/apps/app/pr/cloud-settings-dark-before.png differ diff --git a/apps/app/pr/environment-variables-dark.png b/apps/app/pr/environment-variables-dark.png new file mode 100644 index 0000000000..6375907079 Binary files /dev/null and b/apps/app/pr/environment-variables-dark.png differ diff --git a/apps/app/pr/environment-variables-demo.mp4 b/apps/app/pr/environment-variables-demo.mp4 new file mode 100644 index 0000000000..22b717d8ee Binary files /dev/null and b/apps/app/pr/environment-variables-demo.mp4 differ diff --git a/apps/app/pr/environment-variables.md b/apps/app/pr/environment-variables.md new file mode 100644 index 0000000000..0a9a0518f4 --- /dev/null +++ b/apps/app/pr/environment-variables.md @@ -0,0 +1,189 @@ +# Environment variables UI + +Closes #1436. + +## Why + +Agentic workflows pull in secrets from every direction — LLM provider keys, +ElevenLabs for TTS, Gemini / Nano Banana for images, GitHub tokens for repo +automation, cloud project IDs, corporate proxies and CA certs. Skills and +MCPs in a workspace assume those values exist in the process environment. + +Today the only way to get them there is to edit shell rc files and launch +OpenWork from a terminal, which: + +- **Breaks entirely on Linux GUI launches** (`.bashrc` isn't sourced) — the + concrete user report in #1436. +- **Is invisible friction for non-technical teammates** (the "Susan" persona + called out in `AGENTS.md`). +- Has no masking, no audit trail, no reserved-keys guardrail. + +This PR adds a first-class **Settings → Environment** pane. Credentials go +in once, and every child OpenWork spawns — OpenCode, the OpenWork server, +opencode-router, and any MCP or plugin those three launch — inherits them +via OS process environment. + +Boundaries vs. adjacent features: + +- Not a replacement for OpenCode's native `provider auth` flow, which owns + credentials for LLM providers OpenCode directly supports (stored in + `auth.json`). Users should keep using that for model keys where possible. +- Not a replacement for Den's cloud `LLM Providers` push, which owns + org-wide distribution for signed-in users. On remote workspaces, the pane + shows a read-only hint and does not fetch or display local env values. +- This fills the OSS / local-machine path for every other service skills + and MCPs call into — ElevenLabs, Gemini image APIs, GitHub, Notion, + LangSmith / OTEL exporters, proxy + CA-cert config, and so on. + +## Storage + +Deterministic path, identical across every loader: + +| OS | Path | +| --- | --- | +| Linux / macOS | `~/.config/openwork/env.json` | +| Windows | `%APPDATA%\openwork\env.json` | + +Override via `OPENWORK_ENV_STORE` (mirrors `OPENWORK_TOKEN_STORE`). The file +is written with `0o600` perms on POSIX. + +Shape: + +```json +{ + "schemaVersion": 1, + "updatedAt": 1714000000000, + "variables": [ + { "key": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "updatedAt": 1714000000000 } + ] +} +``` + +## Server + +`EnvService` at `apps/server/src/env-file.ts` — mirrors the `TokenService` +pattern. Four desktop-host-token routes on the OpenWork server, so remote +owner/collaborator/viewer clients and OpenCode tools are structurally unable to +reach them: + +- `GET /env` → `{ items: [{ key, value, updatedAt }] }` (values raw; the UI masks presentationally) +- `GET /env/keys` → `{ keys: [...] }` (names only, used for agent context) +- `PUT /env` → single entry `{ key, value }` or batch `{ entries: [...] }` +- `DELETE /env/:key` + +## Shell spawn injection + +Same file, four loaders that agree byte-for-byte on path + reserved-keys policy: + +| Host | File | Integration point | +| --- | --- | --- | +| Tauri (Rust) | `apps/desktop/src-tauri/src/env_file.rs` | merged into 4 spawn sites alongside `bun_env_overrides()` | +| Electron (Node) | inline in `apps/desktop/electron/runtime.mjs` | single `buildChildEnv()` helper | +| Orchestrator (TS) | inline in `apps/orchestrator/src/cli.ts` | single `buildSpawnEnv()` helper | +| Server injection helper | `EnvService.readForInjection()` | reserved by consumers that want to reuse the TS reader | + +Merge order on every host: **user env first, process env / caller env wins.** +This matches the Linux-GUI case (no shell env → user env fills in) and never +lets the user shadow wiring the shell has already set. + +Reserved-keys policy: **any key starting with `OPENWORK_` or `OPENCODE_`** is +refused at write time and stripped at read time. Defends against a +hand-edited `env.json` that tries to shadow auth credentials. + +## UI + +`apps/app/src/react-app/domains/settings/pages/environment-view.tsx` — +self-contained React pane registered as a **global** settings tab (user-level +data, not workspace-scoped). Drops into the existing settings shell with one +line in each of `types.ts`, `settings-page.tsx`, `settings-route.tsx`. + +- Table with masked values (`ab••••yz`), reveal/hide toggle per row, add/edit + modal, delete-with-confirm. +- Client-side key validation mirrors the server (`^[A-Za-z_][A-Za-z0-9_]*$`) + + reserved-prefix check. +- Writes are saved immediately and then marked as pending. The user can click + **Apply changes** to restart the local agents so the new environment is + active without a full app relaunch. +- Remote workspaces show a read-only hint and do not list local env values. + +## Reload semantics + +Env vars are fixed in a process's environment at spawn time, so saving the +file alone cannot update an already-running OpenCode/server/router child. The +pane makes that explicit: after a successful write it shows a pending state and +an **Apply changes** action. Applying restarts the local OpenWork runtime with +the sidecar orchestrator path, preserves the local workspace list and remote +access setting, reconnects the client, then clears the pending state. + +Until pending changes are applied, the app does not inject newly saved key names +into agent system context. That avoids the agent claiming a key is configured +before the running processes can actually read it. + +The same restart boundary is used for delete: removed key names stop appearing +after **Apply changes**. + +## Agent context + +The app never sends secret values to the model. When there are no pending +environment changes, it calls `GET /env/keys` and sends only configured key +names as per-message system context: + +```text +OpenWork environment variables configured: +- EXAMPLE_API_KEY + +Only names are shown; values are secret. Use these names when relevant. +``` + +This is not written into `AGENTS.md`; OpenCode combines it with its normal +instruction sources for that prompt. + +## i18n + +All strings live under the `settings.environment.*` and +`settings.tab_*_environment` namespaces. Full translations in `en.ts`, +`zh.ts`, `ja.ts`. Other locales (`vi`, `pt-BR`, `th`, `fr`, `ca`, `es`) fall +back to English via the existing `t()` runtime (`i18n/index.ts:109-113`), so +nothing ships as raw keys. + +## Tests + +| Layer | File | What | +| --- | --- | --- | +| Server unit | `apps/server/src/env-file.test.ts` | 12 tests — path resolution, validation, reserved keys, perms, round-trip, tampered-file defense | +| Server HTTP e2e | `apps/server/src/env-routes.e2e.test.ts` | 12 tests — auth 401, owner-bearer rejection, CORS PUT preflight, PUT/GET round-trip, key-name-only route, batch PUT, invalid key 400, reserved key 400, DELETE missing/found, restart persistence | +| Tauri Rust unit | `apps/desktop/src-tauri/src/env_file.rs` | 4 tests — missing file, malformed JSON, well-formed load, reserved-key strip | + +Bun picks up `*.e2e.test.ts` automatically — no CI wiring change. + +## Verification ran in latest review + +``` +pnpm --filter openwork-server test # 107 pass, 0 fail +bun test ./src/env-file.test.ts ./src/env-routes.e2e.test.ts # 24 pass, 0 fail +pnpm --filter openwork-server typecheck # clean +pnpm --filter openwork-server build:bin # ok +pnpm --filter openwork-orchestrator typecheck # clean +pnpm --filter @openwork/app typecheck # clean +pnpm build:ui # ok (production Vite; large chunk warning unchanged) +node --check apps/desktop/electron/runtime.mjs # ok +node --check apps/desktop/scripts/prepare-sidecar.mjs # ok +node --check apps/desktop/scripts/tauri-before-dev.mjs # ok +PATH="$HOME/.cargo/bin:$PATH" cargo test env_file # 4 pass, 0 fail +git diff --check # clean +``` + +## Evidence + +- Screenshot: `apps/app/pr/environment-variables-dark.png` +- Demo recording: `apps/app/pr/environment-variables-demo.mp4` + +## Non-goals (follow-ups) + +- OS keychain storage (`PRINCIPLES.md` line 29). JSON + `0o600` matches the + existing `tokens.ts` precedent and keeps the Rust reader trivial (no + keychain FFI). A follow-up PR can migrate values into the keychain while + leaving the JSON file as a manifest of key names + timestamps. +- Per-workspace scoping. The issue asks for user-level; workspace overrides + are a separate feature. +- Cloud push for MCP keys — owned by the Den / LLM Providers team. diff --git a/apps/app/pr/first-boot-real-shell.png b/apps/app/pr/first-boot-real-shell.png new file mode 100644 index 0000000000..97db14c594 Binary files /dev/null and b/apps/app/pr/first-boot-real-shell.png differ diff --git a/apps/app/pr/permission-approval-dark.png b/apps/app/pr/permission-approval-dark.png new file mode 100644 index 0000000000..3570f98500 Binary files /dev/null and b/apps/app/pr/permission-approval-dark.png differ diff --git a/apps/app/pr/permission-approval-light.png b/apps/app/pr/permission-approval-light.png new file mode 100644 index 0000000000..9247c1317d Binary files /dev/null and b/apps/app/pr/permission-approval-light.png differ diff --git a/apps/app/pr/session-error-recovery-offline.png b/apps/app/pr/session-error-recovery-offline.png new file mode 100644 index 0000000000..3c23d828d3 Binary files /dev/null and b/apps/app/pr/session-error-recovery-offline.png differ diff --git a/apps/app/pr/session-scroll-loading-polish.png b/apps/app/pr/session-scroll-loading-polish.png new file mode 100644 index 0000000000..1c23026bb6 Binary files /dev/null and b/apps/app/pr/session-scroll-loading-polish.png differ diff --git a/apps/app/public/apple-touch-icon.png b/apps/app/public/apple-touch-icon.png new file mode 100644 index 0000000000..7c05dcc9b9 Binary files /dev/null and b/apps/app/public/apple-touch-icon.png differ diff --git a/apps/app/public/favicon-16x16.png b/apps/app/public/favicon-16x16.png new file mode 100644 index 0000000000..9be1ead979 Binary files /dev/null and b/apps/app/public/favicon-16x16.png differ diff --git a/apps/app/public/favicon-32x32.png b/apps/app/public/favicon-32x32.png new file mode 100644 index 0000000000..8a82bb5e58 Binary files /dev/null and b/apps/app/public/favicon-32x32.png differ diff --git a/apps/app/public/openwork-logo-square.svg b/apps/app/public/openwork-logo-square.svg new file mode 100644 index 0000000000..05e4ca1aab --- /dev/null +++ b/apps/app/public/openwork-logo-square.svg @@ -0,0 +1,2 @@ + + diff --git a/apps/app/public/openwork-logo.svg b/apps/app/public/openwork-logo.svg new file mode 100644 index 0000000000..1052922e80 --- /dev/null +++ b/apps/app/public/openwork-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/app/public/openwork-mark.svg b/apps/app/public/openwork-mark.svg new file mode 100644 index 0000000000..e7ebb9153d --- /dev/null +++ b/apps/app/public/openwork-mark.svg @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/scripts/_util.mjs b/apps/app/scripts/_util.mjs similarity index 84% rename from scripts/_util.mjs rename to apps/app/scripts/_util.mjs index 291b0238ce..aa6bcc0926 100644 --- a/scripts/_util.mjs +++ b/apps/app/scripts/_util.mjs @@ -2,14 +2,24 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; import net from "node:net"; -import { realpathSync } from "node:fs"; +import { realpathSync, statSync } from "node:fs"; import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; +function resolveBasicAuthHeader() { + const password = process.env.OPENCODE_SERVER_PASSWORD?.trim() ?? ""; + if (!password) return undefined; + const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || "opencode"; + const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); + return `Basic ${encoded}`; +} + export function makeClient({ baseUrl, directory }) { + const authorization = resolveBasicAuthHeader(); return createOpencodeClient({ baseUrl, directory, + headers: authorization ? { Authorization: authorization } : undefined, responseStyle: "data", throwOnError: true, }); @@ -153,3 +163,12 @@ export function parseArgs(argv) { } return args; } + +export function canWriteWorkspace(directory) { + try { + const stat = statSync(directory); + return stat && stat.isDirectory(); + } catch { + return false; + } +} diff --git a/apps/app/scripts/browser-entry.mjs b/apps/app/scripts/browser-entry.mjs new file mode 100644 index 0000000000..f174b7981d --- /dev/null +++ b/apps/app/scripts/browser-entry.mjs @@ -0,0 +1,310 @@ +import assert from "node:assert"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; + +import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy } from "./_util.mjs"; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function writeSse(res, chunks) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + for (const chunk of chunks) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.write("data: [DONE]\n\n"); + res.end(); +} + +function createTextStream(text) { + return [ + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }, + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }, + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ]; +} + +function createInvalidToolStream() { + return [ + { + id: "chatcmpl-2", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }, + { + id: "chatcmpl-2", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "nonexistent_tool", arguments: "{}" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-2", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }, + ]; +} + +function hasChromeQuickstartPrompt(haystack) { + return ( + haystack.includes("chrome devtools mcp") || + haystack.includes("chrome-devtools_*") || + haystack.includes("control chrome") + ); +} + +const args = parseArgs(process.argv.slice(2)); +const keepTmp = args.get("keep-tmp") === "true"; + +const results = { + ok: true, + steps: [], +}; + +function step(name, fn) { + results.steps.push({ name, status: "running" }); + const idx = results.steps.length - 1; + return Promise.resolve() + .then(fn) + .then((data) => { + results.steps[idx] = { name, status: "ok", data }; + }) + .catch((e) => { + results.ok = false; + results.steps[idx] = { + name, + status: "error", + error: e instanceof Error ? e.message : String(e), + }; + throw e; + }); +} + +let tmpdir; +let mock; +let opencode; +let sawChromeQuickstartPrompt = false; +const mockSockets = new Set(); + +try { + tmpdir = await mkdtemp(path.join(os.tmpdir(), "openwork-browser-entry-")); + + const templateUrl = new URL("../src/app/data/commands/browser-setup.md", import.meta.url); + const template = await readFile(templateUrl, "utf8"); + + await step("workspace.setup", async () => { + await mkdir(path.join(tmpdir, ".opencode", "commands"), { recursive: true }); + await writeFile(path.join(tmpdir, ".opencode", "commands", "browser-setup.md"), template, "utf8"); + return { tmpdir }; + }); + + const mockPort = await findFreePort(); + const baseURL = `http://127.0.0.1:${mockPort}/v1`; + + await step("provider.mock.start", async () => { + mock = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`); + if (req.method === "GET" && url.pathname.endsWith("/models")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + object: "list", + data: [{ id: "qwen-plus", object: "model" }], + }), + ); + return; + } + + if (req.method === "POST" && url.pathname.endsWith("/chat/completions")) { + const raw = await new Promise((resolve) => { + let data = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => (data += chunk)); + req.on("end", () => resolve(data)); + }); + + let body; + try { + body = raw ? JSON.parse(raw) : {}; + } catch { + body = {}; + } + + const haystack = JSON.stringify(body).toLowerCase(); + const triesChromeMcp = hasChromeQuickstartPrompt(haystack); + + if (triesChromeMcp) { + sawChromeQuickstartPrompt = true; + writeSse( + res, + createTextStream( + "Trying Control Chrome first. If Chrome MCP is unavailable, open the MCP tab, connect Control Chrome, and retry.", + ), + ); + } else { + writeSse(res, createInvalidToolStream()); + } + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("not found"); + }); + mock.on("connection", (socket) => { + mockSockets.add(socket); + socket.on("close", () => { + mockSockets.delete(socket); + }); + }); + + await new Promise((resolve) => mock.listen(mockPort, "127.0.0.1", resolve)); + return { baseURL }; + }); + + await step("workspace.config", async () => { + await writeFile( + path.join(tmpdir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL, + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + return {}; + }); + + const port = await findFreePort(); + opencode = await spawnOpencodeServe({ directory: tmpdir, port }); + const client = makeClient({ baseUrl: opencode.baseUrl, directory: opencode.cwd }); + + await step("health", async () => { + const health = await waitForHealthy(client); + return health; + }); + + let sessionId; + + await step("session.create", async () => { + const session = await client.session.create({ title: "OpenWork browser-entry test" }); + sessionId = session.id; + assert.ok(sessionId); + return { id: session.id }; + }); + + await step("session.command (browser-setup)", async () => { + await client.session.command({ + sessionID: sessionId, + command: "browser-setup", + arguments: "", + model: "alibaba/qwen-plus", + }); + return {}; + }); + + await step("assert.chrome-mcp-quickstart", async () => { + assert.equal(sawChromeQuickstartPrompt, true, "Expected browser quickstart prompt to reference Chrome DevTools MCP"); + return { sawChromeQuickstartPrompt }; + }); + + await step("assert.no-tool-errors", async () => { + const start = Date.now(); + // Keep this internal polling window short: the test should wait up to 12 seconds for the assistant response before failing + while (Date.now() - start < 12_000) { + const msgs = await client.session.messages({ sessionID: sessionId, limit: 50 }); + const parts = msgs.flatMap((m) => m.parts ?? []); + const toolErrors = parts.filter((p) => p?.type === "tool" && String(p?.state?.status ?? "").toLowerCase() === "error"); + if (toolErrors.length > 0) { + const first = toolErrors[0]; + const tool = typeof first.tool === "string" ? first.tool : "tool"; + const title = typeof first.state?.title === "string" ? first.state.title : ""; + const err = typeof first.state?.error === "string" ? first.state.error : ""; + throw new Error(`Unexpected tool error (${tool}): ${title} ${err}`.trim()); + } + + const hasAssistantText = msgs.some( + (m) => m.info?.role === "assistant" && (m.parts ?? []).some((p) => p.type === "text" && String(p.text ?? "").trim()), + ); + if (hasAssistantText) { + return { messages: msgs.length }; + } + + await sleep(250); + } + throw new Error("Timed out waiting for assistant response"); + }); + + console.log(JSON.stringify(results, null, 2)); +} catch (e) { + const message = e instanceof Error ? e.message : String(e); + results.ok = false; + results.error = message; + results.stderr = opencode?.getStderr?.() ?? ""; + console.error(JSON.stringify(results, null, 2)); + process.exitCode = 1; +} finally { + try { + if (opencode) await opencode.close(); + } catch { + // ignore + } + try { + if (mock) { + for (const socket of mockSockets) { + socket.destroy(); + } + await new Promise((resolve) => mock.close(() => resolve())); + } + } catch { + // ignore + } + try { + if (tmpdir && !keepTmp) await rm(tmpdir, { recursive: true, force: true }); + } catch { + // ignore + } +} diff --git a/apps/app/scripts/bump-version.mjs b/apps/app/scripts/bump-version.mjs new file mode 100755 index 0000000000..0eb0dcad81 --- /dev/null +++ b/apps/app/scripts/bump-version.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const ROOT = process.cwd(); +const REPO_ROOT = path.resolve(ROOT, "../.."); +const args = process.argv.slice(2); + +const usage = () => { + console.log(`Usage: + node scripts/bump-version.mjs patch|minor|major + node scripts/bump-version.mjs --set x.y.z + node scripts/bump-version.mjs --dry-run [patch|minor|major|--set x.y.z]`); +}; + +const isDryRun = args.includes("--dry-run"); +// pnpm forwards args to scripts with an explicit "--" separator; strip it so +// "pnpm bump:set -- 0.1.21" works as expected. +const filtered = args.filter((arg) => arg !== "--dry-run" && arg !== "--"); + +if (!filtered.length) { + usage(); + process.exit(1); +} + +let mode = filtered[0]; +let explicit = null; + +if (mode === "--set") { + explicit = filtered[1] ?? null; + if (!explicit) { + console.error("--set requires a version like 0.1.21"); + process.exit(1); + } +} + +const semverPattern = /^\d+\.\d+\.\d+$/; + +const readJson = async (filePath) => + JSON.parse(await readFile(filePath, "utf8")); + +const bump = (value, bumpMode) => { + if (!semverPattern.test(value)) { + throw new Error(`Invalid version: ${value}`); + } + const [major, minor, patch] = value.split(".").map(Number); + if (bumpMode === "major") return `${major + 1}.0.0`; + if (bumpMode === "minor") return `${major}.${minor + 1}.0`; + if (bumpMode === "patch") return `${major}.${minor}.${patch + 1}`; + throw new Error(`Unknown bump mode: ${bumpMode}`); +}; + +const targetVersion = async () => { + if (explicit) return explicit; + const pkg = await readJson(path.join(ROOT, "package.json")); + return bump(pkg.version, mode); +}; + +const updatePackageJson = async (nextVersion) => { + const uiPath = path.join(ROOT, "package.json"); + const tauriPath = path.join(REPO_ROOT, "apps", "desktop", "package.json"); + const orchestratorPath = path.join( + REPO_ROOT, + "apps", + "orchestrator", + "package.json", + ); + const serverPath = path.join(REPO_ROOT, "apps", "server", "package.json"); + const opencodeRouterPath = path.join( + REPO_ROOT, + "apps", + "opencode-router", + "package.json", + ); + const uiData = await readJson(uiPath); + const tauriData = await readJson(tauriPath); + const orchestratorData = await readJson(orchestratorPath); + const serverData = await readJson(serverPath); + const opencodeRouterData = await readJson(opencodeRouterPath); + uiData.version = nextVersion; + tauriData.version = nextVersion; + // Desktop pins opencodeRouterVersion for sidecar bundling; keep it aligned. + tauriData.opencodeRouterVersion = nextVersion; + orchestratorData.version = nextVersion; + + // Ensure openwork-orchestrator uses the same openwork-server/opencode-router versions. + orchestratorData.dependencies = orchestratorData.dependencies ?? {}; + orchestratorData.dependencies["openwork-server"] = nextVersion; + orchestratorData.dependencies["opencode-router"] = nextVersion; + + serverData.version = nextVersion; + opencodeRouterData.version = nextVersion; + if (!isDryRun) { + await writeFile(uiPath, JSON.stringify(uiData, null, 2) + "\n"); + await writeFile(tauriPath, JSON.stringify(tauriData, null, 2) + "\n"); + await writeFile( + orchestratorPath, + JSON.stringify(orchestratorData, null, 2) + "\n", + ); + await writeFile(serverPath, JSON.stringify(serverData, null, 2) + "\n"); + await writeFile( + opencodeRouterPath, + JSON.stringify(opencodeRouterData, null, 2) + "\n", + ); + } +}; + +const updateCargoToml = async (nextVersion) => { + const filePath = path.join( + REPO_ROOT, + "apps", + "desktop", + "src-tauri", + "Cargo.toml", + ); + const raw = await readFile(filePath, "utf8"); + const updated = raw.replace( + /\bversion\s*=\s*"[^"]+"/m, + `version = "${nextVersion}"`, + ); + if (!isDryRun) { + await writeFile(filePath, updated); + // Regenerate Cargo.lock so it stays in sync with the version bump. + const { execFileSync } = await import("node:child_process"); + try { + execFileSync("cargo", ["generate-lockfile"], { + cwd: path.join(REPO_ROOT, "apps", "desktop", "src-tauri"), + stdio: "ignore", + }); + } catch { + // cargo may not be installed (e.g. CI without Rust); skip silently. + } + } +}; + +const updateTauriConfig = async (nextVersion) => { + const filePath = path.join( + REPO_ROOT, + "apps", + "desktop", + "src-tauri", + "tauri.conf.json", + ); + const data = JSON.parse(await readFile(filePath, "utf8")); + data.version = nextVersion; + if (!isDryRun) { + await writeFile(filePath, JSON.stringify(data, null, 2) + "\n"); + } +}; + +const main = async () => { + if (explicit && !semverPattern.test(explicit)) { + throw new Error(`Invalid explicit version: ${explicit}`); + } + if (explicit === null && !["patch", "minor", "major"].includes(mode)) { + throw new Error(`Unknown mode: ${mode}`); + } + + const nextVersion = await targetVersion(); + await updatePackageJson(nextVersion); + await updateCargoToml(nextVersion); + await updateTauriConfig(nextVersion); + + console.log( + JSON.stringify( + { + ok: true, + version: nextVersion, + dryRun: isDryRun, + files: [ + "apps/app/package.json", + "apps/desktop/package.json", + "apps/orchestrator/package.json", + "apps/server/package.json", + "apps/opencode-router/package.json", + "apps/desktop/src-tauri/Cargo.toml", + "apps/desktop/src-tauri/tauri.conf.json", + ], + }, + null, + 2, + ), + ); +}; + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ ok: false, error: message })); + process.exit(1); +}); diff --git a/apps/app/scripts/bundle-url-policy.ts b/apps/app/scripts/bundle-url-policy.ts new file mode 100644 index 0000000000..112eb8b216 --- /dev/null +++ b/apps/app/scripts/bundle-url-policy.ts @@ -0,0 +1,45 @@ +import { strict as assert } from "node:assert"; + +import { describeBundleUrlTrust, isConfiguredBundlePublisherUrl } from "../src/app/bundles/url-policy"; + +const trusted = describeBundleUrlTrust( + "https://share.openworklabs.com/b/01ARZ3NDEKTSV4RRFFQ69G5FAV", + "https://share.openworklabs.com", +); + +assert.deepEqual(trusted, { + trusted: true, + bundleId: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + actualOrigin: "https://share.openworklabs.com", + configuredOrigin: "https://share.openworklabs.com", +}); + +const untrusted = describeBundleUrlTrust( + "https://evil.example/b/01ARZ3NDEKTSV4RRFFQ69G5FAV", + "https://share.openworklabs.com", +); + +assert.deepEqual(untrusted, { + trusted: false, + bundleId: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + actualOrigin: "https://evil.example", + configuredOrigin: "https://share.openworklabs.com", +}); + +assert.equal( + isConfiguredBundlePublisherUrl( + "https://share.openworklabs.com/b/01ARZ3NDEKTSV4RRFFQ69G5FAV", + "https://share.openworklabs.com", + ), + true, +); + +assert.equal( + isConfiguredBundlePublisherUrl( + "https://share.openworklabs.com/not-a-bundle", + "https://share.openworklabs.com", + ), + false, +); + +console.log("bundle-url-policy ok"); diff --git a/apps/app/scripts/dev-log.ts b/apps/app/scripts/dev-log.ts new file mode 100644 index 0000000000..5791e789b2 --- /dev/null +++ b/apps/app/scripts/dev-log.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; + +const { clearDevLogs, formatDevLogLine, formatDevLogText, readDevLogs, recordDevLog } = await import( + "../src/app/lib/dev-log.ts" +); + +const results = { + ok: true, + steps: [] as Array>, +}; + +async function step(name: string, fn: () => void | Promise) { + results.steps.push({ name, status: "running" }); + const index = results.steps.length - 1; + + try { + await fn(); + results.steps[index] = { name, status: "ok" }; + } catch (error) { + results.ok = false; + results.steps[index] = { + name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + throw error; + } +} + +try { + clearDevLogs(); + + await step("disabled logging does not retain entries", () => { + recordDevLog(false, { level: "debug", source: "workspace", label: "connect:start" }); + assert.equal(readDevLogs(0).length, 0); + }); + + await step("enabled logging retains ordered entries", () => { + recordDevLog(true, { level: "debug", source: "workspace", label: "connect:start", payload: { root: "/tmp/demo" } }); + recordDevLog(true, { level: "warn", source: "session", label: "stream:error", payload: { code: 500 } }); + const logs = readDevLogs(0); + assert.equal(logs.length, 2); + assert.equal(logs[0]?.source, "workspace"); + assert.equal(logs[1]?.level, "warn"); + }); + + await step("formatted output stays readable and exportable", () => { + const line = formatDevLogLine(readDevLogs(1)[0]!); + assert.match(line, /WARN session:stream:error/); + const text = formatDevLogText(0); + assert.match(text, /DEBUG workspace:connect:start/); + assert.match(text, /WARN session:stream:error/); + }); + + console.log(JSON.stringify(results, null, 2)); +} catch (error) { + results.ok = false; + console.error( + JSON.stringify( + { + ...results, + error: error instanceof Error ? error.message : String(error), + }, + null, + 2, + ), + ); + process.exitCode = 1; +} diff --git a/scripts/e2e.mjs b/apps/app/scripts/e2e.mjs similarity index 100% rename from scripts/e2e.mjs rename to apps/app/scripts/e2e.mjs diff --git a/scripts/events.mjs b/apps/app/scripts/events.mjs similarity index 100% rename from scripts/events.mjs rename to apps/app/scripts/events.mjs diff --git a/apps/app/scripts/fs-engine.mjs b/apps/app/scripts/fs-engine.mjs new file mode 100644 index 0000000000..7fb044ffdf --- /dev/null +++ b/apps/app/scripts/fs-engine.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + findFreePort, + makeClient, + parseArgs, + spawnOpencodeServe, + waitForHealthy, +} from "./_util.mjs"; + +const args = parseArgs(process.argv.slice(2)); +const directory = args.get("dir") ?? process.cwd(); + +const port = await findFreePort(); +const server = await spawnOpencodeServe({ directory, port }); + +try { + const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); + await waitForHealthy(client); + + const root = ".openwork/test-engine"; + const nestedDir = path.join(root, "nested"); + const filePath = path.join(root, "hello.txt"); + + await mkdir(path.join(directory, nestedDir), { recursive: true }); + await writeFile(path.join(directory, filePath), "openwork engine test\n", "utf8"); + + const entries = await client.file.list({ directory, path: root }); + assert.ok(entries.some((entry) => entry.name === "nested" && entry.type === "directory")); + assert.ok(entries.some((entry) => entry.name === "hello.txt" && entry.type === "file")); + + const read = await client.file.read({ directory, path: filePath }); + assert.equal(read.type, "text"); + assert.ok(read.content.includes("openwork engine test")); + + await rm(path.join(directory, root), { recursive: true, force: true }); + + console.log( + JSON.stringify({ + ok: true, + baseUrl: server.baseUrl, + directory: server.cwd, + root, + }), + ); +} catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); + process.exitCode = 1; +} finally { + await server.close(); +} diff --git a/scripts/health.mjs b/apps/app/scripts/health.mjs similarity index 100% rename from scripts/health.mjs rename to apps/app/scripts/health.mjs diff --git a/apps/app/scripts/local-file-path.mjs b/apps/app/scripts/local-file-path.mjs new file mode 100644 index 0000000000..b5a0e248aa --- /dev/null +++ b/apps/app/scripts/local-file-path.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { normalizeLocalFilePath } from "../src/app/lib/local-file-path.impl.js"; + +const equals = (input, expected) => { + assert.equal(normalizeLocalFilePath(input), expected, `normalizeLocalFilePath(${input})`); +}; + +equals(" notes/todo.md ", "notes/todo.md"); +equals("file:///tmp/notes.md", "/tmp/notes.md"); +equals("file:/tmp/notes.md", "/tmp/notes.md"); +equals("file:///C:/Users/xj/note.md", "C:/Users/xj/note.md"); +equals("file://server/share/note.md", "//server/share/note.md"); +equals("file://localhost/tmp/notes.md", "/tmp/notes.md"); +equals("FILE:///tmp/notes.md", "/tmp/notes.md"); + +assert.doesNotThrow(() => normalizeLocalFilePath("file:///tmp/100%/note.md")); +equals("file:///tmp/100%/note.md", "/tmp/100%/note.md"); +assert.doesNotThrow(() => normalizeLocalFilePath("file://%zz")); +equals("file://%zz", "%zz"); + +console.log(JSON.stringify({ ok: true, checks: 11 })); diff --git a/apps/app/scripts/mention-send.mjs b/apps/app/scripts/mention-send.mjs new file mode 100644 index 0000000000..217cbdd0f2 --- /dev/null +++ b/apps/app/scripts/mention-send.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; + +import { findFreePort, makeClient, parseArgs, spawnOpencodeServe, waitForHealthy } from "./_util.mjs"; + +const args = parseArgs(process.argv.slice(2)); +const directory = args.get("dir") ?? process.cwd(); + +const port = await findFreePort(); +const server = await spawnOpencodeServe({ directory, port }); + +const results = { + ok: true, + baseUrl: server.baseUrl, + directory: server.cwd, + steps: [], +}; + +function formatError(e) { + if (e instanceof Error) return e.message; + try { + return JSON.stringify(e); + } catch { + return String(e); + } +} + +function step(name, fn) { + results.steps.push({ name, status: "running" }); + const idx = results.steps.length - 1; + return Promise.resolve() + .then(fn) + .then((data) => { + results.steps[idx] = { name, status: "ok", data }; + }) + .catch((e) => { + results.ok = false; + results.steps[idx] = { name, status: "error", error: formatError(e) }; + throw e; + }); +} + +const targetPath = args.get("path") ?? "src/app/pages/session.tsx"; +const absolutePath = (() => { + const trimmed = String(targetPath || "").trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("/")) return trimmed; + if (/^[a-zA-Z]:\\/.test(trimmed)) return trimmed; + return (server.cwd + "/" + trimmed).replace("//", "/"); +})(); +const fileUrl = absolutePath ? `file://${absolutePath}` : ""; + +try { + const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); + await step("health", async () => waitForHealthy(client)); + + let sessionId = ""; + await step("session.create", async () => { + const session = await client.session.create({ title: "OpenWork mention-send" }); + sessionId = session.id; + assert.ok(sessionId); + return { id: session.id }; + }); + + async function messagesSummary(label) { + const msgs = await client.session.messages({ sessionID: sessionId, limit: 50 }); + const roles = msgs.map((m) => m?.role ?? m?.info?.role ?? null); + const user = msgs.filter((m) => (m?.role ?? m?.info?.role ?? null) === "user"); + const last = user[user.length - 1]; + const lastParts = Array.isArray(last?.parts) ? last.parts : []; + return { + label, + total: msgs.length, + userCount: user.length, + roles: Array.from(new Set(roles)), + lastMessageRole: msgs.length ? (msgs[msgs.length - 1]?.role ?? msgs[msgs.length - 1]?.info?.role ?? null) : null, + sampleKeys: msgs.length ? Object.keys(msgs[0] ?? {}) : [], + sample: msgs.length + ? { + role: msgs[0]?.role ?? msgs[0]?.info?.role ?? null, + parts: Array.isArray(msgs[0]?.parts) ? msgs[0].parts.map((p) => p.type) : [], + } + : null, + lastUserParts: lastParts.map((p) => p.type), + lastUserText: (lastParts.find((p) => p.type === "text")?.text ?? ""), + }; + } + + await step("messages.initial", async () => messagesSummary("initial")); + + await step("prompt.invalidFilePart", async () => { + // Mirrors the bug in OpenWork: sending a file mention with only {path}. + try { + await client.session.prompt({ + sessionID: sessionId, + noReply: true, + parts: [ + { type: "text", text: " " }, + { type: "file", path: targetPath }, + ], + }); + throw new Error("expected prompt to fail validation, but it succeeded"); + } catch (e) { + return { expectedFailure: true, error: formatError(e) }; + } + }); + + await step("prompt.spaceTextWithValidFile", async () => { + assert.ok(fileUrl, "missing file url"); + await client.session.prompt({ + sessionID: sessionId, + noReply: true, + parts: [ + { type: "text", text: " " }, + { type: "file", mime: "text/plain", url: fileUrl, filename: "session.tsx" }, + ], + }); + return messagesSummary("after-space-text"); + }); + + await step("prompt.fixedPayload", async () => { + assert.ok(fileUrl, "missing file url"); + await client.session.prompt({ + sessionID: sessionId, + noReply: true, + parts: [ + { type: "text", text: `@${targetPath}` }, + { type: "file", mime: "text/plain", url: fileUrl, filename: "session.tsx" }, + ], + }); + const summary = await messagesSummary("after-fixed"); + assert.ok(summary.lastUserText.includes(targetPath), "expected last user text to include the mentioned path"); + return summary; + }); + + console.log(JSON.stringify(results, null, 2)); +} catch (e) { + results.ok = false; + results.error = formatError(e); + results.stderr = server.getStderr(); + console.error(JSON.stringify(results, null, 2)); + process.exitCode = 1; +} finally { + await server.close(); +} diff --git a/scripts/permissions.mjs b/apps/app/scripts/permissions.mjs similarity index 100% rename from scripts/permissions.mjs rename to apps/app/scripts/permissions.mjs diff --git a/apps/app/scripts/select-session-debug.mjs b/apps/app/scripts/select-session-debug.mjs new file mode 100644 index 0000000000..f8b4bc1bcd --- /dev/null +++ b/apps/app/scripts/select-session-debug.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; + +import { + findFreePort, + makeClient, + parseArgs, + spawnOpencodeServe, + waitForHealthy, +} from "./_util.mjs"; + +const args = parseArgs(process.argv.slice(2)); +const directory = args.get("dir") ?? process.cwd(); +const baseUrlOverride = args.get("baseUrl") ?? null; +const count = Number.parseInt(args.get("count") ?? "2", 10); +const sessionIdOverride = args.get("session") ?? null; + +const withTiming = async (label, fn) => { + const start = Date.now(); + try { + const result = await fn(); + const elapsed = Date.now() - start; + return { ok: true, label, elapsed, result }; + } catch (error) { + const elapsed = Date.now() - start; + return { ok: false, label, elapsed, error: error instanceof Error ? error.message : String(error) }; + } +}; + +let server = null; + +try { + if (!baseUrlOverride) { + const port = await findFreePort(); + server = await spawnOpencodeServe({ directory, port }); + } + + const baseUrl = baseUrlOverride ?? server.baseUrl; + const client = makeClient({ baseUrl, directory: server?.cwd ?? directory }); + + await waitForHealthy(client); + + console.log( + JSON.stringify({ + ok: true, + baseUrl, + directory: server?.cwd ?? directory, + count, + sessionIdOverride, + }), + ); + + for (let i = 0; i < count; i += 1) { + console.log(`\n=== Iteration ${i + 1}/${count} ===`); + + const health = await withTiming("global.health", async () => client.global.health()); + console.log(JSON.stringify(health)); + + let sessionId = sessionIdOverride; + if (!sessionId) { + const create = await withTiming("session.create", async () => + client.session.create({ title: `Debug session ${i + 1}`, directory }), + ); + console.log(JSON.stringify(create)); + assert.ok(create.ok, "session.create failed"); + sessionId = create.result.id; + } + + const list = await withTiming("session.list", async () => client.session.list({ limit: 50 })); + console.log(JSON.stringify(list)); + + const messages = await withTiming("session.messages", async () => + client.session.messages({ sessionID: sessionId, limit: 50 }), + ); + console.log(JSON.stringify(messages)); + + const todos = await withTiming("session.todo", async () => client.session.todo({ sessionID: sessionId })); + console.log(JSON.stringify(todos)); + + const permissions = await withTiming("permission.list", async () => client.permission.list()); + console.log(JSON.stringify(permissions)); + } +} catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error(JSON.stringify({ ok: false, error: message, stderr: server?.getStderr?.() ?? null })); + process.exitCode = 1; +} finally { + if (server) { + await server.close(); + } +} diff --git a/apps/app/scripts/session-error-recovery.ts b/apps/app/scripts/session-error-recovery.ts new file mode 100644 index 0000000000..85bc60907f --- /dev/null +++ b/apps/app/scripts/session-error-recovery.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; + +import { + latestSessionErrorTurnTime, + shouldResetRunState, +} from "../src/react-app/domains/session/sync/run-state"; + +assert.equal(latestSessionErrorTurnTime([]), null); +assert.equal( + latestSessionErrorTurnTime([ + { id: "session-error:test:0", text: "older", afterMessageID: null, time: 10 }, + { id: "session-error:test:1", text: "latest", afterMessageID: "message-1", time: 25 }, + ]), + 25, +); + +assert.equal( + shouldResetRunState({ + hasError: false, + sessionStatus: "idle", + runHasBegun: true, + runStartedAt: 100, + latestErrorTurnTime: null, + }), + true, +); + +assert.equal( + shouldResetRunState({ + hasError: false, + sessionStatus: "idle", + runHasBegun: false, + runStartedAt: 100, + latestErrorTurnTime: 120, + }), + true, +); + +assert.equal( + shouldResetRunState({ + hasError: false, + sessionStatus: "idle", + runHasBegun: false, + runStartedAt: 100, + latestErrorTurnTime: 80, + }), + false, +); + +assert.equal( + shouldResetRunState({ + hasError: false, + sessionStatus: "running", + runHasBegun: false, + runStartedAt: 100, + latestErrorTurnTime: 120, + }), + false, +); + +assert.equal( + shouldResetRunState({ + hasError: false, + sessionStatus: "idle", + runHasBegun: false, + runStartedAt: null, + latestErrorTurnTime: 120, + }), + false, +); + +assert.equal( + shouldResetRunState({ + hasError: true, + sessionStatus: "idle", + runHasBegun: false, + runStartedAt: 100, + latestErrorTurnTime: null, + }), + true, +); + +console.log( + JSON.stringify({ + ok: true, + cases: [ + "picks latest session error turn", + "resets completed runs when the session returns idle", + "resets immediate failed sends after a synthetic session error turn", + "resets immediate failures that only surface a session-level error banner", + "ignores stale prior errors", + "does not reset while the session is still active", + ], + }), +); diff --git a/apps/app/scripts/session-render-state.test.ts b/apps/app/scripts/session-render-state.test.ts new file mode 100644 index 0000000000..1ac2f612ba --- /dev/null +++ b/apps/app/scripts/session-render-state.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "bun:test"; +import type { UIMessage } from "ai"; + +import type { OpenworkSessionSnapshot } from "../src/app/lib/openwork-server"; +import { + deriveRenderedSessionMessages, + resolveRenderedSessionSnapshot, +} from "../src/react-app/domains/session/surface/session-render-state"; +import { mergeSnapshotIntoCachedMessages } from "../src/react-app/domains/session/sync/message-merge"; + +function snapshotWithMessages( + messages: Array<{ id: string; role: "user" | "assistant"; text: string }>, + sessionId = "ses_test", +): OpenworkSessionSnapshot { + return { + session: { + id: sessionId, + parentID: undefined, + title: "Test session", + time: { created: 1, updated: 2 }, + share: undefined, + version: "0", + }, + messages: messages.map((message, index) => ({ + info: { + id: message.id, + role: message.role, + sessionID: sessionId, + time: { created: index + 1 }, + }, + parts: [ + { + id: `part_${message.id}`, + type: "text", + text: message.text, + sessionID: sessionId, + messageID: message.id, + }, + ], + })), + todos: [], + status: { type: "idle" }, + } as unknown as OpenworkSessionSnapshot; +} + +function uiMessage(id: string, role: "user" | "assistant", text: string): UIMessage { + return { + id, + role, + parts: [{ type: "text", text, state: "done" }], + }; +} + +function snapshotWithText(text: string, sessionId = "ses_test"): OpenworkSessionSnapshot { + return snapshotWithMessages([{ id: "msg_user", role: "user", text }], sessionId); +} + +describe("mergeSnapshotIntoCachedMessages", () => { + it("keeps older cached messages when a busy snapshot only contains the active tail", () => { + const merged = mergeSnapshotIntoCachedMessages( + [uiMessage("msg_current_user", "user", "latest prompt")], + [ + uiMessage("msg_old_user", "user", "old prompt"), + uiMessage("msg_old_assistant", "assistant", "old answer"), + uiMessage("msg_current_user", "user", "latest"), + ], + ); + + expect(merged.map((message) => message.id)).toEqual([ + "msg_old_user", + "msg_old_assistant", + "msg_current_user", + ]); + expect(merged[2]?.parts[0]).toMatchObject({ text: "latest prompt" }); + }); +}); + +describe("deriveRenderedSessionMessages", () => { + it("falls back to snapshot messages when transcript cache is empty", () => { + const messages = deriveRenderedSessionMessages({ + transcriptState: [], + snapshot: snapshotWithText("still here"), + }); + + expect(messages).toHaveLength(1); + expect(messages[0]?.parts[0]).toMatchObject({ + type: "text", + text: "still here", + }); + }); + + it("keeps live transcript cache when it covers the snapshot", () => { + const cached: UIMessage[] = [ + { + id: "msg_user", + role: "assistant", + parts: [{ type: "text", text: "live text", state: "done" }], + }, + ]; + + expect(deriveRenderedSessionMessages({ + transcriptState: cached, + snapshot: snapshotWithText("snapshot text"), + })).toBe(cached); + }); + + it("keeps snapshot history visible when the live cache only has the active turn", () => { + const messages = deriveRenderedSessionMessages({ + transcriptState: [ + { + id: "msg_current_user", + role: "user", + parts: [{ type: "text", text: "latest prompt", state: "done" }], + }, + { + id: "msg_current_assistant", + role: "assistant", + parts: [{ type: "text", text: "streaming answer", state: "streaming" }], + }, + ], + snapshot: snapshotWithMessages([ + { id: "msg_old_user", role: "user", text: "old prompt" }, + { id: "msg_old_assistant", role: "assistant", text: "old answer" }, + ]), + includeLiveOnlyMessages: true, + }); + + expect(messages.map((message) => message.id)).toEqual([ + "msg_old_user", + "msg_old_assistant", + "msg_current_user", + "msg_current_assistant", + ]); + }); + + it("returns an empty list only when there is no cache or snapshot content", () => { + expect(deriveRenderedSessionMessages({ + transcriptState: [], + snapshot: null, + })).toEqual([]); + }); + + it("does not use a cached snapshot from a different session", () => { + const snapshot = resolveRenderedSessionSnapshot({ + sessionId: "ses_next", + currentSnapshot: null, + cachedRendered: { + sessionId: "ses_previous", + snapshot: snapshotWithText("previous session", "ses_previous"), + }, + }); + + expect(snapshot).toBeNull(); + expect(deriveRenderedSessionMessages({ + transcriptState: [], + snapshot, + })).toEqual([]); + }); + + it("keeps a cached snapshot for the current session while live cache is empty", () => { + const cached = snapshotWithText("current session", "ses_current"); + const snapshot = resolveRenderedSessionSnapshot({ + sessionId: "ses_current", + currentSnapshot: null, + cachedRendered: { + sessionId: "ses_current", + snapshot: cached, + }, + }); + + expect(snapshot).toBe(cached); + expect(deriveRenderedSessionMessages({ + transcriptState: [], + snapshot, + })[0]?.parts[0]).toMatchObject({ text: "current session" }); + }); +}); diff --git a/apps/app/scripts/session-scope.ts b/apps/app/scripts/session-scope.ts new file mode 100644 index 0000000000..97899943c8 --- /dev/null +++ b/apps/app/scripts/session-scope.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; + +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", + }, +}); + +const { + describeDirectoryScope, + resolveScopedClientDirectory, + scopedRootsMatch, + shouldApplyScopedSessionLoad, + shouldRedirectMissingSessionAfterScopedLoad, + toSessionTransportDirectory, +} = await import("../src/app/lib/session-scope.ts"); + +const starterRoot = "/Users/test/OpenWork/starter"; +const otherRoot = "/Users/test/OpenWork/second"; + +const results = { + ok: true, + steps: [] as Array>, +}; + +async function step(name: string, fn: () => void | Promise) { + results.steps.push({ name, status: "running" }); + const index = results.steps.length - 1; + + try { + await fn(); + results.steps[index] = { name, status: "ok" }; + } catch (error) { + results.ok = false; + results.steps[index] = { + name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + throw error; + } +} + +try { + await step("local connect prefers explicit target root", () => { + assert.equal( + resolveScopedClientDirectory({ workspaceType: "local", targetRoot: starterRoot }), + starterRoot, + ); + assert.equal( + resolveScopedClientDirectory({ + workspaceType: "local", + directory: otherRoot, + targetRoot: starterRoot, + }), + otherRoot, + ); + }); + + await step("remote connect still waits for remote discovery", () => { + assert.equal(resolveScopedClientDirectory({ workspaceType: "remote", targetRoot: starterRoot }), ""); + }); + + await step("scope matching is stable on desktop-style paths", () => { + assert.equal(scopedRootsMatch(`${starterRoot}/`, starterRoot.toUpperCase()), true); + assert.equal(scopedRootsMatch(starterRoot, otherRoot), false); + }); + + await step("stale session loads cannot overwrite another workspace sidebar", () => { + for (let index = 0; index < 50; index += 1) { + assert.equal( + shouldApplyScopedSessionLoad({ + loadedScopeRoot: otherRoot, + workspaceRoot: starterRoot, + }), + false, + ); + } + }); + + await step("same-scope session loads still update the active workspace", () => { + assert.equal( + shouldApplyScopedSessionLoad({ + loadedScopeRoot: `${starterRoot}/`, + workspaceRoot: starterRoot, + }), + true, + ); + }); + + await step("windows create and list use the same transport directory", () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + }); + + const winRoot = String.raw`C:\Users\Test\OpenWork\starter`; + const transport = toSessionTransportDirectory(winRoot); + + assert.equal(transport, winRoot); + assert.equal(resolveScopedClientDirectory({ workspaceType: "local", targetRoot: winRoot }), transport); + assert.equal(resolveScopedClientDirectory({ workspaceType: "local", directory: winRoot }), transport); + + const uncRoot = String.raw`\\?\UNC\server\share\starter`; + assert.equal(toSessionTransportDirectory(uncRoot), String.raw`\\server\share\starter`); + assert.equal(describeDirectoryScope(uncRoot).normalized, "//server/share/starter"); + + const verbatimDriveRoot = String.raw`\\?\C:\Users\Test\OpenWork\starter`; + assert.equal(toSessionTransportDirectory(verbatimDriveRoot), String.raw`C:\Users\Test\OpenWork\starter`); + assert.equal(describeDirectoryScope(verbatimDriveRoot).normalized, "c:/users/test/openwork/starter"); + }); + + await step("round-trip invariant: every query path equals the create path (unix)", () => { + // Restore macOS navigator for this step. + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", + }, + }); + + const unixPaths = [ + "/Users/test/OpenWork/starter", + "/Users/test/OpenWork/starter/", + "/home/user/projects/my-app", + "/tmp/sandbox", + "/private/tmp/sandbox", + ]; + + for (const raw of unixPaths) { + const createDir = toSessionTransportDirectory(raw); + const listDir = toSessionTransportDirectory(raw); + const resolvedDir = resolveScopedClientDirectory({ workspaceType: "local", targetRoot: raw }); + assert.equal(createDir, listDir, `create vs list mismatch for: ${raw}`); + assert.equal(createDir, resolvedDir, `create vs resolved mismatch for: ${raw}`); + } + }); + + await step("round-trip invariant: every query path equals the create path (windows)", () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + }); + + // Use escaped strings — Bun's parser chokes on String.raw inside array literals. + const windowsPaths = [ + "C:\\Users\\Test\\OpenWork\\starter", + "C:\\Users\\Test\\OpenWork\\starter\\", + "D:\\projects\\my-app", + "\\\\server\\share\\starter", + "\\\\?\\C:\\Users\\Test\\OpenWork\\starter", + "\\\\?\\UNC\\server\\share\\starter", + ]; + + for (const raw of windowsPaths) { + const createDir = toSessionTransportDirectory(raw); + const listDir = toSessionTransportDirectory(raw); + const resolvedDir = resolveScopedClientDirectory({ workspaceType: "local", targetRoot: raw }); + assert.equal(createDir, listDir, `create vs list mismatch for: ${raw}`); + assert.equal(createDir, resolvedDir, `create vs resolved mismatch for: ${raw}`); + } + }); + + await step("idempotency: double-converting a transport directory is stable", () => { + // Restore macOS for Unix paths. + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", + }, + }); + + const samples = [ + "/Users/test/OpenWork/starter", + "/home/user/projects/my-app", + ]; + for (const raw of samples) { + const once = toSessionTransportDirectory(raw); + const twice = toSessionTransportDirectory(once); + assert.equal(once, twice, `not idempotent for unix path: ${raw}`); + } + + // Switch to Windows. + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + }); + + const winSamples = [ + "C:\\Users\\Test\\OpenWork\\starter", + "\\\\server\\share\\starter", + ]; + for (const raw of winSamples) { + const once = toSessionTransportDirectory(raw); + const twice = toSessionTransportDirectory(once); + assert.equal(once, twice, `not idempotent for win path: ${raw}`); + } + }); + + await step("route guard only redirects when the loaded scope matches", () => { + assert.equal( + shouldRedirectMissingSessionAfterScopedLoad({ + loadedScopeRoot: otherRoot, + workspaceRoot: starterRoot, + hasMatchingSession: false, + }), + false, + ); + assert.equal( + shouldRedirectMissingSessionAfterScopedLoad({ + loadedScopeRoot: starterRoot, + workspaceRoot: starterRoot, + hasMatchingSession: false, + }), + true, + ); + assert.equal( + shouldRedirectMissingSessionAfterScopedLoad({ + loadedScopeRoot: starterRoot, + workspaceRoot: starterRoot, + hasMatchingSession: true, + }), + false, + ); + }); + + console.log(JSON.stringify(results, null, 2)); +} catch (error) { + results.ok = false; + console.error( + JSON.stringify( + { + ...results, + error: error instanceof Error ? error.message : String(error), + }, + null, + 2, + ), + ); + process.exitCode = 1; +} diff --git a/apps/app/scripts/session-switch.mjs b/apps/app/scripts/session-switch.mjs new file mode 100644 index 0000000000..694f65cca6 --- /dev/null +++ b/apps/app/scripts/session-switch.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; + +import { + findFreePort, + makeClient, + parseArgs, + spawnOpencodeServe, + waitForHealthy, +} from "./_util.mjs"; + +const args = parseArgs(process.argv.slice(2)); +const directory = args.get("dir") ?? process.cwd(); + +const port = await findFreePort(); +const server = await spawnOpencodeServe({ directory, port }); + +const results = { + ok: true, + baseUrl: server.baseUrl, + directory: server.cwd, + steps: [], +}; + +function step(name, fn) { + results.steps.push({ name, status: "running" }); + const idx = results.steps.length - 1; + + return Promise.resolve() + .then(fn) + .then((data) => { + results.steps[idx] = { name, status: "ok", data }; + }) + .catch((e) => { + results.ok = false; + results.steps[idx] = { + name, + status: "error", + error: e instanceof Error ? e.message : String(e), + }; + throw e; + }); +} + +function getMessageSessionId(message) { + if (message && typeof message.sessionID === "string") return message.sessionID; + if (message && message.info && typeof message.info.sessionID === "string") return message.info.sessionID; + return null; +} + +function extractLastText(messages) { + const list = Array.isArray(messages) ? messages.slice() : []; + for (let i = list.length - 1; i >= 0; i -= 1) { + const msg = list[i]; + const parts = Array.isArray(msg?.parts) ? msg.parts : []; + for (let p = parts.length - 1; p >= 0; p -= 1) { + const part = parts[p]; + if (part && part.type === "text" && typeof part.text === "string") { + return part.text; + } + } + } + return null; +} + +try { + const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); + await waitForHealthy(client); + + let sessionA; + let sessionB; + + await step("session.create A", async () => { + sessionA = await client.session.create({ title: "OpenWork session A" }); + assert.ok(sessionA?.id); + return { id: sessionA.id }; + }); + + await step("session.create B", async () => { + sessionB = await client.session.create({ title: "OpenWork session B" }); + assert.ok(sessionB?.id); + return { id: sessionB.id }; + }); + + await step("session.prompt A", async () => { + await client.session.prompt({ + sessionID: sessionA.id, + noReply: true, + parts: [{ type: "text", text: "Hello from session A" }], + }); + return { sessionID: sessionA.id }; + }); + + await step("session.prompt B", async () => { + await client.session.prompt({ + sessionID: sessionB.id, + noReply: true, + parts: [{ type: "text", text: "Hello from session B" }], + }); + return { sessionID: sessionB.id }; + }); + + await step("session.messages A", async () => { + const messages = await client.session.messages({ sessionID: sessionA.id, limit: 50 }); + assert.ok(Array.isArray(messages)); + for (const msg of messages) { + const msgSessionId = getMessageSessionId(msg); + assert.equal(msgSessionId, sessionA.id); + } + const text = extractLastText(messages); + assert.ok(text && text.includes("session A")); + return { count: messages.length }; + }); + + await step("session.messages B", async () => { + const messages = await client.session.messages({ sessionID: sessionB.id, limit: 50 }); + assert.ok(Array.isArray(messages)); + for (const msg of messages) { + const msgSessionId = getMessageSessionId(msg); + assert.equal(msgSessionId, sessionB.id); + } + const text = extractLastText(messages); + assert.ok(text && text.includes("session B")); + return { count: messages.length }; + }); + + await step("session.messages switch", async () => { + const [messagesA, messagesB] = await Promise.all([ + client.session.messages({ sessionID: sessionA.id, limit: 50 }), + client.session.messages({ sessionID: sessionB.id, limit: 50 }), + ]); + + const textA = extractLastText(messagesA); + const textB = extractLastText(messagesB); + + assert.ok(textA && textA.includes("session A")); + assert.ok(textB && textB.includes("session B")); + + return { aCount: messagesA.length, bCount: messagesB.length }; + }); + + console.log(JSON.stringify(results, null, 2)); +} catch (e) { + const message = e instanceof Error ? e.message : String(e); + results.ok = false; + results.error = message; + results.stderr = server.getStderr(); + console.error(JSON.stringify(results, null, 2)); + process.exitCode = 1; +} finally { + await server.close(); +} diff --git a/apps/app/scripts/sessions-parallel.mjs b/apps/app/scripts/sessions-parallel.mjs new file mode 100644 index 0000000000..45dc072a2e --- /dev/null +++ b/apps/app/scripts/sessions-parallel.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; + +import { + findFreePort, + makeClient, + parseArgs, + spawnOpencodeServe, + waitForHealthy, +} from "./_util.mjs"; + +const args = parseArgs(process.argv.slice(2)); +const directory = args.get("dir") ?? process.cwd(); +const count = parseInt(args.get("count") ?? "5", 10); + +const port = await findFreePort(); +const server = await spawnOpencodeServe({ + directory, + port, +}); + +try { + const client = makeClient({ baseUrl: server.baseUrl, directory: server.cwd }); + await waitForHealthy(client); + + console.log(`Creating ${count} sessions in parallel...`); + + const results = await Promise.all( + Array.from({ length: count }, async (_, i) => { + const start = Date.now(); + const label = `session-${i + 1}`; + console.log(`[${label}] starting...`); + + try { + const session = await client.session.create({ title: `Parallel session ${i + 1}` }); + const elapsed = Date.now() - start; + console.log(`[${label}] created in ${elapsed}ms - ${session.id}`); + return { label, ok: true, elapsed, id: session.id }; + } catch (err) { + const elapsed = Date.now() - start; + console.log(`[${label}] FAILED in ${elapsed}ms - ${err.message}`); + return { label, ok: false, elapsed, error: err.message }; + } + }) + ); + + const successful = results.filter((r) => r.ok); + const failed = results.filter((r) => !r.ok); + const times = successful.map((r) => r.elapsed); + const avg = times.length ? (times.reduce((a, b) => a + b, 0) / times.length).toFixed(0) : "N/A"; + const max = times.length ? Math.max(...times) : "N/A"; + const min = times.length ? Math.min(...times) : "N/A"; + + console.log("\n--- Summary ---"); + console.log(`Total: ${count}, Success: ${successful.length}, Failed: ${failed.length}`); + console.log(`Times (ms): min=${min}, avg=${avg}, max=${max}`); + + // Now test sequential creates after the parallel burst + console.log("\nNow creating 3 more sessions sequentially..."); + for (let i = 0; i < 3; i++) { + const start = Date.now(); + const session = await client.session.create({ title: `Sequential session ${i + 1}` }); + const elapsed = Date.now() - start; + console.log(`[sequential-${i + 1}] created in ${elapsed}ms - ${session.id}`); + } + + console.log( + JSON.stringify({ + ok: true, + baseUrl: server.baseUrl, + parallelResults: results, + stats: { count, successful: successful.length, failed: failed.length, min, avg, max }, + }), + ); +} catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); + process.exitCode = 1; +} finally { + await server.close(); +} diff --git a/scripts/sessions.mjs b/apps/app/scripts/sessions.mjs similarity index 100% rename from scripts/sessions.mjs rename to apps/app/scripts/sessions.mjs diff --git a/scripts/todos.mjs b/apps/app/scripts/todos.mjs similarity index 100% rename from scripts/todos.mjs rename to apps/app/scripts/todos.mjs diff --git a/apps/app/src/app/bundles/apply.ts b/apps/app/src/app/bundles/apply.ts new file mode 100644 index 0000000000..f3530b1c40 --- /dev/null +++ b/apps/app/src/app/bundles/apply.ts @@ -0,0 +1,87 @@ +import type { WorkspaceDisplay } from "../types"; +import { parseOpenworkWorkspaceIdFromUrl } from "../lib/openwork-server"; +import type { WorkspaceInfo } from "../lib/desktop"; +import type { BundleImportTarget, BundleV1 } from "./types"; + +export function buildImportPayloadFromBundle(bundle: BundleV1): { + payload: Record; + importedSkillsCount: number; +} { + if (bundle.type === "skill") { + return { + payload: { + mode: { skills: "merge" }, + skills: [ + { + name: bundle.name, + description: bundle.description, + trigger: bundle.trigger, + content: bundle.content, + }, + ], + }, + importedSkillsCount: 1, + }; + } + + if (bundle.type === "skills-set") { + return { + payload: { + mode: { skills: "merge" }, + skills: bundle.skills.map((skill) => ({ + name: skill.name, + description: skill.description, + trigger: skill.trigger, + content: skill.content, + })), + }, + importedSkillsCount: bundle.skills.length, + }; + } + + throw new Error(`Unsupported bundle type: ${(bundle as { type?: string }).type || "unknown"}`); +} + +export function isBundleImportWorkspace(workspace: WorkspaceDisplay | WorkspaceInfo | null): boolean { + if (!workspace?.id?.trim()) return false; + if (workspace.workspaceType === "local") { + return Boolean(workspace.path?.trim()); + } + return Boolean(workspace.remoteType === "openwork" || workspace.openworkHostUrl?.trim() || workspace.openworkWorkspaceId?.trim()); +} + +export function resolveBundleImportTargetForWorkspace( + workspace: WorkspaceDisplay | WorkspaceInfo | null, +): BundleImportTarget | undefined { + if (!workspace) return undefined; + if (workspace.workspaceType === "local") { + const localRoot = workspace.path?.trim() ?? ""; + return localRoot ? { localRoot } : undefined; + } + + const workspaceId = + workspace.openworkWorkspaceId?.trim() || + parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || + parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || + null; + const directoryHint = workspace.directory?.trim() || workspace.path?.trim() || null; + if (workspaceId || directoryHint) { + return { + workspaceId, + directoryHint, + }; + } + return undefined; +} + +export function describeWorkspaceForBundleToasts(workspace: WorkspaceDisplay | WorkspaceInfo | null): string { + return ( + workspace?.displayName?.trim() || + workspace?.openworkWorkspaceName?.trim() || + workspace?.name?.trim() || + workspace?.directory?.trim() || + workspace?.path?.trim() || + workspace?.baseUrl?.trim() || + "the selected worker" + ); +} diff --git a/apps/app/src/app/bundles/index.ts b/apps/app/src/app/bundles/index.ts new file mode 100644 index 0000000000..002cd2ab99 --- /dev/null +++ b/apps/app/src/app/bundles/index.ts @@ -0,0 +1,5 @@ +export * from "./apply"; +export * from "./publish"; +export * from "./schema"; +export * from "./sources"; +export * from "./types"; diff --git a/apps/app/src/app/bundles/publish.ts b/apps/app/src/app/bundles/publish.ts new file mode 100644 index 0000000000..a6e2ab6560 --- /dev/null +++ b/apps/app/src/app/bundles/publish.ts @@ -0,0 +1,42 @@ +import type { + OpenworkServerClient, + OpenworkWorkspaceExport, +} from "../lib/openwork-server"; +import type { SkillsSetBundleV1 } from "./types"; + +export function buildSkillsSetBundle( + workspaceName: string, + exported: OpenworkWorkspaceExport, +): SkillsSetBundleV1 { + const skills = Array.isArray(exported.skills) ? exported.skills : []; + if (!skills.length) { + throw new Error("No skills found in this workspace."); + } + + return { + schemaVersion: 1, + type: "skills-set", + name: `${workspaceName} skills`, + description: "Complete skills set from an OpenWork workspace.", + skills: skills.map((skill) => ({ + name: skill.name, + description: skill.description, + trigger: skill.trigger, + content: skill.content, + })), + }; +} + +export async function publishSkillsSetBundleFromWorkspace(input: { + client: OpenworkServerClient; + workspaceId: string; + workspaceName: string; +}) { + const exported = await input.client.exportWorkspace(input.workspaceId, { + sensitiveMode: "exclude", + }); + const payload = buildSkillsSetBundle(input.workspaceName, exported); + return input.client.publishBundle(payload, "skills-set", { + name: payload.name, + }); +} diff --git a/apps/app/src/app/bundles/schema.ts b/apps/app/src/app/bundles/schema.ts new file mode 100644 index 0000000000..e2654fae5a --- /dev/null +++ b/apps/app/src/app/bundles/schema.ts @@ -0,0 +1,95 @@ +import type { + BundleImportSummary, + BundleV1, + SkillBundleItem, +} from "./types"; + +function readRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function readSkillItem(value: unknown): SkillBundleItem | null { + const record = readRecord(value); + if (!record) return null; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const content = typeof record.content === "string" ? record.content : ""; + if (!name || !content) return null; + return { + name, + description: typeof record.description === "string" ? record.description : undefined, + trigger: typeof record.trigger === "string" ? record.trigger : undefined, + content, + }; +} + +export function describeBundleImport(bundle: BundleV1): BundleImportSummary { + if (bundle.type === "skill") { + return { + title: "Import 1 skill", + description: bundle.description?.trim() || `Add \`${bundle.name}\` to an existing worker or create a new one for it.`, + items: [bundle.name], + }; + } + + if (bundle.type === "skills-set") { + const count = bundle.skills.length; + return { + title: `Import ${count} skill${count === 1 ? "" : "s"}`, + description: + bundle.description?.trim() || + `${bundle.name || "Shared skills"} is ready to import into an existing worker or a new worker.`, + items: bundle.skills.map((skill) => skill.name), + }; + } + + throw new Error(`Unsupported bundle type: ${(bundle as { type?: string }).type || "unknown"}`); +} + +export function parseBundlePayload(value: unknown): BundleV1 { + const record = readRecord(value); + if (!record) { + throw new Error("Invalid bundle payload."); + } + + const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : null; + const type = typeof record.type === "string" ? record.type.trim() : ""; + const name = typeof record.name === "string" ? record.name.trim() : ""; + + if (schemaVersion !== 1) { + throw new Error("Unsupported bundle schema version."); + } + + if (type === "skill") { + const content = typeof record.content === "string" ? record.content : ""; + if (!name || !content) { + throw new Error("Invalid skill bundle payload."); + } + return { + schemaVersion: 1, + type: "skill", + name, + description: typeof record.description === "string" ? record.description : undefined, + trigger: typeof record.trigger === "string" ? record.trigger : undefined, + content, + }; + } + + if (type === "skills-set") { + const skills = Array.isArray(record.skills) + ? record.skills.map(readSkillItem).filter((item): item is SkillBundleItem => Boolean(item)) + : []; + if (!skills.length) { + throw new Error("Skills set bundle has no importable skills."); + } + return { + schemaVersion: 1, + type: "skills-set", + name: name || "Shared skills", + description: typeof record.description === "string" ? record.description : undefined, + skills, + }; + } + + throw new Error(`Unsupported bundle type: ${type || "unknown"}`); +} diff --git a/apps/app/src/app/bundles/skill-org-publish.ts b/apps/app/src/app/bundles/skill-org-publish.ts new file mode 100644 index 0000000000..910fe6bc86 --- /dev/null +++ b/apps/app/src/app/bundles/skill-org-publish.ts @@ -0,0 +1,51 @@ +import { createDenClient, readDenSettings, writeDenSettings } from "../lib/den"; + +export async function saveInstalledSkillToOpenWorkOrg(input: { + skillText: string; + shared?: "org" | "public" | null; + skillHubId?: string | null; +}): Promise<{ skillId: string; orgId: string; orgName: string }> { + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + if (!token) { + throw new Error("Sign in to OpenWork Cloud in Settings to share with your team."); + } + + const cloudClient = createDenClient({ baseUrl: settings.baseUrl, apiBaseUrl: settings.apiBaseUrl, token }); + let orgId = settings.activeOrgId?.trim() ?? ""; + let orgSlug = settings.activeOrgSlug?.trim() ?? ""; + let orgName = settings.activeOrgName?.trim() ?? ""; + + if (!orgSlug || !orgName || !orgId) { + const response = await cloudClient.listOrgs(); + const match = orgId + ? response.orgs.find((org) => org.id === orgId) + : response.orgs.find((org) => org.slug === orgSlug) ?? response.orgs[0]; + if (!match) { + throw new Error("Choose an organization in Settings -> Cloud before sharing with your team."); + } + orgId = match.id; + orgSlug = match.slug; + orgName = match.name; + writeDenSettings({ + ...settings, + baseUrl: settings.baseUrl, + authToken: token, + activeOrgId: orgId, + activeOrgSlug: orgSlug, + activeOrgName: orgName, + }); + } + + const created = await cloudClient.createOrgSkill(orgId, { + skillText: input.skillText, + shared: input.shared === undefined ? null : input.shared, + }); + + const hubId = input.skillHubId?.trim() ?? ""; + if (hubId) { + await cloudClient.addOrgSkillToHub(orgId, hubId, created.id); + } + + return { skillId: created.id, orgId, orgName }; +} diff --git a/apps/app/src/app/bundles/sources.ts b/apps/app/src/app/bundles/sources.ts new file mode 100644 index 0000000000..62ceba3827 --- /dev/null +++ b/apps/app/src/app/bundles/sources.ts @@ -0,0 +1,156 @@ +import { desktopFetch } from "../lib/desktop"; +import type { OpenworkServerClient } from "../lib/openwork-server"; +import { isDesktopRuntime, safeStringify } from "../utils"; +import { parseBundlePayload } from "./schema"; +import type { BundleImportIntent, BundleRequest, BundleV1 } from "./types"; +import { extractBundleId, isConfiguredBundlePublisherUrl } from "./url-policy"; + +function isSupportedDeepLinkProtocol(protocol: string): boolean { + const normalized = protocol.toLowerCase(); + return normalized === "openwork:" || normalized === "openwork-dev:" || normalized === "https:" || normalized === "http:"; +} + +export function normalizeBundleImportIntent(value: string | null | undefined): BundleImportIntent { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "new_worker" || normalized === "new-worker" || normalized === "newworker") { + return "new_worker"; + } + return "import_current"; +} + +export function parseBundleDeepLink(rawUrl: string): BundleRequest | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const protocol = url.protocol.toLowerCase(); + if (!isSupportedDeepLinkProtocol(protocol)) { + return null; + } + + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + const looksLikeImportRoute = routeHost === "import-bundle" || routePath === "import-bundle" || routeTail === "import-bundle"; + + const rawBundleUrl = url.searchParams.get("ow_bundle") ?? url.searchParams.get("bundleUrl") ?? ""; + if (!looksLikeImportRoute && !rawBundleUrl.trim()) { + return null; + } + + try { + if ((protocol === "https:" || protocol === "http:") && !rawBundleUrl.trim()) { + if (isConfiguredBundlePublisherUrl(url.toString())) { + return { + bundleUrl: url.toString(), + intent: normalizeBundleImportIntent(url.searchParams.get("ow_intent") ?? url.searchParams.get("intent")), + source: url.searchParams.get("ow_source")?.trim() ?? url.searchParams.get("source")?.trim() ?? undefined, + label: url.searchParams.get("ow_label")?.trim() ?? url.searchParams.get("label")?.trim() ?? undefined, + }; + } + } + + const parsedBundleUrl = new URL(rawBundleUrl.trim()); + if (parsedBundleUrl.protocol !== "https:" && parsedBundleUrl.protocol !== "http:") { + return null; + } + return { + bundleUrl: parsedBundleUrl.toString(), + intent: normalizeBundleImportIntent(url.searchParams.get("ow_intent") ?? url.searchParams.get("intent")), + source: url.searchParams.get("ow_source")?.trim() ?? url.searchParams.get("source")?.trim() ?? undefined, + label: url.searchParams.get("ow_label")?.trim() ?? url.searchParams.get("label")?.trim() ?? undefined, + }; + } catch { + return null; + } +} + +export function stripBundleQuery(rawUrl: string): string | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + let changed = false; + for (const key of ["ow_bundle", "bundleUrl", "ow_intent", "intent", "ow_source", "source", "ow_org", "ow_label"]) { + if (url.searchParams.has(key)) { + url.searchParams.delete(key); + changed = true; + } + } + + if (!changed) { + return null; + } + + const search = url.searchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; +} + +export async function fetchBundle( + bundleUrl: string, + serverClient?: OpenworkServerClient | null, + options?: { forceClientFetch?: boolean }, +): Promise { + let targetUrl: URL; + try { + targetUrl = new URL(bundleUrl); + } catch { + throw new Error("Invalid bundle URL."); + } + + if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") { + throw new Error("Bundle URL must use http(s)."); + } + + const bundleId = extractBundleId(targetUrl); + if (bundleId) { + targetUrl.pathname = `/b/${bundleId}/data`; + targetUrl.searchParams.delete("format"); + } + + if (!targetUrl.searchParams.has("format")) { + targetUrl.searchParams.set("format", "json"); + } + + if (serverClient && !options?.forceClientFetch) { + return parseBundlePayload(await serverClient.fetchBundle(targetUrl.toString())); + } + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15_000); + + try { + let response: Response; + try { + response = isDesktopRuntime() + ? await desktopFetch(targetUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }) + : await fetch(targetUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + throw new Error(`Failed to load bundle from ${targetUrl.toString()}: ${message}`); + } + if (!response.ok) { + const details = (await response.text()).trim(); + const suffix = details ? `: ${details}` : ""; + throw new Error(`Failed to fetch bundle from ${targetUrl.toString()} (${response.status})${suffix}`); + } + return parseBundlePayload(await response.json()); + } finally { + window.clearTimeout(timeout); + } +} diff --git a/apps/app/src/app/bundles/types.ts b/apps/app/src/app/bundles/types.ts new file mode 100644 index 0000000000..ba25cff3f3 --- /dev/null +++ b/apps/app/src/app/bundles/types.ts @@ -0,0 +1,65 @@ +export type SkillBundleItem = { + name: string; + description?: string; + content: string; + trigger?: string; +}; + +export type SkillBundleV1 = { + schemaVersion: 1; + type: "skill"; + name: string; + description?: string; + trigger?: string; + content: string; +}; + +export type SkillsSetBundleV1 = { + schemaVersion: 1; + type: "skills-set"; + name: string; + description?: string; + skills: SkillBundleItem[]; +}; + +export type BundleV1 = SkillBundleV1 | SkillsSetBundleV1; + +export type BundleImportIntent = "new_worker" | "import_current"; + +export type BundleRequest = { + bundleUrl?: string | null; + intent: BundleImportIntent; + source?: string; + label?: string; +}; + +export type BundleImportTarget = { + workspaceId?: string | null; + localRoot?: string | null; + directoryHint?: string | null; +}; + +export type SkillDestinationRequest = { + request: BundleRequest; + bundle: SkillBundleV1; +}; + +export type BundleImportChoice = { + request: BundleRequest; + bundle: BundleV1; +}; + +export type BundleWorkerOption = { + id: string; + label: string; + detail: string; + badge: string; + current: boolean; + disabledReason?: string | null; +}; + +export type BundleImportSummary = { + title: string; + description: string; + items: string[]; +}; diff --git a/apps/app/src/app/bundles/url-policy.ts b/apps/app/src/app/bundles/url-policy.ts new file mode 100644 index 0000000000..c40bd2bcbc --- /dev/null +++ b/apps/app/src/app/bundles/url-policy.ts @@ -0,0 +1,49 @@ +import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL } from "../lib/publisher"; + +export type BundleUrlTrust = { + trusted: boolean; + bundleId: string | null; + actualOrigin: string | null; + configuredOrigin: string | null; +}; + +export function extractBundleId(url: URL): string | null { + const segments = url.pathname.split("/").filter(Boolean); + if (segments[0] === "b" && segments[1] && (segments.length === 2 || (segments.length === 3 && segments[2] === "data"))) { + return segments[1]; + } + return null; +} + +export function resolveConfiguredBundlePublisherOrigin(baseUrl = DEFAULT_OPENWORK_PUBLISHER_BASE_URL): string | null { + try { + return new URL(baseUrl).origin; + } catch { + return null; + } +} + +export function describeBundleUrlTrust(bundleUrl: string, baseUrl = DEFAULT_OPENWORK_PUBLISHER_BASE_URL): BundleUrlTrust { + const configuredOrigin = resolveConfiguredBundlePublisherOrigin(baseUrl); + try { + const url = new URL(bundleUrl); + const bundleId = extractBundleId(url); + return { + trusted: Boolean(configuredOrigin && url.origin === configuredOrigin && bundleId), + bundleId, + actualOrigin: url.origin, + configuredOrigin, + }; + } catch { + return { + trusted: false, + bundleId: null, + actualOrigin: null, + configuredOrigin, + }; + } +} + +export function isConfiguredBundlePublisherUrl(bundleUrl: string, baseUrl = DEFAULT_OPENWORK_PUBLISHER_BASE_URL): boolean { + return describeBundleUrlTrust(bundleUrl, baseUrl).trusted; +} diff --git a/apps/app/src/app/cloud/desktop-app-restrictions.ts b/apps/app/src/app/cloud/desktop-app-restrictions.ts new file mode 100644 index 0000000000..92f9bf5777 --- /dev/null +++ b/apps/app/src/app/cloud/desktop-app-restrictions.ts @@ -0,0 +1,79 @@ +import type { DesktopAppRestrictions } from "@openwork/types/den/desktop-app-restrictions"; +import type { DenDesktopConfig } from "../lib/den"; +import type { ModelRef } from "../types"; + +export type DesktopAppRestrictionKey = keyof DesktopAppRestrictions; + +export type DesktopAppRestrictionChecker = (input: { + restriction: DesktopAppRestrictionKey; +}) => boolean; + +export const DESKTOP_RESTRICTION_OPENCODE_PROVIDER_ID = "opencode"; + +export function checkDesktopAppRestriction(input: { + config: DenDesktopConfig | null | undefined; + restriction: DesktopAppRestrictionKey; +}) { + return input.config?.[input.restriction] === true; +} + +export function isDesktopProviderBlocked(input: { + providerId: string; + checkRestriction: DesktopAppRestrictionChecker; +}) { + const providerId = input.providerId.trim().toLowerCase(); + if (!providerId) return false; + + if (providerId === DESKTOP_RESTRICTION_OPENCODE_PROVIDER_ID) { + return input.checkRestriction({ restriction: "blockZenModel" }); + } + + return false; +} + +export function isDesktopModelBlocked(input: { + model: ModelRef; + checkRestriction: DesktopAppRestrictionChecker; +}) { + return isDesktopProviderBlocked({ + providerId: input.model.providerID, + checkRestriction: input.checkRestriction, + }); +} + +type DesktopAppRestrictionSyncContext = { + checkRestriction: DesktopAppRestrictionChecker; + reconcileRestrictedModels?: () => void; + ensureProjectProviderDisabledState?: (providerId: string, disabled: boolean) => Promise; + onError?: (error: Error, details: { + restriction: DesktopAppRestrictionKey; + action: string; + providerId?: string; + }) => void; +}; + +export async function runDesktopAppRestrictionSyncEffects( + input: DesktopAppRestrictionSyncContext, +) { + const shouldDisableOpencodeProvider = input.checkRestriction({ restriction: "blockZenModel" }); + + input.reconcileRestrictedModels?.(); + + if (input.ensureProjectProviderDisabledState) { + try { + await input.ensureProjectProviderDisabledState( + DESKTOP_RESTRICTION_OPENCODE_PROVIDER_ID, + shouldDisableOpencodeProvider, + ); + } catch (error) { + input.onError?.( + error instanceof Error ? error : new Error(String(error ?? "Desktop restriction effect failed.")), + { + restriction: "blockZenModel", + action: "ensureProjectProviderDisabledState", + providerId: DESKTOP_RESTRICTION_OPENCODE_PROVIDER_ID, + }, + ); + } + } +} diff --git a/apps/app/src/app/cloud/import-state.ts b/apps/app/src/app/cloud/import-state.ts new file mode 100644 index 0000000000..aeccfa3ed4 --- /dev/null +++ b/apps/app/src/app/cloud/import-state.ts @@ -0,0 +1,208 @@ +export type CloudImportedSkillHub = { + hubId: string; + name: string; + skillNames: string[]; + skillIds: string[]; + importedAt: number | null; +}; + +export type CloudImportedSkill = { + cloudSkillId: string; + installedName: string; + title: string; + description: string | null; + shared: "org" | "public" | null; + updatedAt: string | null; + importedAt: number | null; +}; + +export type CloudImportedProvider = { + cloudProviderId: string; + providerId: string; + sourceProviderId: string; + name: string; + source: string | null; + updatedAt: string | null; + modelIds: string[]; + importedAt: number | null; +}; + +export type CloudImportedPluginFile = { + configObjectId: string; + versionId: string | null; + objectType: string; + title: string; + path: string; + updatedAt: string | null; +}; + +export type CloudImportedPlugin = { + pluginId: string; + marketplaceId: string | null; + name: string; + description: string | null; + updatedAt: string | null; + files: CloudImportedPluginFile[]; + importedAt: number | null; +}; + +export type WorkspaceCloudImports = { + skillHubs: Record; + skills: Record; + providers: Record; + plugins: Record; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const readStringArray = (value: unknown) => + Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : []; + +export function readWorkspaceCloudImports(value: unknown): WorkspaceCloudImports { + const root = isRecord(value) ? value : {}; + const cloudImports = isRecord(root.cloudImports) ? root.cloudImports : {}; + const rawSkillHubs = isRecord(cloudImports.skillHubs) ? cloudImports.skillHubs : {}; + const rawSkills = isRecord(cloudImports.skills) ? cloudImports.skills : {}; + const rawProviders = isRecord(cloudImports.providers) ? cloudImports.providers : {}; + const rawPlugins = isRecord(cloudImports.plugins) ? cloudImports.plugins : {}; + + const skillHubs = Object.fromEntries( + Object.entries(rawSkillHubs) + .map(([key, entry]) => { + if (!isRecord(entry)) return null; + const hubId = typeof entry.hubId === "string" ? entry.hubId.trim() : key.trim(); + const name = typeof entry.name === "string" ? entry.name.trim() : hubId; + if (!hubId || !name) return null; + const imported = { + hubId, + name, + skillNames: readStringArray(entry.skillNames), + skillIds: readStringArray(entry.skillIds), + importedAt: typeof entry.importedAt === "number" && Number.isFinite(entry.importedAt) + ? entry.importedAt + : null, + } satisfies CloudImportedSkillHub; + return [hubId, imported] as const; + }) + .filter((entry): entry is readonly [string, CloudImportedSkillHub] => Boolean(entry)), + ); + + const providers = Object.fromEntries( + Object.entries(rawProviders) + .map(([key, entry]) => { + if (!isRecord(entry)) return null; + const cloudProviderId = typeof entry.cloudProviderId === "string" + ? entry.cloudProviderId.trim() + : key.trim(); + const providerId = typeof entry.providerId === "string" ? entry.providerId.trim() : ""; + const sourceProviderId = typeof entry.sourceProviderId === "string" + ? entry.sourceProviderId.trim() + : providerId; + const name = typeof entry.name === "string" ? entry.name.trim() : providerId || cloudProviderId; + if (!cloudProviderId || !providerId || !sourceProviderId || !name) return null; + const imported = { + cloudProviderId, + providerId, + sourceProviderId, + name, + source: typeof entry.source === "string" ? entry.source.trim() || null : null, + updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt.trim() || null : null, + modelIds: readStringArray(entry.modelIds), + importedAt: typeof entry.importedAt === "number" && Number.isFinite(entry.importedAt) + ? entry.importedAt + : null, + } satisfies CloudImportedProvider; + return [cloudProviderId, imported] as const; + }) + .filter((entry): entry is readonly [string, CloudImportedProvider] => Boolean(entry)), + ); + + const skills = Object.fromEntries( + Object.entries(rawSkills) + .map(([key, entry]) => { + if (!isRecord(entry)) return null; + const cloudSkillId = typeof entry.cloudSkillId === "string" + ? entry.cloudSkillId.trim() + : key.trim(); + const installedName = typeof entry.installedName === "string" ? entry.installedName.trim() : ""; + const title = typeof entry.title === "string" ? entry.title.trim() : installedName || cloudSkillId; + if (!cloudSkillId || !installedName || !title) return null; + const imported = { + cloudSkillId, + installedName, + title, + description: typeof entry.description === "string" ? entry.description.trim() || null : null, + shared: entry.shared === "org" || entry.shared === "public" ? entry.shared : null, + updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt.trim() || null : null, + importedAt: typeof entry.importedAt === "number" && Number.isFinite(entry.importedAt) + ? entry.importedAt + : null, + } satisfies CloudImportedSkill; + return [cloudSkillId, imported] as const; + }) + .filter((entry): entry is readonly [string, CloudImportedSkill] => Boolean(entry)), + ); + + const plugins = Object.fromEntries( + Object.entries(rawPlugins) + .map(([key, entry]) => { + if (!isRecord(entry)) return null; + const pluginId = typeof entry.pluginId === "string" ? entry.pluginId.trim() : key.trim(); + const name = typeof entry.name === "string" ? entry.name.trim() : pluginId; + if (!pluginId || !name) return null; + const files = Array.isArray(entry.files) + ? entry.files + .map((file): CloudImportedPluginFile | null => { + if (!isRecord(file)) return null; + const configObjectId = typeof file.configObjectId === "string" ? file.configObjectId.trim() : ""; + const objectType = typeof file.objectType === "string" ? file.objectType.trim() : ""; + const title = typeof file.title === "string" ? file.title.trim() : configObjectId; + const path = typeof file.path === "string" ? file.path.trim() : ""; + if (!configObjectId || !objectType || !title || !path) return null; + return { + configObjectId, + versionId: typeof file.versionId === "string" ? file.versionId.trim() || null : null, + objectType, + title, + path, + updatedAt: typeof file.updatedAt === "string" ? file.updatedAt.trim() || null : null, + }; + }) + .filter((file): file is CloudImportedPluginFile => file !== null) + : []; + const imported = { + pluginId, + marketplaceId: typeof entry.marketplaceId === "string" ? entry.marketplaceId.trim() || null : null, + name, + description: typeof entry.description === "string" ? entry.description.trim() || null : null, + updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt.trim() || null : null, + files, + importedAt: typeof entry.importedAt === "number" && Number.isFinite(entry.importedAt) + ? entry.importedAt + : null, + } satisfies CloudImportedPlugin; + return [pluginId, imported] as const; + }) + .filter((entry): entry is readonly [string, CloudImportedPlugin] => Boolean(entry)), + ); + + return { skillHubs, skills, providers, plugins }; +} + +export function withWorkspaceCloudImports( + config: Record, + cloudImports: WorkspaceCloudImports, +) { + return { + ...config, + cloudImports: { + skillHubs: cloudImports.skillHubs, + skills: cloudImports.skills, + providers: cloudImports.providers, + plugins: cloudImports.plugins, + }, + }; +} diff --git a/apps/app/src/app/cloud/sync/constants.ts b/apps/app/src/app/cloud/sync/constants.ts new file mode 100644 index 0000000000..30e73fb0fa --- /dev/null +++ b/apps/app/src/app/cloud/sync/constants.ts @@ -0,0 +1 @@ +export const CLOUD_SYNC_INTERVAL_MS = 5 * 60 * 1000; diff --git a/apps/app/src/app/constants.ts b/apps/app/src/app/constants.ts new file mode 100644 index 0000000000..1af79fe204 --- /dev/null +++ b/apps/app/src/app/constants.ts @@ -0,0 +1,75 @@ +import type { ModelRef, SuggestedPlugin } from "./types"; +import { t } from "../i18n"; + +export const MODEL_PREF_KEY = "openwork.defaultModel"; +export const SESSION_MODEL_PREF_KEY = "openwork.sessionModels"; +export const THINKING_PREF_KEY = "openwork.showThinking"; +export const VARIANT_PREF_KEY = "openwork.modelVariant"; +export const LANGUAGE_PREF_KEY = "openwork.language"; +export const HIDE_TITLEBAR_PREF_KEY = "openwork.hideTitlebar"; + +export const DEFAULT_MODEL: ModelRef = { + providerID: "opencode", + modelID: "big-pickle", +}; + +export const SUGGESTED_PLUGINS: SuggestedPlugin[] = []; + +export type McpDirectoryInfo = { + id?: string; + name: string; + description: string; + url?: string; + type?: "remote" | "local"; + command?: string[]; + oauth: boolean; +}; + +export const CHROME_DEVTOOLS_MCP_ID = "chrome-devtools"; +export const CHROME_DEVTOOLS_MCP_COMMAND = ["npx", "-y", "chrome-devtools-mcp@latest"] as const; + +export const MCP_QUICK_CONNECT: McpDirectoryInfo[] = [ + { + get name() { return t("mcp.quick_connect_notion_title"); }, + get description() { return t("mcp.quick_connect_notion_desc"); }, + url: "https://mcp.notion.com/mcp", + type: "remote", + oauth: true, + }, + { + get name() { return t("mcp.quick_connect_linear_title"); }, + get description() { return t("mcp.quick_connect_linear_desc"); }, + url: "https://mcp.linear.app/mcp", + type: "remote", + oauth: true, + }, + { + get name() { return t("mcp.quick_connect_sentry_title"); }, + get description() { return t("mcp.quick_connect_sentry_desc"); }, + url: "https://mcp.sentry.dev/mcp", + type: "remote", + oauth: true, + }, + { + get name() { return t("mcp.quick_connect_stripe_title"); }, + get description() { return t("mcp.quick_connect_stripe_desc"); }, + url: "https://mcp.stripe.com", + type: "remote", + oauth: true, + }, + { + get name() { return t("mcp.quick_connect_context7_title"); }, + get description() { return t("mcp.quick_connect_context7_desc"); }, + url: "https://mcp.context7.com/mcp", + type: "remote", + oauth: false, + }, + { + id: CHROME_DEVTOOLS_MCP_ID, + get name() { return t("mcp.quick_connect_chrome_title"); }, + get description() { return t("mcp.quick_connect_chrome_desc"); }, + type: "local", + command: [...CHROME_DEVTOOLS_MCP_COMMAND], + oauth: false, + }, +]; diff --git a/apps/app/src/app/data/commands/browser-setup.md b/apps/app/src/app/data/commands/browser-setup.md new file mode 100644 index 0000000000..4043a2ba84 --- /dev/null +++ b/apps/app/src/app/data/commands/browser-setup.md @@ -0,0 +1,14 @@ +--- +name: browser-setup +description: Try Control Chrome first, then explain setup if Chrome MCP is unavailable +--- + +Try browser automation in OpenWork right away. + +IMPORTANT: +- Prefer Chrome DevTools MCP / `chrome-devtools_*` tools first. +- If those tools are available, use them in your first response to open `https://example.com` and tell the user the page title. +- If those tools are not available, do not invent substitute tools and do not fall back to Playwright first. +- Instead, tell the user the shortest exact steps to connect `Control Chrome` from OpenWork's MCP tab, then ask them to retry. + +Keep the response short and action-oriented. diff --git a/apps/app/src/app/data/skill-creator.md b/apps/app/src/app/data/skill-creator.md new file mode 100644 index 0000000000..dc83ab70bb --- /dev/null +++ b/apps/app/src/app/data/skill-creator.md @@ -0,0 +1,78 @@ +--- +name: skill-creator +description: Guide for creating effective skills. Use when users want to create or update a skill that extends OpenCode with specialized knowledge, workflows, or tool integrations. +--- + +# Skill Creator + +This skill is a template + checklist for creating skills in a workspace. + +## What is a skill? + +A skill is a folder under `.opencode/skills//` or `.claude/skills//` anchored by `SKILL.md`. + +## OpenWork behavior + +- In OpenWork, prefer creating the skill at `.opencode/skills//SKILL.md`. +- Use a file mutation tool (`write`, `edit`, or `apply_patch`) on the real skill path instead of pasting the whole skill into chat. +- Writing the skill file lets OpenWork show the reload banner above the conversation so the user can activate the new skill immediately. + +## Design goals + +- Portable: safe to copy between machines +- Reconstructable: can recreate any required local state +- Self-building: can bootstrap its own config/state +- Credential-safe: no secrets committed; graceful first-time setup + +## Recommended structure + +``` +.opencode/ + skills/ + my-skill/ + SKILL.md + README.md + templates/ + scripts/ +``` + +## Trigger phrases (critical) + +The description field is how Claude decides when to use your skill. +Include 2-3 specific phrases that should trigger it. + +Bad example: +"Use when working with content" + +Good examples: +"Use when user mentions 'content pipeline', 'add to content database', or 'schedule a post'" +"Triggers on: 'rotate PDF', 'flip PDF pages', 'change PDF orientation'" + +Quick validation: +- Contains at least one quoted phrase +- Uses "when" or "triggers" +- Longer than ~50 characters + +## Frontmatter template + +```yaml +--- +name: my-skill +description: | + [What it does in one sentence] + + Triggers when user mentions: + - "[specific phrase 1]" + - "[specific phrase 2]" + - "[specific phrase 3]" +--- +``` + +## Authoring checklist + +1. Start with a clear purpose statement: when to use it + what it outputs. +2. Specify inputs/outputs and any required permissions. +3. Include “Setup” steps if the skill needs local tooling. +4. Add examples: at least 2 realistic user prompts. +5. Keep it safe: avoid destructive defaults; ask for confirmation. +6. In OpenWork, finish by writing the final `SKILL.md` file to `.opencode/skills//SKILL.md` so the reload banner can appear. diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css new file mode 100644 index 0000000000..9752084519 --- /dev/null +++ b/apps/app/src/app/index.css @@ -0,0 +1,319 @@ +@import "tailwindcss"; +@config "../../tailwind.config.ts"; + +@import "../styles/colors.css"; + +:root { + color-scheme: light; + --dls-surface: #ffffff; + --dls-sidebar: #f9fafb; + --dls-app-bg: #ffffff; + --dls-border: #f3f4f6; + --dls-accent: #011627; + --dls-accent-hover: #000000; + --dls-accent-rgb: 1 22 39; + --dls-text-primary: #111827; + --dls-text-secondary: #6b7280; + --dls-hover: #f3f4f6; + --dls-active: #eef2f7; + --dls-radius: 16px; + --dls-radius-lg: 24px; + --dls-shell-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); + --dls-card-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); +} + +[data-theme="dark"] { + color-scheme: dark; + --dls-surface: #121212; + --dls-sidebar: #1a1a1a; + --dls-app-bg: #161616; + --dls-border: #262626; + --dls-accent: #3b82f6; + --dls-accent-hover: #2563eb; + --dls-accent-rgb: 59 130 246; + --dls-text-primary: #ededed; + --dls-text-secondary: #9ca3af; + --dls-hover: #1f1f1f; + --dls-active: #2d2d2d; + --dls-shell-shadow: 0 18px 48px rgba(0, 0, 0, 0.32); + --dls-card-shadow: 0 14px 36px rgba(0, 0, 0, 0.24); +} + +html, +body { + height: 100%; +} + +#root { + height: 100%; +} + +html { + font-size: var(--openwork-font-size, 16px); +} + +body { + margin: 0; + font-family: + "IBM Plex Sans", + Geist, + "Avenir Next", + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans", + "Apple Color Emoji", + "Segoe UI Emoji"; + font-size: 0.875rem; + line-height: 1.5; + color: var(--dls-text-primary); + background-color: var(--dls-surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ow-soft-shell { + border: 1px solid var(--dls-border); + background: var(--dls-surface); + border-radius: 2rem; + box-shadow: var(--dls-shell-shadow); +} + +.ow-soft-card { + border: 1px solid var(--dls-border); + background: var(--dls-surface); + border-radius: 1.5rem; + box-shadow: var(--dls-card-shadow); +} + +.ow-soft-card-quiet { + border: 1px solid var(--dls-border); + background: var(--dls-sidebar); + border-radius: 1.5rem; +} + +.ow-button-primary { + display: inline-flex; + min-height: 48px; + align-items: center; + justify-content: center; + border-radius: 9999px; + background: var(--dls-accent); + color: #ffffff; + box-shadow: 0 8px 20px -16px rgba(var(--dls-accent-rgb), 0.45); +} + +.ow-button-primary:hover:not(:disabled) { + background: var(--dls-accent-hover); +} + +.ow-button-secondary { + display: inline-flex; + min-height: 48px; + align-items: center; + justify-content: center; + border-radius: 9999px; + border: 1px solid var(--dls-border); + background: var(--dls-surface); + color: var(--dls-text-primary); + box-shadow: var(--dls-card-shadow); +} + +.ow-button-secondary:hover:not(:disabled) { + background: var(--dls-hover); +} + +.ow-button-primary, +.ow-button-secondary { + padding: 0.75rem 1.25rem; + font-size: 13px; + font-weight: 500; + transition: background-color 150ms ease, color 150ms ease, transform 150ms ease, opacity 150ms ease; +} + +.ow-button-primary:active:not(:disabled), +.ow-button-secondary:active:not(:disabled) { + transform: scale(0.99); +} + +.ow-button-primary:disabled, +.ow-button-secondary:disabled { + opacity: 0.5; +} + +.ow-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + padding: 0.25rem 0.625rem; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.ow-status-pill-positive { + border: 1px solid rgba(16, 185, 129, 0.16); + background: #ecfdf5; + color: #047857; +} + +.ow-status-pill-warning { + border: 1px solid rgba(245, 158, 11, 0.16); + background: #fffbeb; + color: #b45309; +} + +.ow-status-pill-neutral { + border: 1px solid #e5e7eb; + background: #f9fafb; + color: #6b7280; +} + +.ow-icon-tile { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.85rem; + background: #f4f6f8; + color: #011627; +} + +.ow-icon-tile-muted { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.85rem; + background: #f1f5f9; + color: #6b7280; +} + +.ow-input { + appearance: none; + width: 100%; + border: 0; + border-radius: 0.9rem; + background: #fbfbfc; + box-shadow: inset 0 0 0 1px #eceef1; + color: var(--dls-text-primary); +} + +.ow-input::placeholder { + color: #9ca3af; +} + +.ow-input:focus { + outline: none; + box-shadow: inset 0 0 0 1px rgba(var(--dls-accent-rgb), 0.28), 0 0 0 3px rgba(var(--dls-accent-rgb), 0.08); +} + +/* Global clickable elements pointer */ +button, +[role="button"], +a, +input[type="submit"], +input[type="button"], +input[type="checkbox"], +input[type="radio"], +select { + cursor: pointer; +} + +button:disabled, +[role="button"][aria-disabled="true"], +input:disabled, +select:disabled { + cursor: not-allowed; +} + +@utility animate-spin-slow { + animation: spin 3s linear infinite; +} + +@keyframes soft-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.4; + } + 50% { + transform: scale(1.15); + opacity: 1; + } +} + +@utility animate-soft-pulse { + animation: soft-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Highlight animation for just-saved command */ +@keyframes command-highlight { + 0% { + box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.6); + border-color: rgba(99, 102, 241, 0.8); + } + 50% { + box-shadow: 0 0 0 8px rgba(99, 102, 241, 0); + border-color: rgba(99, 102, 241, 0.4); + } + 100% { + box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); + border-color: rgba(255, 255, 255, 0.08); + } +} + +.command-just-saved { + animation: command-highlight 2s ease-out; + border-color: rgba(99, 102, 241, 0.8); +} + +@keyframes progress-shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(200%); + } +} + +@utility animate-progress-shimmer { + animation: progress-shimmer 2s infinite linear; +} + +/* Quiet, small dot ticker (`:: :: ::`). Each dot pulses briefly in sequence, + illuminating left-to-right like a subtle running light. Used for boot, + "awaiting first token", and any idle-but-alive hint. */ +@keyframes ow-dot-ticker { + 0% { + background-color: rgba(var(--dls-secondary-rgb, 120, 120, 120), 0.22); + box-shadow: 0 0 0 rgba(var(--dls-accent-rgb), 0); + } + 20% { + background-color: rgba(var(--dls-accent-rgb), 0.95); + box-shadow: 0 0 8px rgba(var(--dls-accent-rgb), 0.45); + } + 55%, + 100% { + background-color: rgba(var(--dls-secondary-rgb, 120, 120, 120), 0.22); + box-shadow: 0 0 0 rgba(var(--dls-accent-rgb), 0); + } +} + +.ow-dot-ticker { + animation: ow-dot-ticker 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite; + background-color: rgba(var(--dls-secondary-rgb, 120, 120, 120), 0.22); +} + +@media (prefers-reduced-motion: reduce) { + .ow-dot-ticker { + animation: none; + background-color: rgba(var(--dls-accent-rgb), 0.6); + } +} diff --git a/apps/app/src/app/lib/deep-link-bridge.ts b/apps/app/src/app/lib/deep-link-bridge.ts new file mode 100644 index 0000000000..a0b716dcf4 --- /dev/null +++ b/apps/app/src/app/lib/deep-link-bridge.ts @@ -0,0 +1,43 @@ +export const deepLinkBridgeEvent = "openwork:deep-link"; +export const nativeDeepLinkEvent = "openwork:deep-link-native"; + +export type DeepLinkBridgeDetail = { + urls: string[]; +}; + +declare global { + interface Window { + __OPENWORK__?: { + deepLinks?: string[]; + }; + } +} + +function normalizeDeepLinks(urls: readonly string[]): string[] { + return urls.map((url) => url.trim()).filter(Boolean); +} + +export function pushPendingDeepLinks(target: Window, urls: readonly string[]): string[] { + const normalized = normalizeDeepLinks(urls); + if (normalized.length === 0) { + return []; + } + + target.__OPENWORK__ ??= {}; + const pending = target.__OPENWORK__.deepLinks ?? []; + target.__OPENWORK__.deepLinks = [...pending, ...normalized]; + target.dispatchEvent( + new CustomEvent(deepLinkBridgeEvent, { + detail: { urls: normalized }, + }), + ); + return normalized; +} + +export function drainPendingDeepLinks(target: Window): string[] { + const pending = target.__OPENWORK__?.deepLinks ?? []; + if (target.__OPENWORK__) { + target.__OPENWORK__.deepLinks = []; + } + return [...pending]; +} diff --git a/apps/app/src/app/lib/den-session-events.ts b/apps/app/src/app/lib/den-session-events.ts new file mode 100644 index 0000000000..131841fac0 --- /dev/null +++ b/apps/app/src/app/lib/den-session-events.ts @@ -0,0 +1,41 @@ +import type { DenSettings, DenUser } from "./den"; + +export const denSessionUpdatedEvent = "openwork-den-session-updated"; +export const denSettingsChangedEvent = "openwork-den-settings-changed"; + +export type DenSessionUpdatedDetail = { + status?: "success" | "error"; + baseUrl?: string | null; + token?: string | null; + user?: DenUser | null; + email?: string | null; + message?: string | null; +}; + +export function dispatchDenSessionUpdated(detail: DenSessionUpdatedDetail) { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(denSessionUpdatedEvent, { + detail, + }), + ); +} + +export type DenSettingsChangedDetail = { + settings: DenSettings; +}; + +export function dispatchDenSettingsChanged(detail: DenSettingsChangedDetail) { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(denSettingsChangedEvent, { + detail, + }), + ); +} diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts new file mode 100644 index 0000000000..db15c87abe --- /dev/null +++ b/apps/app/src/app/lib/den.ts @@ -0,0 +1,1524 @@ +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, +} from "./den-session-events"; +import { + desktopFetch, + getDesktopBootstrapConfig as getDesktopBootstrapConfigFromShell, + setDesktopBootstrapConfig as setDesktopBootstrapConfigInShell, + type DesktopBootstrapConfig as ShellDesktopBootstrapConfig, +} from "./desktop"; +import { isDesktopRuntime } from "../utils"; +import type { DenOrgSkillCard } from "../types"; + +const STORAGE_BASE_URL = "openwork.den.baseUrl"; +const STORAGE_API_BASE_URL = "openwork.den.apiBaseUrl"; +const STORAGE_AUTH_TOKEN = "openwork.den.authToken"; +const STORAGE_ACTIVE_ORG_ID = "openwork.den.activeOrgId"; +const STORAGE_ACTIVE_ORG_SLUG = "openwork.den.activeOrgSlug"; +const STORAGE_ACTIVE_ORG_NAME = "openwork.den.activeOrgName"; +const DEFAULT_DEN_TIMEOUT_MS = 12_000; + +export const DEFAULT_DEN_AUTH_NAME = "OpenWork User"; +const BUILD_DEN_BASE_URL = + (typeof import.meta !== "undefined" && typeof import.meta.env?.VITE_DEN_BASE_URL === "string" + ? import.meta.env.VITE_DEN_BASE_URL + : "").trim() || "https://app.openworklabs.com"; +const BUILD_DEN_API_BASE_URL = + (typeof import.meta !== "undefined" && typeof import.meta.env?.VITE_DEN_API_BASE_URL === "string" + ? import.meta.env.VITE_DEN_API_BASE_URL + : "").trim() || undefined; +const BUILD_DEN_REQUIRE_SIGNIN = + (typeof import.meta !== "undefined" && typeof import.meta.env?.VITE_DEN_REQUIRE_SIGNIN === "string" + ? /^(1|true|yes|on)$/i.test(import.meta.env.VITE_DEN_REQUIRE_SIGNIN.trim()) + : false); + +export const DEFAULT_DEN_BASE_URL = BUILD_DEN_BASE_URL; + +export type DenSettings = { + baseUrl: string; + apiBaseUrl?: string; + authToken?: string | null; + activeOrgId?: string | null; + activeOrgSlug?: string | null; + activeOrgName?: string | null; +}; + +type DenBaseUrls = { + baseUrl: string; + apiBaseUrl: string; +}; + +export type DenBootstrapConfig = DenBaseUrls & { + requireSignin: boolean; +}; + +export type DenDesktopConfig = SharedDesktopConfig; + +export type DenUser = { + id: string; + email: string; + name: string | null; +}; + +export type DenOrgSummary = { + id: string; + name: string; + slug: string; + role: "owner" | "admin" | "member"; +}; + +export type DenWorkerSummary = { + workerId: string; + workerName: string; + status: string; + instanceUrl: string | null; + provider: string | null; + isMine: boolean; + createdAt: string | null; +}; + +export type DenWorkerTokens = { + clientToken: string | null; + ownerToken: string | null; + hostToken: string | null; + openworkUrl: string | null; + workspaceId: string | null; +}; + +export type DenOrgLlmProviderModel = { + id: string; + name: string; + config: Record; + createdAt: string | null; +}; + +export type DenOrgLlmProvider = { + id: string; + source: "models_dev" | "custom"; + providerId: string; + name: string; + providerConfig: Record; + hasApiKey: boolean; + models: DenOrgLlmProviderModel[]; + createdAt: string | null; + updatedAt: string | null; +}; + +export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { + apiKey: string | null; +}; + +export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; + +export type DenPluginConfigObjectVersion = { + id: string; + rawSourceText: string | null; + normalizedPayloadJson: Record | null; + sourceRevisionRef: string | null; + createdAt: string | null; +}; + +export type DenPluginConfigObject = { + id: string; + objectType: DenPluginConfigObjectType; + title: string; + description: string | null; + currentFileName: string | null; + currentFileExtension: string | null; + currentRelativePath: string | null; + status: string; + updatedAt: string | null; + latestVersion: DenPluginConfigObjectVersion | null; +}; + +export type DenPluginMembership = { + id: string; + pluginId: string; + configObjectId: string; + configObject?: DenPluginConfigObject; +}; + +export type DenOrgPlugin = { + id: string; + name: string; + description: string | null; + status: string; + memberCount: number; + updatedAt: string | null; + componentCounts: Record; +}; + +export type DenOrgMarketplace = { + id: string; + name: string; + description: string | null; + status: string; + pluginCount: number; + updatedAt: string | null; +}; + +export type DenOrgMarketplaceResolved = { + marketplace: DenOrgMarketplace; + plugins: DenOrgPlugin[]; +}; + +export type DenOrgPluginResolved = { + plugin: DenOrgPlugin; + memberships: DenPluginMembership[]; +}; + +export type DenBillingPrice = { + amount: number | null; + currency: string | null; + recurringInterval: string | null; + recurringIntervalCount: number | null; +}; + +export type DenBillingSubscription = { + id: string; + status: string; + amount: number | null; + currency: string | null; + recurringInterval: string | null; + recurringIntervalCount: number | null; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; + canceledAt: string | null; + endedAt: string | null; +}; + +export type DenBillingInvoice = { + id: string; + createdAt: string | null; + status: string; + totalAmount: number | null; + currency: string | null; + invoiceNumber: string | null; + invoiceUrl: string | null; +}; + +export type DenBillingSummary = { + featureGateEnabled: boolean; + hasActivePlan: boolean; + checkoutRequired: boolean; + checkoutUrl: string | null; + portalUrl: string | null; + price: DenBillingPrice | null; + subscription: DenBillingSubscription | null; + invoices: DenBillingInvoice[]; + productId: string | null; + benefitId: string | null; +}; + +type DenAuthResult = { + user: DenUser | null; + token: string | null; +}; + +export type DenDesktopHandoffExchange = { + user: DenUser | null; + token: string | null; +}; + +const defaultBootstrapBaseUrls = resolveDenBaseUrls({ + baseUrl: BUILD_DEN_BASE_URL, + apiBaseUrl: BUILD_DEN_API_BASE_URL, +}); + +let desktopBootstrapConfig: DenBootstrapConfig = { + ...defaultBootstrapBaseUrls, + requireSignin: BUILD_DEN_REQUIRE_SIGNIN, +}; + +export type DenAppVersionMetadata = { + minAppVersion: string; + latestAppVersion: string; +}; + +type RawJsonResponse = { + ok: boolean; + status: number; + json: T | null; +}; + +export class DenApiError extends Error { + status: number; + code: string; + details?: unknown; + + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.name = "DenApiError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getDenAppVersionMetadata(payload: unknown): DenAppVersionMetadata | null { + if (!isRecord(payload)) return null; + + const latestAppVersion = + typeof payload.latestAppVersion === "string" ? payload.latestAppVersion.trim() : ""; + if (!latestAppVersion) return null; + + return { + minAppVersion: + typeof payload.minAppVersion === "string" ? payload.minAppVersion.trim() : "", + latestAppVersion, + }; +} + +export function normalizeDenDesktopConfig(payload: unknown): DenDesktopConfig { + return normalizeDesktopConfig(payload); +} + +export function normalizeDenBaseUrl(input: string | null | undefined): string | null { + const value = (input ?? "").trim(); + if (!value) return null; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return url.toString().replace(/\/+$/, ""); + } catch { + return null; + } +} + +function isWebAppHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + + if ( + normalized === "localhost" || + normalized === "0.0.0.0" || + normalized === "::1" || + normalized === "[::1]" || + /^127(?:\.\d{1,3}){3}$/.test(normalized) + ) { + return true; + } + + const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) { + const [first, second, third, fourth] = ipv4Match.slice(1).map(Number); + const octets = [first, second, third, fourth]; + if (octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) { + if ( + first === 10 || + first === 127 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) || + (first === 100 && second >= 64 && second <= 127) + ) { + return true; + } + } + } + + return normalized === "app.openworklabs.com" || normalized === "app.openwork.software" || normalized.startsWith("app."); +} + +function stripDenApiBasePath(input: string | null | undefined): string | null { + const normalized = normalizeDenBaseUrl(input); + if (!normalized) return null; + + try { + const url = new URL(normalized); + const pathname = url.pathname.replace(/\/+$/, ""); + const suffix = "/api/den"; + if (!pathname.toLowerCase().endsWith(suffix)) { + return normalized; + } + + const nextPathname = pathname.slice(0, -suffix.length) || "/"; + url.pathname = nextPathname; + return url.toString().replace(/\/+$/, ""); + } catch { + return normalized; + } +} + +function ensureDenApiBasePath(input: string | null | undefined): string | null { + const normalized = normalizeDenBaseUrl(input); + if (!normalized) return null; + + try { + const url = new URL(normalized); + const pathname = url.pathname.replace(/\/+$/, ""); + if (pathname.toLowerCase().endsWith("/api/den")) { + return normalized; + } + url.pathname = `${pathname}/api/den`.replace(/\/+/g, "/"); + return url.toString().replace(/\/+$/, ""); + } catch { + return normalized; + } +} + +function deriveDenApiBaseUrl(input: string | null | undefined): string { + const normalized = normalizeDenBaseUrl(input) ?? DEFAULT_DEN_BASE_URL; + + try { + const url = new URL(normalized); + const pathname = url.pathname.replace(/\/+$/, ""); + if (pathname.toLowerCase().endsWith("/api/den")) { + return normalized; + } + if (isWebAppHost(url.hostname)) { + return ensureDenApiBasePath(normalized) ?? normalized; + } + } catch { + return normalized; + } + + return normalized; +} + +export function resolveDenBaseUrls(input: { baseUrl?: string | null; apiBaseUrl?: string | null } | string | null | undefined): DenBaseUrls { + const rawBaseUrl = typeof input === "string" ? input : input?.baseUrl; + const rawApiBaseUrl = typeof input === "string" ? null : input?.apiBaseUrl; + const normalizedBaseUrl = normalizeDenBaseUrl(rawBaseUrl); + const normalizedApiBaseUrl = normalizeDenBaseUrl(rawApiBaseUrl); + const seedUrl = normalizedBaseUrl ?? normalizedApiBaseUrl ?? DEFAULT_DEN_BASE_URL; + + return { + baseUrl: stripDenApiBasePath(normalizedBaseUrl ?? seedUrl) ?? DEFAULT_DEN_BASE_URL, + apiBaseUrl: normalizedApiBaseUrl ?? deriveDenApiBaseUrl(seedUrl), + }; +} + +function resolveDenBootstrapConfig( + input: { baseUrl: string; apiBaseUrl?: string | null; requireSignin?: boolean | null }, +): DenBootstrapConfig { + return { + ...resolveDenBaseUrls(input), + requireSignin: input.requireSignin === true, + }; +} + +function syncBootstrapSettingsToLocalStorage(config: DenBootstrapConfig) { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem(STORAGE_BASE_URL, config.baseUrl); + window.localStorage.setItem(STORAGE_API_BASE_URL, config.apiBaseUrl); +} + +function getPendingBootstrapConfig(next: DenSettings): DenBootstrapConfig | null { + if (next.baseUrl === undefined && next.apiBaseUrl === undefined) { + return null; + } + + const previous = readDenBootstrapConfig(); + return resolveDenBootstrapConfig({ + baseUrl: next.baseUrl ?? previous.baseUrl, + apiBaseUrl: next.apiBaseUrl ?? previous.apiBaseUrl, + requireSignin: previous.requireSignin, + }); +} + +function applyDesktopBootstrapConfig(config: DenBootstrapConfig) { + desktopBootstrapConfig = config; + syncBootstrapSettingsToLocalStorage(config); +} + +export function readDenBootstrapConfig(): DenBootstrapConfig { + return desktopBootstrapConfig; +} + +export async function initializeDenBootstrapConfig(): Promise { + if (!isDesktopRuntime()) { + desktopBootstrapConfig = resolveDenBootstrapConfig({ + baseUrl: BUILD_DEN_BASE_URL, + apiBaseUrl: BUILD_DEN_API_BASE_URL, + requireSignin: BUILD_DEN_REQUIRE_SIGNIN, + }); + return desktopBootstrapConfig; + } + + try { + const bootstrap = await getDesktopBootstrapConfigFromShell(); + applyDesktopBootstrapConfig(resolveDenBootstrapConfig(bootstrap)); + } catch { + desktopBootstrapConfig = resolveDenBootstrapConfig({ + baseUrl: BUILD_DEN_BASE_URL, + apiBaseUrl: BUILD_DEN_API_BASE_URL, + requireSignin: BUILD_DEN_REQUIRE_SIGNIN, + }); + syncBootstrapSettingsToLocalStorage(desktopBootstrapConfig); + } + + return desktopBootstrapConfig; +} + +export async function setDenBootstrapConfig( + next: ShellDesktopBootstrapConfig, +): Promise { + const normalized = resolveDenBootstrapConfig(next); + + if (isDesktopRuntime()) { + const persisted = await setDesktopBootstrapConfigInShell({ + baseUrl: normalized.baseUrl, + apiBaseUrl: normalized.apiBaseUrl, + requireSignin: normalized.requireSignin, + }); + applyDesktopBootstrapConfig(resolveDenBootstrapConfig(persisted)); + } else { + applyDesktopBootstrapConfig(normalized); + } + + dispatchDenSettingsChanged({ + settings: readDenSettings(), + }); + + return readDenBootstrapConfig(); +} + +export function buildDenAuthUrl(baseUrl: string, mode: "sign-in" | "sign-up"): string { + const target = new URL(resolveDenBaseUrls(baseUrl).baseUrl); + target.searchParams.set("mode", mode); + if (isDesktopDeployment()) { + target.searchParams.set("desktopAuth", "1"); + target.searchParams.set("desktopScheme", "openwork"); + } + return target.toString(); +} + +function resolveRequestBaseUrl(baseUrls: DenBaseUrls, path: string): string { + return path.startsWith("/api/") ? baseUrls.baseUrl : baseUrls.apiBaseUrl; +} + +export function readDenSettings(): DenSettings { + if (typeof window === "undefined") { + return { + ...readDenBootstrapConfig(), + authToken: null, + activeOrgId: null, + activeOrgSlug: null, + activeOrgName: null, + }; + } + + const baseUrls = resolveDenBaseUrls({ + baseUrl: window.localStorage.getItem(STORAGE_BASE_URL) ?? readDenBootstrapConfig().baseUrl, + apiBaseUrl: window.localStorage.getItem(STORAGE_API_BASE_URL) ?? readDenBootstrapConfig().apiBaseUrl, + }); + + return { + ...baseUrls, + authToken: (window.localStorage.getItem(STORAGE_AUTH_TOKEN) ?? "").trim() || null, + activeOrgId: (window.localStorage.getItem(STORAGE_ACTIVE_ORG_ID) ?? "").trim() || null, + activeOrgSlug: (window.localStorage.getItem(STORAGE_ACTIVE_ORG_SLUG) ?? "").trim() || null, + activeOrgName: (window.localStorage.getItem(STORAGE_ACTIVE_ORG_NAME) ?? "").trim() || null, + }; +} + +export function writeDenSettings(next: DenSettings, options?: { persistBootstrap?: boolean }) { + if (typeof window === "undefined") { + return; + } + + const pendingBootstrap = getPendingBootstrapConfig(next); + const previous = readDenSettings(); + const resolved = resolveDenBaseUrls(next); + const previousResolved = resolveDenBaseUrls(previous); + const baseUrl = resolved.baseUrl; + const apiBaseUrl = next.apiBaseUrl !== undefined + ? resolved.apiBaseUrl + : previousResolved.baseUrl === resolved.baseUrl + ? previous.apiBaseUrl ?? resolved.apiBaseUrl + : resolved.apiBaseUrl; + const authToken = next.authToken?.trim() ?? ""; + const activeOrgId = next.activeOrgId?.trim() ?? ""; + const activeOrgSlug = next.activeOrgSlug?.trim() ?? ""; + const activeOrgName = next.activeOrgName?.trim() ?? ""; + + if ( + previous.baseUrl === baseUrl && + (previous.apiBaseUrl ?? "") === apiBaseUrl && + (previous.authToken ?? "") === authToken && + (previous.activeOrgId ?? "") === activeOrgId && + (previous.activeOrgSlug ?? "") === activeOrgSlug && + (previous.activeOrgName ?? "") === activeOrgName + ) { + return; + } + + window.localStorage.setItem(STORAGE_BASE_URL, baseUrl); + window.localStorage.setItem(STORAGE_API_BASE_URL, apiBaseUrl); + if (authToken) { + window.localStorage.setItem(STORAGE_AUTH_TOKEN, authToken); + } else { + window.localStorage.removeItem(STORAGE_AUTH_TOKEN); + } + + if (activeOrgId) { + window.localStorage.setItem(STORAGE_ACTIVE_ORG_ID, activeOrgId); + } else { + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_ID); + } + + if (activeOrgSlug) { + window.localStorage.setItem(STORAGE_ACTIVE_ORG_SLUG, activeOrgSlug); + } else { + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_SLUG); + } + + if (activeOrgName) { + window.localStorage.setItem(STORAGE_ACTIVE_ORG_NAME, activeOrgName); + } else { + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_NAME); + } + + if (options?.persistBootstrap !== false && pendingBootstrap) { + const currentBootstrap = readDenBootstrapConfig(); + if ( + pendingBootstrap.baseUrl !== currentBootstrap.baseUrl || + pendingBootstrap.apiBaseUrl !== currentBootstrap.apiBaseUrl + ) { + void setDenBootstrapConfig({ + baseUrl: pendingBootstrap.baseUrl, + apiBaseUrl: pendingBootstrap.apiBaseUrl, + requireSignin: currentBootstrap.requireSignin, + }).catch(() => undefined); + } + } + + dispatchDenSettingsChanged({ + settings: readDenSettings(), + }); +} + +export function clearDenSession(options?: { includeBaseUrls?: boolean }) { + if (typeof window === "undefined") { + return; + } + + if (options?.includeBaseUrls) { + window.localStorage.removeItem(STORAGE_BASE_URL); + window.localStorage.removeItem(STORAGE_API_BASE_URL); + } + + window.localStorage.removeItem(STORAGE_AUTH_TOKEN); + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_ID); + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_SLUG); + window.localStorage.removeItem(STORAGE_ACTIVE_ORG_NAME); + + dispatchDenSettingsChanged({ + settings: readDenSettings(), + }); +} + +export async function ensureDenActiveOrganization(options?: { forceServerSync?: boolean }) { + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + if (!token) { + return null; + } + + const client = createDenClient({ + baseUrl: settings.baseUrl, + apiBaseUrl: settings.apiBaseUrl, + token, + }); + + const response = await client.listOrgs(); + const targetOrg = + response.orgs.find((org) => org.id === response.activeOrgId) ?? + response.orgs.find((org) => org.slug === response.activeOrgSlug) ?? + response.orgs[0] ?? + null; + + if (!targetOrg) { + writeDenSettings({ + ...settings, + activeOrgId: null, + activeOrgSlug: null, + activeOrgName: null, + }, { persistBootstrap: false }); + return null; + } + + if ( + options?.forceServerSync && + (!response.activeOrgId || response.activeOrgId !== targetOrg.id) + ) { + await client.setActiveOrganization({ organizationId: targetOrg.id }); + } + + writeDenSettings({ + ...settings, + activeOrgId: targetOrg.id, + activeOrgSlug: targetOrg.slug, + activeOrgName: targetOrg.name, + }, { persistBootstrap: false }); + + return targetOrg; +} + +function getErrorMessage(payload: unknown, fallback: string): string { + if (typeof payload === "string" && payload.trim()) { + return payload.trim(); + } + + if (!isRecord(payload)) { + return fallback; + } + + if (typeof payload.message === "string" && payload.message.trim()) { + return payload.message.trim(); + } + + if (typeof payload.error === "string" && payload.error.trim()) { + return payload.error.trim(); + } + + return fallback; +} + +function getUser(payload: unknown): DenUser | null { + if (!isRecord(payload) || !isRecord(payload.user)) { + return null; + } + + const user = payload.user; + if (typeof user.id !== "string" || typeof user.email !== "string") { + return null; + } + + return { + id: user.id, + email: user.email, + name: typeof user.name === "string" ? user.name : null, + }; +} + +function getToken(payload: unknown): string | null { + if (!isRecord(payload) || typeof payload.token !== "string") { + return null; + } + return payload.token.trim() || null; +} + +function getOrgList(payload: unknown): DenOrgSummary[] { + if (!isRecord(payload) || !Array.isArray(payload.orgs)) { + return []; + } + + return payload.orgs + .map((entry) => { + if (!isRecord(entry)) return null; + if ( + typeof entry.id !== "string" || + typeof entry.name !== "string" || + typeof entry.slug !== "string" || + (entry.role !== "owner" && entry.role !== "admin" && entry.role !== "member") + ) { + return null; + } + + return { + id: entry.id, + name: entry.name, + slug: entry.slug, + role: entry.role, + } satisfies DenOrgSummary; + }) + .filter((entry): entry is DenOrgSummary => Boolean(entry)); +} + +function getWorkers(payload: unknown): DenWorkerSummary[] { + if (!isRecord(payload) || !Array.isArray(payload.workers)) { + return []; + } + + return payload.workers + .map((entry) => { + if (!isRecord(entry)) return null; + const instance = isRecord(entry.instance) ? entry.instance : null; + if (typeof entry.id !== "string" || typeof entry.name !== "string") { + return null; + } + return { + workerId: entry.id, + workerName: entry.name, + status: typeof entry.status === "string" ? entry.status : "unknown", + instanceUrl: instance && typeof instance.url === "string" ? instance.url : null, + provider: instance && typeof instance.provider === "string" ? instance.provider : null, + isMine: Boolean(entry.isMine), + createdAt: typeof entry.createdAt === "string" ? entry.createdAt : null, + } satisfies DenWorkerSummary; + }) + .filter((entry): entry is DenWorkerSummary => Boolean(entry)); +} + +function getWorkerTokens(payload: unknown): DenWorkerTokens | null { + if (!isRecord(payload) || !isRecord(payload.tokens)) { + return null; + } + + const tokens = payload.tokens; + const connect = isRecord(payload.connect) ? payload.connect : null; + return { + clientToken: typeof tokens.client === "string" ? tokens.client : null, + ownerToken: typeof tokens.owner === "string" ? tokens.owner : null, + hostToken: typeof tokens.host === "string" ? tokens.host : null, + openworkUrl: connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null, + workspaceId: connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null, + }; +} + +function parseDenOrgSkillRow(record: Record, hubName: string | null): DenOrgSkillCard | null { + if (typeof record.id !== "string" || typeof record.title !== "string" || typeof record.skillText !== "string") { + return null; + } + const description = typeof record.description === "string" ? record.description : null; + const shared = record.shared === "org" || record.shared === "public" ? record.shared : null; + return { + id: record.id, + title: record.title, + description, + skillText: record.skillText, + hubName, + shared, + updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : null, + }; +} + +function getDenOrgSkillsFromPayload(payload: unknown): DenOrgSkillCard[] { + if (!isRecord(payload) || !Array.isArray(payload.skills)) { + return []; + } + return payload.skills + .map((entry) => (isRecord(entry) ? parseDenOrgSkillRow(entry, null) : null)) + .filter((entry): entry is DenOrgSkillCard => entry !== null); +} + +export type DenOrgSkillHub = { id: string; name: string; skills: DenOrgSkillCard[] }; + +function parseOrgSkillHubEntry(hub: Record): DenOrgSkillHub | null { + const hubId = hub.id; + const hubName = hub.name; + const hubSkills = hub.skills; + if (typeof hubId !== "string" || typeof hubName !== "string" || !Array.isArray(hubSkills)) { + return null; + } + const skills = hubSkills + .map((s) => (isRecord(s) ? parseDenOrgSkillRow(s, hubName) : null)) + .filter((s): s is DenOrgSkillCard => s !== null); + return { id: hubId, name: hubName, skills }; +} + +function getDenOrgSkillHubsFromPayload(payload: unknown): DenOrgSkillHub[] { + if (!isRecord(payload) || !Array.isArray(payload.skillHubs)) { + return []; + } + return payload.skillHubs + .map((entry) => (isRecord(entry) ? parseOrgSkillHubEntry(entry) : null)) + .filter((e): e is DenOrgSkillHub => e !== null); +} + +function parseDenOrgLlmProviderModel(value: unknown): DenOrgLlmProviderModel | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") { + return null; + } + + return { + id: value.id, + name: value.name, + config: isRecord(value.config) ? value.config : {}, + createdAt: typeof value.createdAt === "string" ? value.createdAt : null, + }; +} + +function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null { + if ( + !isRecord(value) || + typeof value.id !== "string" || + typeof value.providerId !== "string" || + typeof value.name !== "string" || + (value.source !== "models_dev" && value.source !== "custom") + ) { + return null; + } + + return { + id: value.id, + source: value.source, + providerId: value.providerId, + name: value.name, + providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {}, + hasApiKey: value.hasApiKey === true, + models: Array.isArray(value.models) + ? value.models.map(parseDenOrgLlmProviderModel).filter((entry): entry is DenOrgLlmProviderModel => entry !== null) + : [], + createdAt: typeof value.createdAt === "string" ? value.createdAt : null, + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, + }; +} + +function getDenOrgLlmProviders(payload: unknown): DenOrgLlmProvider[] { + if (!isRecord(payload) || !Array.isArray(payload.llmProviders)) { + return []; + } + + return payload.llmProviders + .map(parseDenOrgLlmProvider) + .filter((entry): entry is DenOrgLlmProvider => entry !== null); +} + +function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConnection | null { + if (!isRecord(payload) || !payload.llmProvider) { + return null; + } + + const provider = parseDenOrgLlmProvider(payload.llmProvider); + if (!provider || !isRecord(payload.llmProvider)) { + return null; + } + + return { + ...provider, + apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null, + }; +} + +function parsePluginConfigObjectType(value: unknown): DenPluginConfigObjectType | null { + return value === "skill" || value === "agent" || value === "command" || value === "tool" || + value === "mcp" || value === "hook" || value === "context" || value === "custom" + ? value + : null; +} + +function parsePluginConfigObjectVersion(value: unknown): DenPluginConfigObjectVersion | null { + if (!isRecord(value) || typeof value.id !== "string") return null; + return { + id: value.id, + rawSourceText: typeof value.rawSourceText === "string" ? value.rawSourceText : null, + normalizedPayloadJson: isRecord(value.normalizedPayloadJson) ? value.normalizedPayloadJson : null, + sourceRevisionRef: typeof value.sourceRevisionRef === "string" ? value.sourceRevisionRef : null, + createdAt: typeof value.createdAt === "string" ? value.createdAt : null, + }; +} + +function parsePluginConfigObject(value: unknown): DenPluginConfigObject | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.title !== "string") return null; + const objectType = parsePluginConfigObjectType(value.objectType); + if (!objectType) return null; + return { + id: value.id, + objectType, + title: value.title, + description: typeof value.description === "string" ? value.description : null, + currentFileName: typeof value.currentFileName === "string" ? value.currentFileName : null, + currentFileExtension: typeof value.currentFileExtension === "string" ? value.currentFileExtension : null, + currentRelativePath: typeof value.currentRelativePath === "string" ? value.currentRelativePath : null, + status: typeof value.status === "string" ? value.status : "active", + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, + latestVersion: parsePluginConfigObjectVersion(value.latestVersion), + }; +} + +function parseOrgPlugin(value: unknown): DenOrgPlugin | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") return null; + const counts = isRecord(value.componentCounts) + ? Object.fromEntries( + Object.entries(value.componentCounts).filter((entry): entry is [string, number] => + typeof entry[0] === "string" && typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] >= 0, + ), + ) + : {}; + return { + id: value.id, + name: value.name, + description: typeof value.description === "string" ? value.description : null, + status: typeof value.status === "string" ? value.status : "active", + memberCount: typeof value.memberCount === "number" && Number.isFinite(value.memberCount) ? value.memberCount : 0, + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, + componentCounts: counts, + }; +} + +function parseOrgMarketplace(value: unknown): DenOrgMarketplace | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") return null; + return { + id: value.id, + name: value.name, + description: typeof value.description === "string" ? value.description : null, + status: typeof value.status === "string" ? value.status : "active", + pluginCount: typeof value.pluginCount === "number" && Number.isFinite(value.pluginCount) ? value.pluginCount : 0, + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, + }; +} + +function parsePluginMembership(value: unknown): DenPluginMembership | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.pluginId !== "string" || typeof value.configObjectId !== "string") { + return null; + } + const configObject = parsePluginConfigObject(value.configObject); + return { + id: value.id, + pluginId: value.pluginId, + configObjectId: value.configObjectId, + ...(configObject ? { configObject } : {}), + }; +} + +function getOrgMarketplaces(payload: unknown): DenOrgMarketplace[] { + if (!isRecord(payload) || !Array.isArray(payload.items)) return []; + return payload.items.map(parseOrgMarketplace).filter((entry): entry is DenOrgMarketplace => entry !== null); +} + +function getOrgMarketplaceResolved(payload: unknown): DenOrgMarketplaceResolved | null { + if (!isRecord(payload) || !isRecord(payload.item)) return null; + const marketplace = parseOrgMarketplace(payload.item.marketplace); + if (!marketplace || !Array.isArray(payload.item.plugins)) return null; + return { + marketplace, + plugins: payload.item.plugins.map(parseOrgPlugin).filter((entry): entry is DenOrgPlugin => entry !== null), + }; +} + +function getOrgPluginResolved(plugin: DenOrgPlugin, payload: unknown): DenOrgPluginResolved { + const memberships = isRecord(payload) && Array.isArray(payload.items) + ? payload.items.map(parsePluginMembership).filter((entry): entry is DenPluginMembership => entry !== null) + : []; + return { plugin, memberships }; +} + +function getBillingPrice(value: unknown): DenBillingPrice | null { + if (!isRecord(value)) { + return null; + } + + return { + amount: typeof value.amount === "number" ? value.amount : null, + currency: typeof value.currency === "string" ? value.currency : null, + recurringInterval: typeof value.recurringInterval === "string" ? value.recurringInterval : null, + recurringIntervalCount: typeof value.recurringIntervalCount === "number" ? value.recurringIntervalCount : null, + }; +} + +function getBillingSubscription(value: unknown): DenBillingSubscription | null { + if (!isRecord(value) || typeof value.id !== "string") { + return null; + } + + return { + id: value.id, + status: typeof value.status === "string" ? value.status : "unknown", + amount: typeof value.amount === "number" ? value.amount : null, + currency: typeof value.currency === "string" ? value.currency : null, + recurringInterval: typeof value.recurringInterval === "string" ? value.recurringInterval : null, + recurringIntervalCount: typeof value.recurringIntervalCount === "number" ? value.recurringIntervalCount : null, + currentPeriodStart: typeof value.currentPeriodStart === "string" ? value.currentPeriodStart : null, + currentPeriodEnd: typeof value.currentPeriodEnd === "string" ? value.currentPeriodEnd : null, + cancelAtPeriodEnd: value.cancelAtPeriodEnd === true, + canceledAt: typeof value.canceledAt === "string" ? value.canceledAt : null, + endedAt: typeof value.endedAt === "string" ? value.endedAt : null, + }; +} + +function getBillingInvoice(value: unknown): DenBillingInvoice | null { + if (!isRecord(value) || typeof value.id !== "string") { + return null; + } + + return { + id: value.id, + createdAt: typeof value.createdAt === "string" ? value.createdAt : null, + status: typeof value.status === "string" ? value.status : "unknown", + totalAmount: typeof value.totalAmount === "number" ? value.totalAmount : null, + currency: typeof value.currency === "string" ? value.currency : null, + invoiceNumber: typeof value.invoiceNumber === "string" ? value.invoiceNumber : null, + invoiceUrl: typeof value.invoiceUrl === "string" ? value.invoiceUrl : null, + }; +} + +export type DenOrgSkillHubSummary = { + id: string; + name: string; + canManage: boolean; +}; + +function getOrgSkillHubSummaries(payload: unknown): DenOrgSkillHubSummary[] { + if (!isRecord(payload) || !Array.isArray(payload.skillHubs)) { + return []; + } + + return payload.skillHubs + .map((entry) => { + if (!isRecord(entry)) return null; + if (typeof entry.id !== "string" || typeof entry.name !== "string" || typeof entry.canManage !== "boolean") { + return null; + } + return { id: entry.id, name: entry.name, canManage: entry.canManage }; + }) + .filter((entry): entry is DenOrgSkillHubSummary => Boolean(entry)); +} + +function getCreatedOrgSkillId(payload: unknown): string | null { + if (!isRecord(payload) || !isRecord(payload.skill)) return null; + return typeof payload.skill.id === "string" ? payload.skill.id : null; +} + +function getBillingSummary(payload: unknown): DenBillingSummary | null { + if (!isRecord(payload) || !isRecord(payload.billing)) { + return null; + } + + const billing = payload.billing; + if ( + typeof billing.featureGateEnabled !== "boolean" || + typeof billing.hasActivePlan !== "boolean" || + typeof billing.checkoutRequired !== "boolean" + ) { + return null; + } + + return { + featureGateEnabled: billing.featureGateEnabled, + hasActivePlan: billing.hasActivePlan, + checkoutRequired: billing.checkoutRequired, + checkoutUrl: typeof billing.checkoutUrl === "string" ? billing.checkoutUrl : null, + portalUrl: typeof billing.portalUrl === "string" ? billing.portalUrl : null, + price: getBillingPrice(billing.price), + subscription: getBillingSubscription(billing.subscription), + invoices: Array.isArray(billing.invoices) + ? billing.invoices.map((item) => getBillingInvoice(item)).filter((item): item is DenBillingInvoice => item !== null) + : [], + productId: typeof billing.productId === "string" ? billing.productId : null, + benefitId: typeof billing.benefitId === "string" ? billing.benefitId : null, + }; +} + +const resolveFetch = () => (isDesktopRuntime() ? desktopFetch : globalThis.fetch); + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +async function fetchWithTimeout(fetchImpl: FetchLike, url: string, init: RequestInit, timeoutMs: number) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return fetchImpl(url, init); + } + + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const signal = controller?.signal; + const initWithSignal = signal && !init.signal ? { ...init, signal } : init; + + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + try { + controller?.abort(); + } catch { + // ignore + } + reject(new Error("Request timed out.")); + }, timeoutMs); + }); + + try { + return await Promise.race([fetchImpl(url, initWithSignal), timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +async function requestJsonRaw( + input: string | DenBaseUrls, + path: string, + options: { method?: string; token?: string | null; body?: unknown; timeoutMs?: number } = {}, +): Promise> { + const baseUrls = typeof input === "string" ? resolveDenBaseUrls(input) : input; + const url = `${resolveRequestBaseUrl(baseUrls, path)}${path}`; + const headers: Record = { Accept: "application/json" }; + const token = options.token?.trim() ?? ""; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetchWithTimeout( + resolveFetch(), + url, + { + method: options.method ?? "GET", + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + credentials: "include", + }, + options.timeoutMs ?? DEFAULT_DEN_TIMEOUT_MS, + ); + + const text = await response.text(); + let json: T | null = null; + try { + json = text ? (JSON.parse(text) as T) : null; + } catch { + json = null; + } + return { ok: response.ok, status: response.status, json }; +} + +async function requestJson( + input: string | DenBaseUrls, + path: string, + options: { method?: string; token?: string | null; body?: unknown; timeoutMs?: number } = {}, +): Promise { + const raw = await requestJsonRaw(input, path, options); + if (!raw.ok) { + const payload = raw.json; + const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : "request_failed"; + const message = getErrorMessage(payload, `Request failed with ${raw.status}.`); + throw new DenApiError(raw.status, code, message, isRecord(payload) ? payload.details : undefined); + } + return raw.json as T; +} + +async function ensureActiveOrganization( + baseUrls: DenBaseUrls, + token: string | null, + input: { organizationId?: string | null; organizationSlug?: string | null }, +) { + const organizationId = input.organizationId?.trim() ?? ""; + const organizationSlug = input.organizationSlug?.trim() ?? ""; + if (!token || (!organizationId && !organizationSlug)) { + return; + } + + await requestJson(baseUrls, "/api/auth/organization/set-active", { + method: "POST", + token, + body: { + organizationId: organizationId || undefined, + organizationSlug: organizationSlug || undefined, + }, + }); +} + +export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string | null; token?: string | null }) { + const baseUrls = resolveDenBaseUrls({ + baseUrl: options.baseUrl, + apiBaseUrl: options.apiBaseUrl, + }); + const token = options.token?.trim() ?? null; + + return { + async setActiveOrganization(input: { organizationId?: string | null; organizationSlug?: string | null }): Promise { + await ensureActiveOrganization(baseUrls, token, input); + }, + + async signInEmail(email: string, password: string): Promise { + const payload = await requestJson(baseUrls, "/api/auth/sign-in/email", { + method: "POST", + body: { + email: email.trim(), + password, + }, + }); + return { user: getUser(payload), token: getToken(payload) }; + }, + + async signUpEmail(email: string, password: string): Promise { + const payload = await requestJson(baseUrls, "/api/auth/sign-up/email", { + method: "POST", + body: { + name: DEFAULT_DEN_AUTH_NAME, + email: email.trim(), + password, + }, + }); + return { user: getUser(payload), token: getToken(payload) }; + }, + + async signOut() { + await requestJsonRaw(baseUrls, "/api/auth/sign-out", { + method: "POST", + token, + body: {}, + }); + }, + + async getSession(): Promise { + const payload = await requestJson(baseUrls, "/v1/me", { + method: "GET", + token, + }); + const user = getUser(payload); + if (!user) { + throw new DenApiError(500, "invalid_session_payload", "Session response did not include a user."); + } + return user; + }, + + async getAppVersionMetadata(): Promise { + const payload = await requestJson(baseUrls, "/v1/app-version", { + method: "GET", + }); + const appVersionMetadata = getDenAppVersionMetadata(payload); + if (!appVersionMetadata) { + throw new DenApiError(500, "invalid_app_version_payload", "App version response was missing version details."); + } + return appVersionMetadata; + }, + + async getDesktopConfig(): Promise { + const payload = await requestJson(baseUrls, "/v1/me/desktop-config", { + method: "GET", + token, + }); + return normalizeDenDesktopConfig(payload); + }, + + async exchangeDesktopHandoff(grant: string): Promise { + const payload = await requestJson(baseUrls, "/v1/auth/desktop-handoff/exchange", { + method: "POST", + body: { grant }, + }); + return { user: getUser(payload), token: getToken(payload) }; + }, + + async listOrgs(): Promise<{ orgs: DenOrgSummary[]; activeOrgId: string | null; activeOrgSlug: string | null; defaultOrgId: string | null }> { + const payload = await requestJson(baseUrls, "/v1/me/orgs", { + method: "GET", + token, + }); + + const activeOrgId = isRecord(payload) && typeof payload.activeOrgId === "string" + ? payload.activeOrgId + : null; + const activeOrgSlug = isRecord(payload) && typeof payload.activeOrgSlug === "string" + ? payload.activeOrgSlug + : null; + + return { + orgs: getOrgList(payload), + activeOrgId, + activeOrgSlug, + defaultOrgId: activeOrgId, + }; + }, + + async listWorkers(orgId: string, limit = 20): Promise { + const params = new URLSearchParams(); + params.set("limit", String(limit)); + const payload = await requestJson(baseUrls, `/v1/workers?${params.toString()}`, { + method: "GET", + token, + }); + return getWorkers(payload); + }, + + async getWorkerTokens(workerId: string, orgId: string): Promise { + const payload = await requestJson(baseUrls, `/v1/workers/${encodeURIComponent(workerId)}/tokens`, { + method: "POST", + token, + body: {}, + }); + const tokens = getWorkerTokens(payload); + if (!tokens) { + throw new DenApiError(500, "invalid_worker_token_payload", "Worker token response was missing token values."); + } + return tokens; + }, + + async listOrgSkills(orgId: string): Promise { + const payload = await requestJson(baseUrls, "/v1/skills", { + method: "GET", + token, + }); + return getDenOrgSkillsFromPayload(payload); + }, + + async listOrgSkillHubs(orgId: string): Promise { + const payload = await requestJson(baseUrls, "/v1/skill-hubs", { + method: "GET", + token, + }); + return getDenOrgSkillHubsFromPayload(payload); + }, + + async listOrgSkillHubSummaries(orgId: string): Promise { + const payload = await requestJson(baseUrls, "/v1/skill-hubs", { + method: "GET", + token, + }); + return getOrgSkillHubSummaries(payload); + }, + + async createOrgSkill( + orgId: string, + input: { skillText: string; shared?: "org" | "public" | null }, + ): Promise<{ id: string }> { + const body = { + skillText: input.skillText, + shared: input.shared === undefined ? ("org" as const) : input.shared, + }; + const payload = await requestJson(baseUrls, "/v1/skills", { + method: "POST", + token, + body, + }); + const id = getCreatedOrgSkillId(payload); + if (!id) { + throw new DenApiError(500, "invalid_skill_payload", "Skill response was missing id."); + } + return { id }; + }, + + async addOrgSkillToHub(orgId: string, skillHubId: string, skillId: string): Promise { + await requestJson( + baseUrls, + `/v1/skill-hubs/${encodeURIComponent(skillHubId)}/skills`, + { + method: "POST", + token, + body: { skillId }, + }, + ); + }, + + async listOrgLlmProviders(orgId: string): Promise { + const payload = await requestJson(baseUrls, "/v1/llm-providers", { + method: "GET", + token, + }); + return getDenOrgLlmProviders(payload); + }, + + async getOrgLlmProviderConnection(orgId: string, llmProviderId: string): Promise { + const payload = await requestJson( + baseUrls, + `/v1/llm-providers/${encodeURIComponent(llmProviderId)}/connect`, + { + method: "GET", + token, + }, + ); + const provider = getDenOrgLlmProviderConnection(payload); + if (!provider) { + throw new DenApiError(500, "invalid_llm_provider_payload", "LLM provider response was missing connection details."); + } + return provider; + }, + + async listOrgMarketplaces(orgId: string): Promise { + const payload = await requestJson( + baseUrls, + `/v1/marketplaces?status=active&limit=100`, + { method: "GET", token }, + ); + return getOrgMarketplaces(payload); + }, + + async getOrgMarketplaceResolved(orgId: string, marketplaceId: string): Promise { + const payload = await requestJson( + baseUrls, + `/v1/marketplaces/${encodeURIComponent(marketplaceId)}/resolved`, + { method: "GET", token }, + ); + const resolved = getOrgMarketplaceResolved(payload); + if (!resolved) { + throw new DenApiError(500, "invalid_marketplace_payload", "Marketplace response was missing plugin details."); + } + return resolved; + }, + + async getOrgPluginResolved(orgId: string, plugin: DenOrgPlugin): Promise { + const payload = await requestJson( + baseUrls, + `/v1/plugins/${encodeURIComponent(plugin.id)}/resolved`, + { method: "GET", token }, + ); + return getOrgPluginResolved(plugin, payload); + }, + + async getBillingStatus(options: { includeCheckout?: boolean; includePortal?: boolean; includeInvoices?: boolean } = {}): Promise { + const params = new URLSearchParams(); + if (options.includeCheckout) { + params.set("includeCheckout", "1"); + } + if (options.includePortal === false) { + params.set("excludePortal", "1"); + } + if (options.includeInvoices === false) { + params.set("excludeInvoices", "1"); + } + + const path = params.size > 0 ? `/v1/workers/billing?${params.toString()}` : "/v1/workers/billing"; + const payload = await requestJson(baseUrls, path, { + method: "GET", + token, + }); + const summary = getBillingSummary(payload); + if (!summary) { + throw new DenApiError(500, "invalid_billing_payload", "Billing response was missing details."); + } + return summary; + }, + + async updateSubscriptionCancellation(cancelAtPeriodEnd: boolean): Promise<{ subscription: DenBillingSubscription | null; billing: DenBillingSummary }> { + const payload = await requestJson(baseUrls, "/v1/workers/billing/subscription", { + method: "POST", + token, + body: { cancelAtPeriodEnd }, + }); + const billing = getBillingSummary(payload); + if (!billing) { + throw new DenApiError(500, "invalid_billing_payload", "Subscription update response was missing billing details."); + } + + return { + subscription: isRecord(payload) ? getBillingSubscription(payload.subscription) : null, + billing, + }; + }, + }; +} + +export async function fetchDenOrgSkillsCatalog( + client: ReturnType, + orgId: string, +): Promise { + const [hubs, flatSkills] = await Promise.all([client.listOrgSkillHubs(orgId), client.listOrgSkills(orgId)]); + const hubNameBySkillId = new Map(); + for (const hub of hubs) { + for (const skill of hub.skills) { + if (!hubNameBySkillId.has(skill.id)) { + hubNameBySkillId.set(skill.id, hub.name); + } + } + } + const byId = new Map(); + for (const skill of flatSkills) { + byId.set(skill.id, { + ...skill, + hubName: hubNameBySkillId.get(skill.id) ?? null, + }); + } + return [...byId.values()].sort((a, b) => a.title.localeCompare(b.title)); +} diff --git a/apps/app/src/app/lib/desktop-tauri.ts b/apps/app/src/app/lib/desktop-tauri.ts new file mode 100644 index 0000000000..417ec66a1c --- /dev/null +++ b/apps/app/src/app/lib/desktop-tauri.ts @@ -0,0 +1,776 @@ +import { invoke } from "@tauri-apps/api/core"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { validateMcpServerName } from "../mcp"; +import { applyWebviewZoom } from "./font-zoom"; +import { nativeDeepLinkEvent } from "./deep-link-bridge"; + +export const desktopFetch = tauriFetch as unknown as typeof globalThis.fetch; + +export async function openDesktopUrl(url: string): Promise { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl(url); +} + +export async function openDesktopPath(target: string): Promise { + const { openPath } = await import("@tauri-apps/plugin-opener"); + await openPath(target); +} + +export async function revealDesktopItemInDir(target: string): Promise { + const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + await revealItemInDir(target); +} + +export async function relaunchDesktopApp(): Promise { + const { relaunch } = await import("@tauri-apps/plugin-process"); + await relaunch(); +} + +export async function getDesktopHomeDir(): Promise { + const { homeDir } = await import("@tauri-apps/api/path"); + return homeDir(); +} + +export async function joinDesktopPath(...parts: string[]): Promise { + const { join } = await import("@tauri-apps/api/path"); + return join(...parts); +} + +export async function setDesktopZoomFactor(value: number): Promise { + try { + const { getCurrentWebview } = await import("@tauri-apps/api/webview"); + const webview = getCurrentWebview(); + await applyWebviewZoom(webview, value); + return true; + } catch { + return false; + } +} + +export async function subscribeDesktopDeepLinks( + handler: (urls: string[]) => void, +): Promise<() => void> { + const [{ getCurrent, onOpenUrl }, { listen }] = await Promise.all([ + import("@tauri-apps/plugin-deep-link"), + import("@tauri-apps/api/event"), + ]); + + const startUrls = await getCurrent().catch(() => null); + if (Array.isArray(startUrls)) { + handler(startUrls); + } + + const deepLinkUnlisten = await onOpenUrl((urls) => { + handler(urls); + }).catch(() => () => undefined); + + const eventUnlisten = await listen(nativeDeepLinkEvent, (event) => { + if (Array.isArray(event.payload)) { + handler(event.payload); + } + }).catch(() => () => undefined); + + return () => { + void deepLinkUnlisten(); + void eventUnlisten(); + }; +} + +export type EngineInfo = { + running: boolean; + runtime: "direct"; + baseUrl: string | null; + projectDir: string | null; + hostname: string | null; + port: number | null; + opencodeUsername: string | null; + opencodePassword: string | null; + opencodeBinPath: string | null; + opencodeBinSource: string | null; + pid: number | null; + lastStdout: string | null; + lastStderr: string | null; +}; + +export type OpenworkServerInfo = { + running: boolean; + remoteAccessEnabled: boolean; + host: string | null; + port: number | null; + baseUrl: string | null; + connectUrl: string | null; + mdnsUrl: string | null; + lanUrl: string | null; + clientToken: string | null; + ownerToken: string | null; + hostToken: string | null; + managedOpencodeBinPath: string | null; + managedOpencodeBinSource: string | null; + pid: number | null; + lastStdout: string | null; + lastStderr: string | null; +}; + +export type EngineDoctorResult = { + found: boolean; + inPath: boolean; + resolvedPath: string | null; + resolvedSource: string | null; + version: string | null; + supportsServe: boolean; + notes: string[]; + serveHelpStatus: number | null; + serveHelpStdout: string | null; + serveHelpStderr: string | null; +}; + +export type WorkspaceInfo = { + id: string; + name: string; + path: string; + preset: string; + workspaceType: "local" | "remote"; + remoteType?: "openwork" | "opencode" | null; + baseUrl?: string | null; + directory?: string | null; + displayName?: string | null; + openworkHostUrl?: string | null; + openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkWorkspaceId?: string | null; + openworkWorkspaceName?: string | null; + + // Sandbox lifecycle metadata (desktop-managed) + sandboxBackend?: "docker" | "microsandbox" | null; + sandboxRunId?: string | null; + sandboxContainerName?: string | null; +}; + +export type WorkspaceList = { + // UI-selected workspace persisted by the desktop shell. + selectedId?: string; + // Runtime/watch target currently followed by the desktop host. + watchedId?: string | null; + // Legacy desktop payloads used activeId for the UI-selected workspace. + activeId?: string | null; + workspaces: WorkspaceInfo[]; +}; + +export function resolveWorkspaceListSelectedId( + list: Pick | null | undefined, +): string { + return list?.selectedId?.trim() || list?.activeId?.trim() || ""; +} + +export type WorkspaceExportSummary = { + outputPath: string; + included: number; + excluded: string[]; +}; + +export async function engineStart( + projectDir: string, + options?: { + preferSidecar?: boolean; + runtime?: "direct"; + workspacePaths?: string[]; + opencodeBinPath?: string | null; + opencodeEnableExa?: boolean; + openworkRemoteAccess?: boolean; + }, +): Promise { + return invoke("engine_start", { + projectDir, + preferSidecar: options?.preferSidecar ?? true, + opencodeBinPath: options?.opencodeBinPath ?? null, + opencodeEnableExa: options?.opencodeEnableExa ?? null, + openworkRemoteAccess: options?.openworkRemoteAccess ?? null, + runtime: options?.runtime ?? null, + workspacePaths: options?.workspacePaths ?? null, + }); +} + +export async function workspaceBootstrap(): Promise { + return invoke("workspace_bootstrap"); +} + +export async function workspaceSetSelected(workspaceId: string): Promise { + return invoke("workspace_set_selected", { workspaceId }); +} + +export async function workspaceSetRuntimeActive(workspaceId: string | null): Promise { + return invoke("workspace_set_runtime_active", { workspaceId: workspaceId ?? "" }); +} + +export async function workspaceCreate(input: { + folderPath: string; + name: string; + preset: string; +}): Promise { + return invoke("workspace_create", { + folderPath: input.folderPath, + name: input.name, + preset: input.preset, + }); +} + +export async function workspaceCreateRemote(input: { + baseUrl: string; + directory?: string | null; + displayName?: string | null; + remoteType?: "openwork" | "opencode" | null; + openworkHostUrl?: string | null; + openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkWorkspaceId?: string | null; + openworkWorkspaceName?: string | null; + + // Sandbox lifecycle metadata (desktop-managed) + sandboxBackend?: "docker" | "microsandbox" | null; + sandboxRunId?: string | null; + sandboxContainerName?: string | null; +}): Promise { + return invoke("workspace_create_remote", { + baseUrl: input.baseUrl, + directory: input.directory ?? null, + displayName: input.displayName ?? null, + remoteType: input.remoteType ?? null, + openworkHostUrl: input.openworkHostUrl ?? null, + openworkToken: input.openworkToken ?? null, + openworkClientToken: input.openworkClientToken ?? null, + openworkHostToken: input.openworkHostToken ?? null, + openworkWorkspaceId: input.openworkWorkspaceId ?? null, + openworkWorkspaceName: input.openworkWorkspaceName ?? null, + sandboxBackend: input.sandboxBackend ?? null, + sandboxRunId: input.sandboxRunId ?? null, + sandboxContainerName: input.sandboxContainerName ?? null, + }); +} + +export async function workspaceUpdateRemote(input: { + workspaceId: string; + baseUrl?: string | null; + directory?: string | null; + displayName?: string | null; + remoteType?: "openwork" | "opencode" | null; + openworkHostUrl?: string | null; + openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkWorkspaceId?: string | null; + openworkWorkspaceName?: string | null; + + // Sandbox lifecycle metadata (desktop-managed) + sandboxBackend?: "docker" | "microsandbox" | null; + sandboxRunId?: string | null; + sandboxContainerName?: string | null; +}): Promise { + return invoke("workspace_update_remote", { + workspaceId: input.workspaceId, + baseUrl: input.baseUrl ?? null, + directory: input.directory ?? null, + displayName: input.displayName ?? null, + remoteType: input.remoteType ?? null, + openworkHostUrl: input.openworkHostUrl ?? null, + openworkToken: input.openworkToken ?? null, + openworkClientToken: input.openworkClientToken ?? null, + openworkHostToken: input.openworkHostToken ?? null, + openworkWorkspaceId: input.openworkWorkspaceId ?? null, + openworkWorkspaceName: input.openworkWorkspaceName ?? null, + sandboxBackend: input.sandboxBackend ?? null, + sandboxRunId: input.sandboxRunId ?? null, + sandboxContainerName: input.sandboxContainerName ?? null, + }); +} + +export async function workspaceUpdateDisplayName(input: { + workspaceId: string; + displayName?: string | null; +}): Promise { + return invoke("workspace_update_display_name", { + workspaceId: input.workspaceId, + displayName: input.displayName ?? null, + }); +} + +export async function workspaceForget(workspaceId: string): Promise { + return invoke("workspace_forget", { workspaceId }); +} + +export async function workspaceAddAuthorizedRoot(input: { + workspacePath: string; + folderPath: string; +}): Promise { + return invoke("workspace_add_authorized_root", { + workspacePath: input.workspacePath, + folderPath: input.folderPath, + }); +} + +export async function workspaceExportConfig(input: { + workspaceId: string; + outputPath: string; +}): Promise { + return invoke("workspace_export_config", { + workspaceId: input.workspaceId, + outputPath: input.outputPath, + }); +} + +export async function workspaceImportConfig(input: { + archivePath: string; + targetDir: string; + name?: string | null; +}): Promise { + return invoke("workspace_import_config", { + archivePath: input.archivePath, + targetDir: input.targetDir, + name: input.name ?? null, + }); +} + +export type OpencodeCommandDraft = { + name: string; + description?: string; + template: string; + agent?: string; + model?: string; + subtask?: boolean; +}; + +export type WorkspaceOpenworkConfig = { + version: number; + workspace?: { + name?: string | null; + createdAt?: number | null; + preset?: string | null; + } | null; + authorizedRoots: string[]; + reload?: { + auto?: boolean; + resume?: boolean; + } | null; +}; + +export async function workspaceOpenworkRead(input: { + workspacePath: string; +}): Promise { + return invoke("workspace_openwork_read", { + workspacePath: input.workspacePath, + }); +} + +export async function workspaceOpenworkWrite(input: { + workspacePath: string; + config: WorkspaceOpenworkConfig; +}): Promise { + return invoke("workspace_openwork_write", { + workspacePath: input.workspacePath, + config: input.config, + }); +} + +export async function opencodeCommandList(input: { + scope: "workspace" | "global"; + projectDir: string; +}): Promise { + return invoke("opencode_command_list", { + scope: input.scope, + projectDir: input.projectDir, + }); +} + +export async function opencodeCommandWrite(input: { + scope: "workspace" | "global"; + projectDir: string; + command: OpencodeCommandDraft; +}): Promise { + return invoke("opencode_command_write", { + scope: input.scope, + projectDir: input.projectDir, + command: input.command, + }); +} + +export async function opencodeCommandDelete(input: { + scope: "workspace" | "global"; + projectDir: string; + name: string; +}): Promise { + return invoke("opencode_command_delete", { + scope: input.scope, + projectDir: input.projectDir, + name: input.name, + }); +} + +export async function engineStop(): Promise { + return invoke("engine_stop"); +} + +export async function engineRestart(options?: { + opencodeEnableExa?: boolean; + openworkRemoteAccess?: boolean; +}): Promise { + return invoke("engine_restart", { + opencodeEnableExa: options?.opencodeEnableExa ?? null, + openworkRemoteAccess: options?.openworkRemoteAccess ?? null, + }); +} + +export type AppBuildInfo = { + version: string; + gitSha?: string | null; + buildEpoch?: string | null; + openworkDevMode?: boolean; + os?: string | null; + arch?: string | null; +}; + +export type DesktopBootstrapConfig = { + baseUrl: string; + apiBaseUrl?: string | null; + requireSignin: boolean; +}; + +export async function appBuildInfo(): Promise { + return invoke("app_build_info"); +} + +export async function getDesktopBootstrapConfig(): Promise { + return invoke("get_desktop_bootstrap_config"); +} + +export async function setDesktopBootstrapConfig( + config: DesktopBootstrapConfig, +): Promise { + return invoke("set_desktop_bootstrap_config", { config }); +} + +export async function nukeOpenworkAndOpencodeConfigAndExit(): Promise { + return invoke("nuke_openwork_and_opencode_config_and_exit"); +} + +export type OrchestratorDetachedHost = { + openworkUrl: string; + token: string; + ownerToken?: string | null; + hostToken: string; + port: number; + sandboxBackend?: "docker" | "microsandbox" | null; + sandboxRunId?: string | null; + sandboxContainerName?: string | null; +}; + +export async function orchestratorStartDetached(input: { + workspacePath: string; + sandboxBackend?: "none" | "docker" | "microsandbox" | null; + sandboxImageRef?: string | null; + runId?: string | null; + openworkToken?: string | null; + openworkHostToken?: string | null; +}): Promise { + return invoke("orchestrator_start_detached", { + workspacePath: input.workspacePath, + sandboxBackend: input.sandboxBackend ?? null, + sandboxImageRef: input.sandboxImageRef ?? null, + runId: input.runId ?? null, + openworkToken: input.openworkToken ?? null, + openworkHostToken: input.openworkHostToken ?? null, + }); +} + +export type SandboxDoctorResult = { + installed: boolean; + daemonRunning: boolean; + permissionOk: boolean; + ready: boolean; + clientVersion?: string | null; + serverVersion?: string | null; + error?: string | null; + debug?: { + candidates: string[]; + selectedBin?: string | null; + versionCommand?: { + status: number; + stdout: string; + stderr: string; + } | null; + infoCommand?: { + status: number; + stdout: string; + stderr: string; + } | null; + } | null; +}; + +export async function sandboxDoctor(): Promise { + return invoke("sandbox_doctor"); +} + +export async function sandboxStop(containerName: string): Promise { + return invoke("sandbox_stop", { containerName }); +} + +export type OpenworkDockerCleanupResult = { + candidates: string[]; + removed: string[]; + errors: string[]; +}; + +export async function sandboxCleanupOpenworkContainers(): Promise { + return invoke("sandbox_cleanup_openwork_containers"); +} + +export type SandboxDebugProbeResult = { + startedAt: number; + finishedAt: number; + runId: string; + workspacePath: string; + ready: boolean; + doctor: SandboxDoctorResult; + detachedHost?: OrchestratorDetachedHost | null; + dockerInspect?: { + status: number; + stdout: string; + stderr: string; + } | null; + dockerLogs?: { + status: number; + stdout: string; + stderr: string; + } | null; + cleanup: { + containerName?: string | null; + containerRemoved: boolean; + removeResult?: { + status: number; + stdout: string; + stderr: string; + } | null; + workspaceRemoved: boolean; + errors: string[]; + }; + error?: string | null; +}; + +export async function sandboxDebugProbe(): Promise { + return invoke("sandbox_debug_probe"); +} + +export async function openworkServerInfo(): Promise { + return invoke("openwork_server_info"); +} + +export async function openworkServerRestart(options?: { + remoteAccessEnabled?: boolean; +}): Promise { + return invoke("openwork_server_restart", { + remoteAccessEnabled: options?.remoteAccessEnabled ?? null, + }); +} + +export async function engineInfo(): Promise { + return invoke("engine_info"); +} + +export async function runtimeBootstrap(): Promise { + return { + ok: true, + skipped: true, + reason: "unsupported-runtime", + }; +} + +export async function engineDoctor(options?: { + preferSidecar?: boolean; + opencodeBinPath?: string | null; +}): Promise { + return invoke("engine_doctor", { + preferSidecar: options?.preferSidecar ?? true, + opencodeBinPath: options?.opencodeBinPath ?? null, + }); +} + +export async function pickDirectory(options?: { + title?: string; + defaultPath?: string; + multiple?: boolean; +}): Promise { + const { open } = await import("@tauri-apps/plugin-dialog"); + return open({ + title: options?.title, + defaultPath: options?.defaultPath, + directory: true, + canCreateDirectories: true, + multiple: options?.multiple, + }); +} + +export async function pickFile(options?: { + title?: string; + defaultPath?: string; + multiple?: boolean; + filters?: Array<{ name: string; extensions: string[] }>; +}): Promise { + const { open } = await import("@tauri-apps/plugin-dialog"); + return open({ + title: options?.title, + defaultPath: options?.defaultPath, + directory: false, + multiple: options?.multiple, + filters: options?.filters, + }); +} + +export async function saveFile(options?: { + title?: string; + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; +}): Promise { + const { save } = await import("@tauri-apps/plugin-dialog"); + return save({ + title: options?.title, + defaultPath: options?.defaultPath, + filters: options?.filters, + }); +} + +export type ExecResult = { + ok: boolean; + status: number; + stdout: string; + stderr: string; +}; + +export async function engineInstall(): Promise { + return invoke("engine_install"); +} + +export async function importSkill( + projectDir: string, + sourceDir: string, + options?: { overwrite?: boolean }, +): Promise { + return invoke("import_skill", { + projectDir, + sourceDir, + overwrite: options?.overwrite ?? false, + }); +} + +export async function installSkillTemplate( + projectDir: string, + name: string, + content: string, + options?: { overwrite?: boolean }, +): Promise { + return invoke("install_skill_template", { + projectDir, + name, + content, + overwrite: options?.overwrite ?? false, + }); +} + +export type LocalSkillCard = { + name: string; + path: string; + description?: string; + trigger?: string; +}; + +export type LocalSkillContent = { + path: string; + content: string; +}; + +export async function listLocalSkills(projectDir: string): Promise { + return invoke("list_local_skills", { projectDir }); +} + +export async function readLocalSkill(projectDir: string, name: string): Promise { + return invoke("read_local_skill", { projectDir, name }); +} + +export async function writeLocalSkill(projectDir: string, name: string, content: string): Promise { + return invoke("write_local_skill", { projectDir, name, content }); +} + +export async function uninstallSkill(projectDir: string, name: string): Promise { + return invoke("uninstall_skill", { projectDir, name }); +} + +export type OpencodeConfigFile = { + path: string; + exists: boolean; + content: string | null; +}; + +export type UpdaterEnvironment = { + supported: boolean; + reason: string | null; + executablePath: string | null; + appBundlePath: string | null; +}; + +export async function updaterEnvironment(): Promise { + return invoke("updater_environment"); +} + +export async function readOpencodeConfig( + scope: "project" | "global", + projectDir: string, +): Promise { + return invoke("read_opencode_config", { scope, projectDir }); +} + +export async function writeOpencodeConfig( + scope: "project" | "global", + projectDir: string, + content: string, +): Promise { + return invoke("write_opencode_config", { scope, projectDir, content }); +} + +export async function resetOpenworkState(mode: "onboarding" | "all"): Promise { + return invoke("reset_openwork_state", { mode }); +} + +export type CacheResetResult = { + removed: string[]; + missing: string[]; + errors: string[]; +}; + +export async function resetOpencodeCache(): Promise { + return invoke("reset_opencode_cache"); +} + +export async function opencodeMcpAuth( + projectDir: string, + serverName: string, +): Promise { + const safeProjectDir = projectDir.trim(); + if (!safeProjectDir) { + throw new Error("project_dir is required"); + } + + const safeServerName = validateMcpServerName(serverName); + + return invoke("opencode_mcp_auth", { + projectDir: safeProjectDir, + serverName: safeServerName, + }); +} + +/** + * Set window decorations (titlebar) visibility. + * When `decorations` is false, the native titlebar is hidden. + * Useful for tiling window managers on Linux (e.g., Hyprland, i3, sway). + */ +export async function setWindowDecorations(decorations: boolean): Promise { + return invoke("set_window_decorations", { decorations }); +} diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts new file mode 100644 index 0000000000..efea351245 --- /dev/null +++ b/apps/app/src/app/lib/desktop.ts @@ -0,0 +1,345 @@ +import * as tauriBridge from "./desktop-tauri"; +import { nativeDeepLinkEvent } from "./deep-link-bridge"; + +export type * from "./desktop-tauri"; + +export type DesktopBridge = typeof tauriBridge; + +declare global { + interface Window { + __OPENWORK_ELECTRON__?: { + bridge?: Partial; + invokeDesktop?: (command: string, ...args: unknown[]) => Promise; + shell?: { + openExternal?: (url: string) => Promise; + relaunch?: () => Promise; + }; + migration?: { + readSnapshot?: () => Promise; + ackSnapshot?: () => Promise<{ ok: boolean; moved: boolean }>; + }; + updater?: { + getChannel?: () => Promise<{ + channel: "stable" | "alpha"; + feedUrl: string; + currentVersion: string; + }>; + setChannel?: (channel: "stable" | "alpha") => Promise<{ + channel: "stable" | "alpha"; + feedUrl: string; + currentVersion: string; + }>; + check?: () => Promise<{ + available: boolean; + currentVersion?: string; + latestVersion?: string | null; + releaseDate?: string | null; + releaseNotes?: unknown; + channel?: "stable" | "alpha"; + feedUrl?: string; + reason?: string; + }>; + download?: () => Promise<{ ok: boolean; reason?: string }>; + installAndRestart?: () => Promise<{ ok: boolean; reason?: string }>; + }; + meta?: { + initialDeepLinks?: string[]; + platform?: "darwin" | "linux" | "windows"; + version?: string; + }; + }; + } +} + +function missingElectronMethod(method: string): never { + throw new Error(`Electron desktop bridge method is not implemented yet: ${method}`); +} + +function isElectronDesktopRuntime() { + return typeof window !== "undefined" && window.__OPENWORK_ELECTRON__ != null; +} + +function isTauriDesktopRuntime() { + return typeof window !== "undefined" && (window as any).__TAURI_INTERNALS__ != null; +} + +async function invokeElectronHelper(command: string, ...args: unknown[]): Promise { + const invokeDesktop = window.__OPENWORK_ELECTRON__?.invokeDesktop; + if (!invokeDesktop) { + throw new Error(`Electron desktop helper is unavailable: ${command}`); + } + return (await invokeDesktop(command, ...args)) as T; +} + +function resolveElectronBridge(): DesktopBridge { + const exposed = window.__OPENWORK_ELECTRON__?.bridge ?? {}; + const invokeDesktop = window.__OPENWORK_ELECTRON__?.invokeDesktop; + return new Proxy(exposed as DesktopBridge, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (value != null) { + return value; + } + + if (prop === "resolveWorkspaceListSelectedId") { + return tauriBridge.resolveWorkspaceListSelectedId; + } + + if (typeof prop === "string" && invokeDesktop) { + return (...args: unknown[]) => invokeDesktop(prop, ...args); + } + + if (typeof prop === "string") { + return (..._args: unknown[]) => missingElectronMethod(prop); + } + + return value; + }, + }); +} + +function resolveDesktopBridge(): DesktopBridge { + if ( + typeof window !== "undefined" && + (window.__OPENWORK_ELECTRON__?.bridge || window.__OPENWORK_ELECTRON__?.invokeDesktop) + ) { + return resolveElectronBridge(); + } + return tauriBridge; +} + +export const desktopBridge: DesktopBridge = new Proxy({} as DesktopBridge, { + get(_target, prop, receiver) { + return Reflect.get(resolveDesktopBridge(), prop, receiver); + }, +}); + +function isLoopbackUrl(input: RequestInfo | URL): boolean { + const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + try { + const url = new URL(raw); + return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"; + } catch { + return false; + } +} + +export const desktopFetch: typeof globalThis.fetch = (input, init) => { + if (isElectronDesktopRuntime()) { + if (isLoopbackUrl(input)) { + return globalThis.fetch(input, init); + } + + return invokeElectronHelper<{ + status: number; + statusText: string; + headers: [string, string][]; + body: string; + }>("__fetch", typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url, { + method: init?.method, + headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : undefined, + body: typeof init?.body === "string" ? init.body : undefined, + }).then( + (result) => + new Response(result.body, { + status: result.status, + statusText: result.statusText, + headers: result.headers, + }), + ); + } + return tauriBridge.desktopFetch(input, init); +}; + +export async function openDesktopUrl(url: string): Promise { + if (isElectronDesktopRuntime()) { + await window.__OPENWORK_ELECTRON__?.shell?.openExternal?.(url); + return; + } + if (isTauriDesktopRuntime()) { + await tauriBridge.openDesktopUrl(url); + return; + } + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener,noreferrer"); + } +} + +export async function openDesktopPath(target: string): Promise { + if (isElectronDesktopRuntime()) { + const result = await invokeElectronHelper("__openPath", target); + if (typeof result === "string" && result.trim()) { + throw new Error(result); + } + return; + } + await tauriBridge.openDesktopPath(target); +} + +export async function revealDesktopItemInDir(target: string): Promise { + if (isElectronDesktopRuntime()) { + await invokeElectronHelper("__revealItemInDir", target); + return; + } + await tauriBridge.revealDesktopItemInDir(target); +} + +export async function relaunchDesktopApp(): Promise { + if (isElectronDesktopRuntime()) { + await window.__OPENWORK_ELECTRON__?.shell?.relaunch?.(); + return; + } + await tauriBridge.relaunchDesktopApp(); +} + +export async function getDesktopHomeDir(): Promise { + if (isElectronDesktopRuntime()) { + return invokeElectronHelper("__homeDir"); + } + return tauriBridge.getDesktopHomeDir(); +} + +export async function joinDesktopPath(...parts: string[]): Promise { + if (isElectronDesktopRuntime()) { + return invokeElectronHelper("__joinPath", ...parts); + } + return tauriBridge.joinDesktopPath(...parts); +} + +export async function setDesktopZoomFactor(value: number): Promise { + if (isElectronDesktopRuntime()) { + return invokeElectronHelper("__setZoomFactor", value); + } + return tauriBridge.setDesktopZoomFactor(value); +} + +export async function subscribeDesktopDeepLinks( + handler: (urls: string[]) => void, +): Promise<() => void> { + if (isElectronDesktopRuntime()) { + const listener = (event: Event) => { + const customEvent = event as CustomEvent; + if (Array.isArray(customEvent.detail)) { + handler(customEvent.detail); + } + }; + window.addEventListener(nativeDeepLinkEvent, listener as EventListener); + const initialUrls = window.__OPENWORK_ELECTRON__?.meta?.initialDeepLinks; + if (Array.isArray(initialUrls) && initialUrls.length > 0) { + handler(initialUrls); + } + return () => { + window.removeEventListener(nativeDeepLinkEvent, listener as EventListener); + }; + } + + return tauriBridge.subscribeDesktopDeepLinks(handler); +} + +const { + resolveWorkspaceListSelectedId, + engineStart, + workspaceBootstrap, + workspaceSetSelected, + workspaceSetRuntimeActive, + workspaceCreate, + workspaceCreateRemote, + workspaceUpdateRemote, + workspaceUpdateDisplayName, + workspaceForget, + workspaceAddAuthorizedRoot, + workspaceExportConfig, + workspaceImportConfig, + workspaceOpenworkRead, + workspaceOpenworkWrite, + opencodeCommandList, + opencodeCommandWrite, + opencodeCommandDelete, + engineStop, + engineRestart, + appBuildInfo, + getDesktopBootstrapConfig, + setDesktopBootstrapConfig, + nukeOpenworkAndOpencodeConfigAndExit, + orchestratorStartDetached, + sandboxDoctor, + sandboxStop, + sandboxCleanupOpenworkContainers, + sandboxDebugProbe, + openworkServerInfo, + openworkServerRestart, + runtimeBootstrap, + engineInfo, + engineDoctor, + pickDirectory, + pickFile, + saveFile, + engineInstall, + importSkill, + installSkillTemplate, + listLocalSkills, + readLocalSkill, + writeLocalSkill, + uninstallSkill, + updaterEnvironment, + readOpencodeConfig, + writeOpencodeConfig, + resetOpenworkState, + resetOpencodeCache, + opencodeMcpAuth, + setWindowDecorations, +} = desktopBridge; + +export { + resolveWorkspaceListSelectedId, + engineStart, + workspaceBootstrap, + workspaceSetSelected, + workspaceSetRuntimeActive, + workspaceCreate, + workspaceCreateRemote, + workspaceUpdateRemote, + workspaceUpdateDisplayName, + workspaceForget, + workspaceAddAuthorizedRoot, + workspaceExportConfig, + workspaceImportConfig, + workspaceOpenworkRead, + workspaceOpenworkWrite, + opencodeCommandList, + opencodeCommandWrite, + opencodeCommandDelete, + engineStop, + engineRestart, + appBuildInfo, + getDesktopBootstrapConfig, + setDesktopBootstrapConfig, + nukeOpenworkAndOpencodeConfigAndExit, + orchestratorStartDetached, + sandboxDoctor, + sandboxStop, + sandboxCleanupOpenworkContainers, + sandboxDebugProbe, + openworkServerInfo, + openworkServerRestart, + runtimeBootstrap, + engineInfo, + engineDoctor, + pickDirectory, + pickFile, + saveFile, + engineInstall, + importSkill, + installSkillTemplate, + listLocalSkills, + readLocalSkill, + writeLocalSkill, + uninstallSkill, + updaterEnvironment, + readOpencodeConfig, + writeOpencodeConfig, + resetOpenworkState, + resetOpencodeCache, + opencodeMcpAuth, + setWindowDecorations, +}; diff --git a/apps/app/src/app/lib/dev-log.ts b/apps/app/src/app/lib/dev-log.ts new file mode 100644 index 0000000000..25594a55e2 --- /dev/null +++ b/apps/app/src/app/lib/dev-log.ts @@ -0,0 +1,88 @@ +export type DevLogLevel = "debug" | "warn" | "perf"; + +export type DevLogRecord = { + id: number; + at: string; + ts: number; + level: DevLogLevel; + source: string; + label: string; + payload?: unknown; +}; + +type DevRoot = typeof globalThis & { + __openworkDevLogSeq?: number; + __openworkDevLogs?: DevLogRecord[]; +}; + +const DEV_LOG_LIMIT = 1500; + +const payloadText = (value: unknown) => { + if (value === undefined) return ""; + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +export const recordDevLog = ( + enabled: boolean, + input: { + level: DevLogLevel; + source: string; + label: string; + payload?: unknown; + }, +) => { + if (!enabled) return; + + const root = globalThis as DevRoot; + const id = (root.__openworkDevLogSeq ?? 0) + 1; + root.__openworkDevLogSeq = id; + + const entry: DevLogRecord = { + id, + at: new Date().toISOString(), + ts: Date.now(), + level: input.level, + source: input.source, + label: input.label, + payload: input.payload, + }; + + const logs = root.__openworkDevLogs ?? []; + logs.push(entry); + if (logs.length > DEV_LOG_LIMIT) { + logs.splice(0, logs.length - DEV_LOG_LIMIT); + } + root.__openworkDevLogs = logs; +}; + +export const readDevLogs = (limit = 200) => { + const root = globalThis as DevRoot; + const logs = root.__openworkDevLogs ?? []; + if (limit === 0) return logs.slice(); + if (limit < 0) return []; + if (logs.length <= limit) return logs.slice(); + return logs.slice(logs.length - limit); +}; + +export const clearDevLogs = () => { + const root = globalThis as DevRoot; + root.__openworkDevLogs = []; + root.__openworkDevLogSeq = 0; +}; + +export const formatDevLogLine = (entry: DevLogRecord) => { + const prefix = `[${entry.at}] ${entry.level.toUpperCase()} ${entry.source}:${entry.label}`; + const text = payloadText(entry.payload); + return text ? `${prefix} ${text}` : prefix; +}; + +export const formatDevLogText = (limit = 200) => { + const lines = readDevLogs(limit).map(formatDevLogLine); + if (!lines.length) return ""; + return `${lines.join("\n")}\n`; +}; diff --git a/apps/app/src/app/lib/electron-alpha.ts b/apps/app/src/app/lib/electron-alpha.ts new file mode 100644 index 0000000000..5e30fbc6e9 --- /dev/null +++ b/apps/app/src/app/lib/electron-alpha.ts @@ -0,0 +1,80 @@ +import { desktopFetch } from "./desktop"; + +export type ElectronAlphaArtifact = { + arch: "arm64" | "x64"; + manifestUrl: string; + releaseUrl: string; + url: string; + path: string; + version: string; + sha512: string; +}; + +const ELECTRON_ALPHA_RELEASE_BASE_URL = + "https://github.com/different-ai/openwork/releases/download/alpha-macos-latest"; + +export const ELECTRON_ALPHA_RELEASE_PAGE_URL = + "https://github.com/different-ai/openwork/releases/tag/alpha-macos-latest"; + +export const ELECTRON_ALPHA_LATEST_MAC_YML_URL = `${ELECTRON_ALPHA_RELEASE_BASE_URL}/latest-mac.yml`; + +function parseYamlScalar(raw: string, key: string): string | null { + const pattern = new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, "m"); + const match = raw.match(pattern); + if (!match?.[1]) return null; + return match[1].trim().replace(/^['"]|['"]$/g, ""); +} + +function parseFirstFileUrl(raw: string): string | null { + const match = raw.match(/^\s*-\s+url:\s*(.+?)\s*$/m); + if (!match?.[1]) return null; + return match[1].trim().replace(/^['"]|['"]$/g, ""); +} + +function resolveArtifactUrl(pathOrUrl: string): string { + if (/^https:\/\//i.test(pathOrUrl)) return pathOrUrl; + return new URL(pathOrUrl, `${ELECTRON_ALPHA_RELEASE_BASE_URL}/`).toString(); +} + +export function parseElectronLatestMacYml( + raw: string, + arch: "arm64" | "x64", +): ElectronAlphaArtifact { + const version = parseYamlScalar(raw, "version"); + const path = parseYamlScalar(raw, "path") ?? parseFirstFileUrl(raw); + const sha512 = parseYamlScalar(raw, "sha512"); + + if (!version) { + throw new Error("latest-mac.yml is missing version."); + } + if (!path) { + throw new Error("latest-mac.yml is missing artifact path/url."); + } + if (!sha512) { + throw new Error("latest-mac.yml is missing sha512."); + } + + return { + arch, + manifestUrl: ELECTRON_ALPHA_LATEST_MAC_YML_URL, + releaseUrl: ELECTRON_ALPHA_RELEASE_PAGE_URL, + url: resolveArtifactUrl(path), + path, + version, + sha512, + }; +} + +export async function resolveElectronAlphaArtifact( + arch: "arm64" | "x64" = "arm64", +): Promise { + const response = await desktopFetch(ELECTRON_ALPHA_LATEST_MAC_YML_URL, { + headers: { Accept: "text/yaml, text/plain, */*" }, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch latest-mac.yml (${response.status} ${response.statusText}).`, + ); + } + return parseElectronLatestMacYml(await response.text(), arch); +} diff --git a/apps/app/src/app/lib/feedback.ts b/apps/app/src/app/lib/feedback.ts new file mode 100644 index 0000000000..5eb0c16a91 --- /dev/null +++ b/apps/app/src/app/lib/feedback.ts @@ -0,0 +1,109 @@ +const ENV_FEEDBACK_URL = String(import.meta.env.VITE_OPENWORK_FEEDBACK_URL ?? "").trim(); +const ENV_APP_VERSION = String(import.meta.env.VITE_OPENWORK_APP_VERSION ?? "").trim(); + +export const DEFAULT_FEEDBACK_URL = + ENV_FEEDBACK_URL || "https://openworklabs.com/feedback"; + +type FeedbackUrlOptions = { + entrypoint: string; + deployment?: string | null; + appVersion?: string | null; + openworkServerVersion?: string | null; + opencodeVersion?: string | null; + orchestratorVersion?: string | null; +}; + +type ClientOsContext = { + osName?: string; + osVersion?: string; + platform?: string; +}; + +function parseClientOsContext(): ClientOsContext { + if (typeof navigator === "undefined") return {}; + + const platform = + typeof (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData + ?.platform === "string" + ? (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform?.trim() ?? "" + : typeof navigator.platform === "string" + ? navigator.platform.trim() + : ""; + const userAgent = + typeof navigator.userAgent === "string" ? navigator.userAgent : ""; + + const macMatch = userAgent.match(/Mac OS X ([0-9_]+)/i); + if (macMatch) { + return { + osName: "macOS", + osVersion: macMatch[1]?.replace(/_/g, "."), + platform, + }; + } + + const windowsMatch = userAgent.match(/Windows NT ([0-9.]+)/i); + if (windowsMatch) { + const rawVersion = windowsMatch[1] ?? ""; + const mappedVersion = + rawVersion === "10.0" ? "10/11" : rawVersion || undefined; + return { + osName: "Windows", + osVersion: mappedVersion, + platform, + }; + } + + const iosMatch = userAgent.match(/(?:iPhone|iPad|iPod).*OS ([0-9_]+)/i); + if (iosMatch) { + return { + osName: "iOS", + osVersion: iosMatch[1]?.replace(/_/g, "."), + platform, + }; + } + + const androidMatch = userAgent.match(/Android ([0-9.]+)/i); + if (androidMatch) { + return { + osName: "Android", + osVersion: androidMatch[1], + platform, + }; + } + + if (/Linux/i.test(userAgent) || /Linux/i.test(platform)) { + return { + osName: "Linux", + platform, + }; + } + + return platform ? { platform } : {}; +} + +export function buildFeedbackUrl(options: FeedbackUrlOptions): string { + const url = new URL(DEFAULT_FEEDBACK_URL); + const osContext = parseClientOsContext(); + + url.searchParams.set("source", "openwork-app"); + url.searchParams.set("entrypoint", options.entrypoint); + + const entries = { + deployment: options.deployment?.trim() ?? "", + appVersion: options.appVersion?.trim() || ENV_APP_VERSION, + openworkServerVersion: options.openworkServerVersion?.trim() ?? "", + opencodeVersion: options.opencodeVersion?.trim() ?? "", + orchestratorVersion: options.orchestratorVersion?.trim() ?? "", + osName: osContext.osName?.trim() ?? "", + osVersion: osContext.osVersion?.trim() ?? "", + platform: osContext.platform?.trim() ?? "", + }; + + for (const [key, value] of Object.entries(entries)) { + if (value) { + url.searchParams.set(key, value); + } + } + + return url.toString(); +} diff --git a/apps/app/src/app/lib/font-zoom.ts b/apps/app/src/app/lib/font-zoom.ts new file mode 100644 index 0000000000..cf33f68ca2 --- /dev/null +++ b/apps/app/src/app/lib/font-zoom.ts @@ -0,0 +1,82 @@ +export const FONT_ZOOM_STORAGE_KEY = "openwork.desktop-font-zoom.v1"; +export const FONT_ZOOM_BASE_PX = 16; +export const FONT_ZOOM_STEP = 0.1; +export const FONT_ZOOM_MIN = 0.8; +export const FONT_ZOOM_MAX = 1.6; + +export type FontZoomShortcutAction = "in" | "out" | "reset"; +export type FontZoomTarget = { setZoom: (scaleFactor: number) => Promise }; + +export function clampFontZoom(value: number): number { + return Math.min(FONT_ZOOM_MAX, Math.max(FONT_ZOOM_MIN, value)); +} + +export function normalizeFontZoom(value: number): number { + return Math.round(clampFontZoom(value) * 100) / 100; +} + +export function parseFontZoomShortcut(event: { + key: string; + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; +}): FontZoomShortcutAction | null { + const mod = event.metaKey || event.ctrlKey; + if (!mod || event.altKey) return null; + + if ( + event.code === "Equal" || + event.code === "NumpadAdd" || + event.key === "+" || + event.key === "=" + ) { + return "in"; + } + if ( + event.code === "Minus" || + event.code === "NumpadSubtract" || + event.key === "-" || + event.key === "_" + ) { + return "out"; + } + if (event.code === "Digit0" || event.code === "Numpad0" || event.key === "0") { + return "reset"; + } + + return null; +} + +export function readStoredFontZoom(storage: Pick): number | null { + try { + const raw = storage.getItem(FONT_ZOOM_STORAGE_KEY); + if (!raw) return null; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return null; + return normalizeFontZoom(parsed); + } catch { + return null; + } +} + +export function persistFontZoom(storage: Pick, value: number) { + try { + storage.setItem(FONT_ZOOM_STORAGE_KEY, String(value)); + } catch { + // ignore storage failures + } +} + +export function applyFontZoom(rootStyle: Pick, value: number): number { + const normalized = normalizeFontZoom(value); + const px = FONT_ZOOM_BASE_PX * normalized; + rootStyle.setProperty("--openwork-font-size", `${px}px`); + return normalized; +} + +export async function applyWebviewZoom(target: FontZoomTarget, value: number): Promise { + const normalized = normalizeFontZoom(value); + await target.setZoom(normalized); + return normalized; +} diff --git a/apps/app/src/app/lib/local-file-path.impl.d.ts b/apps/app/src/app/lib/local-file-path.impl.d.ts new file mode 100644 index 0000000000..34dc99ee87 --- /dev/null +++ b/apps/app/src/app/lib/local-file-path.impl.d.ts @@ -0,0 +1 @@ +export function normalizeLocalFilePath(value: unknown): string; diff --git a/apps/app/src/app/lib/local-file-path.impl.js b/apps/app/src/app/lib/local-file-path.impl.js new file mode 100644 index 0000000000..cbc660edbd --- /dev/null +++ b/apps/app/src/app/lib/local-file-path.impl.js @@ -0,0 +1,33 @@ +const FILE_URI_PREFIX_RE = /^file:(?:\/\/)?/i; +const WINDOWS_DRIVE_URI_PATH_RE = /^\/[A-Za-z]:\//; + +const safeDecodeURIComponent = (value) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +export const normalizeLocalFilePath = (value) => { + const trimmed = String(value ?? "").trim(); + if (!FILE_URI_PREFIX_RE.test(trimmed)) return trimmed; + + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "file:") return trimmed; + + const pathname = safeDecodeURIComponent(parsed.pathname || ""); + if (!pathname) return trimmed; + if (WINDOWS_DRIVE_URI_PATH_RE.test(pathname)) return pathname.slice(1); + if (parsed.hostname && parsed.hostname.toLowerCase() !== "localhost") { + return `//${parsed.hostname}${pathname}`; + } + return pathname; + } catch { + const decoded = safeDecodeURIComponent(trimmed.replace(FILE_URI_PREFIX_RE, "")); + if (!decoded) return trimmed; + if (WINDOWS_DRIVE_URI_PATH_RE.test(decoded)) return decoded.slice(1); + return decoded; + } +}; diff --git a/apps/app/src/app/lib/local-file-path.ts b/apps/app/src/app/lib/local-file-path.ts new file mode 100644 index 0000000000..5546f44a3d --- /dev/null +++ b/apps/app/src/app/lib/local-file-path.ts @@ -0,0 +1,4 @@ +import { normalizeLocalFilePath as normalizeLocalFilePathImpl } from "./local-file-path.impl.js"; + +export const normalizeLocalFilePath = (value: string): string => + normalizeLocalFilePathImpl(value) as string; diff --git a/apps/app/src/app/lib/migration.ts b/apps/app/src/app/lib/migration.ts new file mode 100644 index 0000000000..ab42ea0e5c --- /dev/null +++ b/apps/app/src/app/lib/migration.ts @@ -0,0 +1,218 @@ +// One-way Tauri → Electron migration snapshot plumbing. +// +// The Tauri shell exports localStorage keys the user actively depends on +// (workspace list, selected workspace, per-workspace last-session, server +// list) into a JSON file in app_data_dir just before launching the +// Electron installer. Electron reads that file on first launch, hydrates +// localStorage for the keys that are still empty, then marks the file as +// acknowledged. +// +// Scope decision: we migrate *workspace* keys only. Everything else +// (theme, font zoom, sidebar widths, feature flags) is cheap to redo and +// not worth the complexity of a cross-origin localStorage transfer. + +import { invoke } from "@tauri-apps/api/core"; + +export const MIGRATION_SNAPSHOT_VERSION = 1; + +// Keep this list tiny and strict. Adding keys here expands blast radius +// if a later release renames them. +export const MIGRATION_KEY_PATTERNS: Array = [ + /^openwork\.react\.activeWorkspace$/, + /^openwork\.react\.sessionByWorkspace$/, + /^openwork\.server\.list$/, + /^openwork\.server\.active$/, + /^openwork\.server\.urlOverride$/, + /^openwork\.server\.token$/, +]; + +export type MigrationSnapshot = { + version: typeof MIGRATION_SNAPSHOT_VERSION; + writtenAt: number; + source: "tauri"; + keys: Record; +}; + +function matchesMigrationKey(key: string) { + return MIGRATION_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +function collectMigrationKeysFromLocalStorage(): Record { + const out: Record = {}; + if (typeof window === "undefined") return out; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key || !matchesMigrationKey(key)) continue; + const value = window.localStorage.getItem(key); + if (value != null) out[key] = value; + } + return out; +} + +/** + * Tauri-only. Called by the last Tauri release right before it kicks off + * the Electron installer. Snapshots the workspace-related localStorage + * keys to /migration-snapshot.v1.json via a Rust command + * that does the actual disk write (renderer can't write outside the + * sandbox on its own). + */ +export async function writeMigrationSnapshotFromTauri(): Promise<{ + ok: boolean; + keyCount: number; + reason?: string; +}> { + try { + const keys = collectMigrationKeysFromLocalStorage(); + const snapshot: MigrationSnapshot = { + version: MIGRATION_SNAPSHOT_VERSION, + writtenAt: Date.now(), + source: "tauri", + keys, + }; + await invoke("write_migration_snapshot", { snapshot }); + return { ok: true, keyCount: Object.keys(keys).length }; + } catch (error) { + return { + ok: false, + keyCount: 0, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +type ElectronMigrationBridge = { + readSnapshot: () => Promise; + ackSnapshot: () => Promise<{ ok: boolean; moved: boolean }>; +}; + +function electronMigrationBridge(): ElectronMigrationBridge | null { + if (typeof window === "undefined") return null; + const bridge = (window as unknown as { + __OPENWORK_ELECTRON__?: { migration?: ElectronMigrationBridge }; + }).__OPENWORK_ELECTRON__; + return bridge?.migration ?? null; +} + +/** + * Electron-only. Called once during app boot. Reads the migration + * snapshot (if any), hydrates localStorage for keys that aren't already + * set on the Electron install, and acks the file so we don't re-ingest + * on subsequent launches. + * + * Returns the number of keys hydrated. Returns 0 when there is no + * snapshot, which is the steady-state case after the first launch. + */ +export async function ingestMigrationSnapshotOnElectronBoot(): Promise { + const bridge = electronMigrationBridge(); + if (!bridge) return 0; + + let snapshot: MigrationSnapshot | null = null; + try { + snapshot = await bridge.readSnapshot(); + } catch { + return 0; + } + if (!snapshot || snapshot.version !== MIGRATION_SNAPSHOT_VERSION) return 0; + + const entries = Object.entries(snapshot.keys ?? {}); + let hydrated = 0; + if (typeof window !== "undefined") { + for (const [key, value] of entries) { + if (!matchesMigrationKey(key)) continue; + if (window.localStorage.getItem(key) != null) continue; + try { + window.localStorage.setItem(key, value); + hydrated += 1; + } catch { + // localStorage write failures are non-fatal; the user just won't + // see that key migrated this launch. + } + } + } + + try { + await bridge.ackSnapshot(); + } catch { + // A failed ack means we'll re-ingest on next launch, but the + // "skip if already set" guard keeps that idempotent. + } + + return hydrated; +} + +export type MigrateToElectronRequest = { + /** + * Download URL for the matching Electron artifact. On macOS a .zip. + * On Windows, an NSIS .exe (TODO — stubbed today). On Linux, an AppImage + * (TODO — stubbed today). + */ + url: string; + /** Optional sha256 to verify before touching the filesystem. */ + sha256?: string; + /** Optional electron-builder sha512 (base64) from latest-mac.yml. */ + sha512?: string; + /** + * Override where the Electron .app ends up (macOS). Defaults to + * replacing the currently-running .app bundle in place. + */ + targetAppPath?: string; +}; + +/** + * Tauri-only. Hand off to the new Electron build: + * 1. Download + verify the installer + * 2. Replace the running .app bundle + * 3. Relaunch into the Electron binary + * 4. Quit this Tauri process + * + * Callers should invoke `writeMigrationSnapshotFromTauri()` first so the + * new Electron shell can hydrate localStorage on first launch. + */ +export async function migrateToElectron( + request: MigrateToElectronRequest, +): Promise<{ ok: boolean; reason?: string }> { + try { + await invoke("migrate_to_electron", { request }); + return { ok: true }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +// Localstorage key that stores a "don't ask again until" epoch-ms. +// Users who click "Later" get a 24h reprieve; after that we nudge again. +export const MIGRATION_DEFER_KEY = "openwork.migration.deferredUntil"; +export const MIGRATION_DEFAULT_DEFER_MS = 24 * 60 * 60 * 1000; + +export function isMigrationDeferred(now: number = Date.now()): boolean { + if (typeof window === "undefined") return false; + try { + const raw = window.localStorage.getItem(MIGRATION_DEFER_KEY); + if (!raw) return false; + const until = Number.parseInt(raw, 10); + return Number.isFinite(until) && until > now; + } catch { + return false; + } +} + +export function deferMigration(ms: number = MIGRATION_DEFAULT_DEFER_MS): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MIGRATION_DEFER_KEY, String(Date.now() + ms)); + } catch { + // non-fatal + } +} + +export function clearMigrationDefer(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(MIGRATION_DEFER_KEY); + } catch { + // non-fatal + } +} diff --git a/apps/app/src/app/lib/model-behavior.ts b/apps/app/src/app/lib/model-behavior.ts new file mode 100644 index 0000000000..2883be910c --- /dev/null +++ b/apps/app/src/app/lib/model-behavior.ts @@ -0,0 +1,217 @@ +import type { ProviderListItem } from "../types"; +import type { ModelBehaviorOption } from "../types"; +import { t } from "../../i18n"; + +type ProviderModel = ProviderListItem["models"][string]; + +const WELL_KNOWN_VARIANT_ORDER = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +function defaultBehaviorOption(): ModelBehaviorOption { + return { + value: null, + label: t("settings.provider_default_label"), + description: t("settings.provider_default_desc"), + }; +} + +const humanize = (value: string) => { + const cleaned = value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); + if (!cleaned) return value; + 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(" "); +}; + +export const normalizeModelBehaviorValue = (value: string | null) => { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (!normalized) return null; + if ( + normalized === "balance" || + normalized === "balanced" || + normalized === "default" || + normalized === "provider-default" + ) { + return null; + } + return normalized; +}; + +const getVariantKeys = (model: ProviderModel) => { + const keys = Object.keys(model.variants ?? {}) + .map((key) => normalizeModelBehaviorValue(key)) + .filter((key): key is string => Boolean(key)); + return Array.from(new Set(keys)); +}; + +const sortVariantKeys = (keys: string[]) => + keys.slice().sort((a, b) => { + const aIndex = WELL_KNOWN_VARIANT_ORDER.indexOf(a as (typeof WELL_KNOWN_VARIANT_ORDER)[number]); + const bIndex = WELL_KNOWN_VARIANT_ORDER.indexOf(b as (typeof WELL_KNOWN_VARIANT_ORDER)[number]); + if (aIndex !== -1 || bIndex !== -1) { + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + } + return a.localeCompare(b); + }); + +const providerFamily = (providerID: string, providerName?: string | null) => { + const normalizedId = providerID.trim().toLowerCase(); + if (["anthropic", "openai", "google", "opencode"].includes(normalizedId)) { + return normalizedId; + } + + const normalizedName = providerName?.trim().toLowerCase() ?? ""; + if (normalizedName.includes("anthropic")) return "anthropic"; + if (normalizedName.includes("openai")) return "openai"; + if (normalizedName.includes("google")) return "google"; + if (normalizedName.includes("opencode")) return "opencode"; + return normalizedId; +}; + +const getBehaviorTitle = ( + providerID: string, + model: ProviderModel, + variantKeys: string[], + providerName?: string | null, +) => { + const family = providerFamily(providerID, providerName); + if (variantKeys.length > 0) { + if (family === "anthropic") return t("model_behavior.title_extended_thinking"); + if (family === "google") return t("model_behavior.title_reasoning_budget"); + if ( + family === "openai" || + family === "opencode" || + variantKeys.some((key) => ["none", "minimal", "low", "medium", "high", "xhigh"].includes(key)) + ) { + return t("model_behavior.title_reasoning_effort"); + } + return t("app.model_behavior_title"); + } + if (model.capabilities?.reasoning) return t("model_behavior.title_builtin_reasoning"); + return t("model_behavior.title_standard_generation"); +}; + +const getVariantLabel = (providerID: string, key: string, providerName?: string | null) => { + const family = providerFamily(providerID, providerName); + if (key === "none") return t("model_behavior.label_fast"); + if (key === "minimal") return t("model_behavior.label_quick"); + if (key === "low") return t("model_behavior.label_light"); + if (key === "medium") return t("model_behavior.label_balanced"); + if (key === "high") return family === "anthropic" ? t("model_behavior.label_extended") : t("model_behavior.label_deep"); + if (key === "xhigh" || key === "max") return t("model_behavior.label_maximum"); + return humanize(key); +}; + +export const formatGenericBehaviorLabel = (value: string | null) => { + const normalized = normalizeModelBehaviorValue(value); + if (!normalized) return defaultBehaviorOption().label; + return getVariantLabel("generic", normalized); +}; + +const getVariantDescription = ( + providerID: string, + key: string, + label: string, + providerName?: string | null, +) => { + const family = providerFamily(providerID, providerName); + if (key === "none") return t("model_behavior.desc_none"); + if (key === "minimal") return t("model_behavior.desc_minimal"); + if (key === "low") return family === "google" + ? t("model_behavior.desc_low_google") + : t("model_behavior.desc_low"); + if (key === "medium") return t("model_behavior.desc_medium"); + if (key === "high") return family === "anthropic" + ? t("model_behavior.desc_high_anthropic") + : t("model_behavior.desc_high"); + if (key === "xhigh" || key === "max") return family === "anthropic" + ? t("model_behavior.desc_max_anthropic") + : t("model_behavior.desc_max"); + return t("model_behavior.desc_generic", undefined, { label: label.toLowerCase() }); +}; + +export const getModelBehaviorOptions = ( + providerID: string, + model: ProviderModel, + providerName?: string | null, +): ModelBehaviorOption[] => { + const variantKeys = sortVariantKeys(getVariantKeys(model)); + if (!variantKeys.length) return []; + return [ + defaultBehaviorOption(), + ...variantKeys.map((key) => { + const label = getVariantLabel(providerID, key, providerName); + return { + value: key, + label, + description: getVariantDescription(providerID, key, label, providerName), + }; + }), + ]; +}; + +export const sanitizeModelBehaviorValue = ( + providerID: string, + model: ProviderModel, + value: string | null, + providerName?: string | null, +) => { + const normalized = normalizeModelBehaviorValue(value); + if (!normalized) return null; + return getModelBehaviorOptions(providerID, model, providerName).some((option) => option.value === normalized) + ? normalized + : null; +}; + +export const getModelBehaviorSummary = ( + providerID: string, + model: ProviderModel, + value: string | null, + providerName?: string | null, +) => { + const options = getModelBehaviorOptions(providerID, model, providerName); + const sanitized = sanitizeModelBehaviorValue(providerID, model, value, providerName); + const selected = options.find((option) => option.value === sanitized) ?? options[0] ?? null; + const title = getBehaviorTitle(providerID, model, getVariantKeys(model), providerName); + + if (options.length > 0) { + return { + title, + label: selected?.label ?? defaultBehaviorOption().label, + description: selected?.description ?? defaultBehaviorOption().description, + options, + }; + } + + if (model.capabilities?.reasoning) { + return { + title, + label: t("model_behavior.label_builtin"), + description: t("model_behavior.desc_builtin"), + options, + }; + } + + return { + title, + label: t("model_behavior.label_standard"), + description: t("model_behavior.desc_standard"), + options, + }; +}; diff --git a/apps/app/src/app/lib/opencode-session.ts b/apps/app/src/app/lib/opencode-session.ts new file mode 100644 index 0000000000..713638e8fe --- /dev/null +++ b/apps/app/src/app/lib/opencode-session.ts @@ -0,0 +1,161 @@ +/** + * Typed helpers for OpenCode session operations. + * + * The OpenCode SDK (v2) exposes `session.abort`, `session.revert`, + * `session.unrevert`, `session.shell`, and `command.list` as typed methods. + * This module provides thin wrappers that avoid `as any` casts by using the + * SDK types directly, and adds feature-detection for newer API surface + * (e.g. `shellAsync`) that may not be present in older SDK versions. + */ +import type { Session } from "@opencode-ai/sdk/v2/client"; +import type { Client, ModelRef } from "../types"; +import { unwrap } from "./opencode"; + +// --------------------------------------------------------------------------- +// Session helpers +// --------------------------------------------------------------------------- + +/** + * Abort an active session. Silently succeeds if the session is already idle. + */ +export async function abortSession(client: Client, sessionID: string): Promise { + unwrap(await client.session.abort({ sessionID })); +} + +/** + * Abort an active session, swallowing errors (useful before revert/undo). + */ +export async function abortSessionSafe(client: Client, sessionID: string): Promise { + try { + await client.session.abort({ sessionID }); + } catch { + // intentional: abort may fail if session is already idle + } +} + +/** + * Revert a session to a specific message boundary. + */ +export async function revertSession( + client: Client, + sessionID: string, + messageID: string, +): Promise { + return unwrap(await client.session.revert({ sessionID, messageID })) as Session; +} + +/** + * Restore all previously reverted messages in a session. + */ +export async function unrevertSession( + client: Client, + sessionID: string, +): Promise { + return unwrap(await client.session.unrevert({ sessionID })) as Session; +} + +/** + * Compact/summarize a long session to reduce context size. + * Uses `session.summarize` when available and falls back to `/compact` command. + */ +export async function compactSession( + client: Client, + sessionID: string, + model: ModelRef, + options?: { directory?: string }, +): Promise { + const session = client.session as { summarize?: (input: { + sessionID: string; + directory?: string; + providerID: string; + modelID: string; + }) => Promise }; + + if (typeof session.summarize === "function") { + const result = await session.summarize({ + sessionID, + directory: options?.directory, + providerID: model.providerID, + modelID: model.modelID, + }); + assertNoClientError(result); + return; + } + + const modelString = `${model.providerID}/${model.modelID}`; + const result = await client.session.command({ + sessionID, + command: "compact", + arguments: "", + model: modelString, + directory: options?.directory, + }); + assertNoClientError(result); +} + +// --------------------------------------------------------------------------- +// Shell execution +// --------------------------------------------------------------------------- + +/** + * Execute a shell command in a session. Uses `shell` from the SDK. + * Falls back to `promptAsync` with a `!` prefix if `shell` is unavailable. + */ +export async function shellInSession( + client: Client, + sessionID: string, + command: string, + options?: { model?: { providerID: string; modelID: string }; agent?: string; variant?: string }, +): Promise { + const result = await client.session.shell({ sessionID, command }); + assertNoClientError(result); +} + +// --------------------------------------------------------------------------- +// Command listing +// --------------------------------------------------------------------------- + +export type CommandListItem = { + id: string; + name: string; + description?: string; + source?: "command" | "mcp" | "skill"; +}; + +/** + * List available slash commands for a workspace. + */ +export async function listCommands( + client: Client, + directory?: string, +): Promise { + try { + const result = await client.command.list({ directory }); + const list = result?.data ?? []; + if (!Array.isArray(list)) return []; + return list.map((cmd: Record) => ({ + id: `cmd:${cmd.name}`, + name: String(cmd.name ?? ""), + description: cmd.description ? String(cmd.description) : undefined, + source: cmd.source as CommandListItem["source"], + })); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Internal +// --------------------------------------------------------------------------- + +function assertNoClientError(result: unknown): void { + const maybe = result as { error?: unknown } | null | undefined; + if (!maybe || maybe.error === undefined) return; + const message = + maybe.error instanceof Error + ? maybe.error.message + : typeof maybe.error === "string" + ? maybe.error + : JSON.stringify(maybe.error); + throw new Error(message || "Unknown error"); +} diff --git a/apps/app/src/app/lib/opencode.ts b/apps/app/src/app/lib/opencode.ts new file mode 100644 index 0000000000..48ea7a531c --- /dev/null +++ b/apps/app/src/app/lib/opencode.ts @@ -0,0 +1,507 @@ +import { createOpencodeClient, type Message, type Part, type Session, type Todo } from "@opencode-ai/sdk/v2/client"; + +import { desktopFetch } from "./desktop"; +import { createOpenworkServerClient, OpenworkServerError } from "./openwork-server"; +import { isDesktopRuntime } from "../utils"; + +type FieldsResult = + | ({ data: T; error?: undefined } & { request: Request; response: Response }) + | ({ data?: undefined; error: unknown } & { request: Request; response: Response }); + +type PromptAsyncParameters = { + sessionID: string; + directory?: string; + messageID?: string; + model?: { providerID: string; modelID: string }; + agent?: string; + noReply?: boolean; + tools?: { [key: string]: boolean }; + system?: string; + variant?: string; + parts?: unknown[]; + reasoning_effort?: string; +}; + +type CommandParameters = { + sessionID: string; + directory?: string; + messageID?: string; + agent?: string; + model?: string; + arguments?: string; + command?: string; + variant?: string; + parts?: unknown[]; + reasoning_effort?: string; +}; + +type SessionListParameters = { + directory?: string; + roots?: boolean; + start?: number; + search?: string; + limit?: number; +}; + +type SessionLookupParameters = { + sessionID: string; + directory?: string; +}; + +type SessionMessagesParameters = { + sessionID: string; + directory?: string; + limit?: number; +}; + +export type OpencodeAuth = { + username?: string; + password?: string; + token?: string; + mode?: "basic" | "openwork"; +}; + +const DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS = 10_000; +const OAUTH_OPENCODE_REQUEST_TIMEOUT_MS = 5 * 60_000; +const MCP_AUTH_OPENCODE_REQUEST_TIMEOUT_MS = 90_000; +const SESSION_COMMAND_URL_RE = /\/session\/[^/?#]+\/command(?:[?#]|$)/; + +function getRequestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (typeof Request !== "undefined" && input instanceof Request) return input.url; + return String(input); +} + +function resolveRequestTimeoutMs(input: RequestInfo | URL, fallbackMs: number): number { + const url = getRequestUrl(input); + if (SESSION_COMMAND_URL_RE.test(url)) { + return 0; + } + if (/\/provider\/oauth\//.test(url) || /\/mcp\/auth\/callback\b/.test(url)) { + return Math.max(fallbackMs, OAUTH_OPENCODE_REQUEST_TIMEOUT_MS); + } + if (/\/mcp\/.*auth\b/.test(url)) { + return Math.max(fallbackMs, MCP_AUTH_OPENCODE_REQUEST_TIMEOUT_MS); + } + return fallbackMs; +} + + +function buildDirectoryHeader(directory?: string) { + if (!directory?.trim()) return undefined; + const trimmed = directory.trim(); + return /[^\x00-\x7F]/.test(trimmed) ? encodeURIComponent(trimmed) : trimmed; +} + +async function postSessionRequest( + fetchImpl: typeof globalThis.fetch, + baseUrl: string, + path: string, + body: Record, + options?: { headers?: Record; directory?: string; throwOnError?: boolean }, +): Promise> { + const headers = new Headers(options?.headers); + headers.set("Content-Type", "application/json"); + const directoryHeader = buildDirectoryHeader(options?.directory); + if (directoryHeader) { + headers.set("x-opencode-directory", directoryHeader); + } + + const response = await fetchImpl(`${baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const request = new Request(`${baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (response.ok) { + const data = response.status === 204 ? ({} as T) : ((await response.json()) as T); + return { data, request, response }; + } + + const text = await response.text(); + let error: unknown = text; + try { + error = text ? JSON.parse(text) : text; + } catch { + // ignore + } + if (options?.throwOnError) throw error; + return { error, request, response }; +} + +function resolveOpenworkWorkspaceMount(baseUrl: string): { baseUrl: string; workspaceId: string } | null { + try { + const url = new URL(baseUrl); + const match = url.pathname.replace(/\/+$/, "").match(/^(.*\/w\/([^/]+))\/opencode$/); + if (!match?.[1] || !match[2]) return null; + url.pathname = match[1]; + url.search = ""; + return { + baseUrl: url.toString().replace(/\/+$/, ""), + workspaceId: decodeURIComponent(match[2]), + }; + } catch { + return null; + } +} + +function createSyntheticResult( + url: string, + method: string, + input: + | { ok: true; data: T; status?: number } + | { ok: false; error: unknown; status?: number }, +): FieldsResult { + const request = new Request(url, { method }); + const response = new Response(input.ok ? JSON.stringify(input.data) : null, { + status: input.status ?? (input.ok ? 200 : 500), + headers: { "Content-Type": "application/json" }, + }); + if (input.ok) { + return { data: input.data, request, response }; + } + return { error: input.error, request, response }; +} + +async function wrapOpenworkRead( + url: string, + read: () => Promise, + options?: { throwOnError?: boolean }, +): Promise> { + try { + return createSyntheticResult(url, "GET", { ok: true, data: await read() }); + } catch (error) { + if (options?.throwOnError) throw error; + return createSyntheticResult(url, "GET", { + ok: false, + error, + status: error instanceof OpenworkServerError ? error.status : 500, + }); + } +} + +function shouldFallbackToLegacySessionRead(error: unknown): boolean { + if (!(error instanceof OpenworkServerError)) return false; + return error.status === 404 || error.status === 405 || error.status === 501; +} + +async function wrapOpenworkReadWithFallback( + url: string, + read: () => Promise, + fallback: () => Promise>, + options?: { throwOnError?: boolean }, +): Promise> { + try { + return createSyntheticResult(url, "GET", { ok: true, data: await read() }); + } catch (error) { + if (!shouldFallbackToLegacySessionRead(error)) { + if (options?.throwOnError) throw error; + return createSyntheticResult(url, "GET", { + ok: false, + error, + status: error instanceof OpenworkServerError ? error.status : 500, + }); + } + return fallback(); + } +} + +async function fetchWithTimeout( + fetchImpl: typeof globalThis.fetch, + input: RequestInfo | URL, + init: RequestInit | undefined, + timeoutMs: number, +) { + const effectiveTimeoutMs = resolveRequestTimeoutMs(input, timeoutMs); + if (!Number.isFinite(effectiveTimeoutMs) || effectiveTimeoutMs <= 0) { + return fetchImpl(input, init); + } + + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const signal = controller?.signal; + const initWithSignal = signal && !init?.signal ? { ...(init ?? {}), signal } : init; + + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + try { + controller?.abort(); + } catch { + // ignore + } + reject(new Error("Request timed out.")); + }, effectiveTimeoutMs); + }); + + try { + return await Promise.race([fetchImpl(input, initWithSignal), timeoutPromise]); + } catch (error) { + const name = (error && typeof error === "object" && "name" in error ? (error as any).name : "") as string; + if (name === "AbortError") { + throw new Error("Request timed out."); + } + throw error; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +const encodeBasicAuth = (auth?: OpencodeAuth) => { + if (!auth?.username || !auth?.password) return null; + const token = `${auth.username}:${auth.password}`; + if (typeof btoa === "function") return btoa(token); + const buffer = (globalThis as { Buffer?: { from: (input: string, encoding: string) => { toString: (encoding: string) => string } } }) + .Buffer; + return buffer ? buffer.from(token, "utf8").toString("base64") : null; +}; + +const resolveAuthHeader = (auth?: OpencodeAuth) => { + if (auth?.mode === "openwork" && auth.token) { + return `Bearer ${auth.token}`; + } + const encoded = encodeBasicAuth(auth); + 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 createDesktopFetch = (auth?: OpencodeAuth) => { + const authHeader = resolveAuthHeader(auth); + const addAuth = (headers: Headers) => { + if (!authHeader || headers.has("Authorization")) return; + headers.set("Authorization", authHeader); + }; + + 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() + : desktopFetch; + // 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(underlyingFetch, request, undefined, timeoutMs); + } + + const headers = new Headers(init?.headers); + addAuth(headers); + return fetchWithTimeout( + underlyingFetch, + input, + { + ...init, + headers, + }, + timeoutMs, + ); + }; +}; + +export function unwrap(result: FieldsResult): NonNullable { + if (result.data !== undefined) { + return result.data as NonNullable; + } + const message = + result.error instanceof Error + ? result.error.message + : typeof result.error === "string" + ? result.error + : JSON.stringify(result.error); + throw new Error(message || "Unknown error"); +} + +export function createClient(baseUrl: string, directory?: string, auth?: OpencodeAuth) { + const headers: Record = {}; + if (!isDesktopRuntime()) { + const authHeader = resolveAuthHeader(auth); + if (authHeader) { + headers.Authorization = authHeader; + } + } + + const fetchImpl = isDesktopRuntime() + ? createDesktopFetch(auth) + : (input: RequestInfo | URL, init?: RequestInit) => + fetchWithTimeout(globalThis.fetch, input, init, DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS); + const client = createOpencodeClient({ + baseUrl, + directory, + headers: Object.keys(headers).length ? headers : undefined, + fetch: fetchImpl, + }); + + const session = client.session as typeof client.session; + const openworkMount = auth?.mode === "openwork" ? resolveOpenworkWorkspaceMount(baseUrl) : null; + const openworkSessionClient = + openworkMount && auth?.token + ? createOpenworkServerClient({ baseUrl: openworkMount.baseUrl, token: auth.token }) + : null; + // TODO(2026-04-12): remove the old-server compatibility path here once all + // OpenWork servers expose the workspace-scoped session read APIs. + const sessionOverrides = session as any as { + list: (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => Promise>; + get: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise>; + messages: (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => Promise>>; + todo: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise>; + promptAsync: (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => Promise>; + command: (parameters: CommandParameters, options?: { throwOnError?: boolean }) => Promise>; + }; + + const listOriginal = sessionOverrides.list.bind(session); + sessionOverrides.list = (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return listOriginal(parameters, options); + } + const query = new URLSearchParams(); + if (typeof parameters?.roots === "boolean") query.set("roots", String(parameters.roots)); + if (typeof parameters?.start === "number") query.set("start", String(parameters.start)); + if (parameters?.search?.trim()) query.set("search", parameters.search.trim()); + if (typeof parameters?.limit === "number") query.set("limit", String(parameters.limit)); + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions${query.size ? `?${query.toString()}` : ""}`; + return wrapOpenworkReadWithFallback( + url, + async () => (await openworkSessionClient.listSessions(openworkMount.workspaceId, parameters)).items, + () => listOriginal(parameters, options), + options, + ); + }; + + const getOriginal = sessionOverrides.get.bind(session); + sessionOverrides.get = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return getOriginal(parameters, options); + } + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}`; + return wrapOpenworkReadWithFallback( + url, + async () => (await openworkSessionClient.getSession(openworkMount.workspaceId, parameters.sessionID)).item, + () => getOriginal(parameters, options), + options, + ); + }; + + const messagesOriginal = sessionOverrides.messages.bind(session); + sessionOverrides.messages = (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return messagesOriginal(parameters, options); + } + const query = new URLSearchParams(); + if (typeof parameters.limit === "number") query.set("limit", String(parameters.limit)); + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/messages${query.size ? `?${query.toString()}` : ""}`; + return wrapOpenworkReadWithFallback( + url, + async () => + (await openworkSessionClient.getSessionMessages(openworkMount.workspaceId, parameters.sessionID, { + limit: parameters.limit, + })).items, + () => messagesOriginal(parameters, options), + options, + ); + }; + + const todoOriginal = sessionOverrides.todo.bind(session); + sessionOverrides.todo = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return todoOriginal(parameters, options); + } + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/snapshot`; + return wrapOpenworkReadWithFallback( + url, + async () => (await openworkSessionClient.getSessionSnapshot(openworkMount.workspaceId, parameters.sessionID)).item.todos, + () => todoOriginal(parameters, options), + options, + ); + }; + + const promptAsyncOriginal = sessionOverrides.promptAsync.bind(session); + sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => { + if (!("reasoning_effort" in parameters)) { + return promptAsyncOriginal(parameters, options); + } + const { sessionID, directory: requestDirectory, ...body } = parameters; + return postSessionRequest(fetchImpl, baseUrl, `/session/${encodeURIComponent(sessionID)}/prompt_async`, body, { + headers: Object.keys(headers).length ? headers : undefined, + directory: requestDirectory ?? directory, + throwOnError: options?.throwOnError, + }); + }; + + const commandOriginal = sessionOverrides.command.bind(session); + sessionOverrides.command = (parameters: CommandParameters, options?: { throwOnError?: boolean }) => { + if (!("reasoning_effort" in parameters)) { + return commandOriginal(parameters, options); + } + const { sessionID, directory: requestDirectory, ...body } = parameters; + return postSessionRequest(fetchImpl, baseUrl, `/session/${encodeURIComponent(sessionID)}/command`, body, { + headers: Object.keys(headers).length ? headers : undefined, + directory: requestDirectory ?? directory, + throwOnError: options?.throwOnError, + }); + }; + + return client; +} + +export async function waitForHealthy( + client: ReturnType, + options?: { timeoutMs?: number; pollMs?: number }, +) { + const timeoutMs = options?.timeoutMs ?? 10_000; + const pollMs = options?.pollMs ?? 250; + + const start = Date.now(); + let lastError: string | null = null; + + while (Date.now() - start < timeoutMs) { + try { + const health = unwrap(await client.global.health()); + if (health.healthy) { + return health; + } + lastError = "Server reported unhealthy"; + } catch (error) { + lastError = error instanceof Error ? error.message : "Unknown error"; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + + throw new Error(lastError ?? "Timed out waiting for server health"); +} diff --git a/apps/app/src/app/lib/openwork-deployment.ts b/apps/app/src/app/lib/openwork-deployment.ts new file mode 100644 index 0000000000..77dd4bc742 --- /dev/null +++ b/apps/app/src/app/lib/openwork-deployment.ts @@ -0,0 +1,25 @@ +export const OPENWORK_DEPLOYMENT_ENV_VAR = "VITE_OPENWORK_DEPLOYMENT"; + +export type OpenWorkDeployment = "desktop" | "web"; + +function normalizeDeployment(value: string | undefined): OpenWorkDeployment { + const normalized = value?.trim().toLowerCase(); + return normalized === "web" ? "web" : "desktop"; +} + +export function getOpenWorkDeployment(): OpenWorkDeployment { + const envValue = + typeof import.meta !== "undefined" && typeof import.meta.env?.VITE_OPENWORK_DEPLOYMENT === "string" + ? import.meta.env.VITE_OPENWORK_DEPLOYMENT + : undefined; + + return normalizeDeployment(envValue); +} + +export function isWebDeployment(): boolean { + return getOpenWorkDeployment() === "web"; +} + +export function isDesktopDeployment(): boolean { + return getOpenWorkDeployment() === "desktop"; +} diff --git a/apps/app/src/app/lib/openwork-env-runtime.ts b/apps/app/src/app/lib/openwork-env-runtime.ts new file mode 100644 index 0000000000..9999b9629f --- /dev/null +++ b/apps/app/src/app/lib/openwork-env-runtime.ts @@ -0,0 +1,88 @@ +const PENDING_CHANGES_KEY = "openwork.settings.environment.pendingChanges"; + +type PendingChangesState = { + pending: boolean; + runtimeKey?: string; +}; + +function getStorage(kind: "localStorage" | "sessionStorage"): Storage | null { + if (typeof window === "undefined") return null; + try { + return window[kind] ?? null; + } catch { + return null; + } +} + +function parsePendingChangesState(raw: string | null): PendingChangesState { + if (!raw) return { pending: false }; + if (raw === "1") return { pending: true }; + try { + const parsed = JSON.parse(raw) as { pending?: unknown; runtimeKey?: unknown }; + return { + pending: parsed.pending === true, + runtimeKey: typeof parsed.runtimeKey === "string" && parsed.runtimeKey.trim() + ? parsed.runtimeKey.trim() + : undefined, + }; + } catch { + return { pending: false }; + } +} + +export function buildOpenworkEnvRuntimeKey(input: { + baseUrl?: string | null; + pid?: number | null; + port?: number | null; +}): string | undefined { + const baseUrl = (input.baseUrl?.trim() ?? "").replace(/\/+$/, ""); + const pid = typeof input.pid === "number" && Number.isFinite(input.pid) && input.pid > 0 + ? `pid:${input.pid}` + : ""; + const port = !pid && typeof input.port === "number" && Number.isFinite(input.port) && input.port > 0 + ? `port:${input.port}` + : ""; + const runtime = pid || port; + if (!baseUrl && !runtime) return undefined; + return `${baseUrl || "openwork"}::${runtime || "runtime"}`; +} + +export function readOpenworkEnvPendingChanges(runtimeKey?: string | null): boolean { + const localStorage = getStorage("localStorage"); + const sessionStorage = getStorage("sessionStorage"); + const state = parsePendingChangesState(localStorage?.getItem(PENDING_CHANGES_KEY) ?? null); + const legacySessionState = parsePendingChangesState( + sessionStorage?.getItem(PENDING_CHANGES_KEY) ?? null, + ); + const pending = state.pending ? state : legacySessionState; + if (!pending.pending) return false; + + const currentRuntimeKey = runtimeKey?.trim() || undefined; + if (currentRuntimeKey && pending.runtimeKey && pending.runtimeKey !== currentRuntimeKey) { + writeOpenworkEnvPendingChanges(false); + return false; + } + + return true; +} + +export function writeOpenworkEnvPendingChanges(value: boolean, runtimeKey?: string | null): void { + const localStorage = getStorage("localStorage"); + const sessionStorage = getStorage("sessionStorage"); + try { + if (value) { + const payload = { + pending: true, + changedAt: Date.now(), + ...(runtimeKey?.trim() ? { runtimeKey: runtimeKey.trim() } : {}), + }; + localStorage?.setItem(PENDING_CHANGES_KEY, JSON.stringify(payload)); + sessionStorage?.removeItem(PENDING_CHANGES_KEY); + } else { + localStorage?.removeItem(PENDING_CHANGES_KEY); + sessionStorage?.removeItem(PENDING_CHANGES_KEY); + } + } catch { + // ignore persistence failures + } +} diff --git a/apps/app/src/app/lib/openwork-links.ts b/apps/app/src/app/lib/openwork-links.ts new file mode 100644 index 0000000000..b13ad8fca3 --- /dev/null +++ b/apps/app/src/app/lib/openwork-links.ts @@ -0,0 +1,206 @@ +import { DEFAULT_DEN_BASE_URL, normalizeDenBaseUrl } from "./den"; +import { normalizeOpenworkServerUrl } from "./openwork-server"; +import { normalizeBundleImportIntent, parseBundleDeepLink } from "../bundles/sources"; +import type { BundleRequest } from "../bundles/types"; + +export type RemoteWorkspaceDefaults = { + openworkHostUrl?: string | null; + openworkToken?: string | null; + directory?: string | null; + displayName?: string | null; + autoConnect?: boolean; +}; + +export type DenAuthDeepLink = { + grant: string; + denBaseUrl: string; +}; + +function isSupportedDeepLinkProtocol(protocol: string): boolean { + const normalized = protocol.toLowerCase(); + return normalized === "openwork:" || normalized === "openwork-dev:" || normalized === "https:" || normalized === "http:"; +} + +export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefaults | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const protocol = url.protocol.toLowerCase(); + if (!isSupportedDeepLinkProtocol(protocol)) { + return null; + } + + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + if (routeHost !== "connect-remote" && routePath !== "connect-remote" && routeTail !== "connect-remote") { + return null; + } + + const hostUrlRaw = url.searchParams.get("openworkHostUrl") ?? url.searchParams.get("openworkUrl") ?? ""; + const tokenRaw = url.searchParams.get("openworkToken") ?? url.searchParams.get("accessToken") ?? ""; + const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); + const token = tokenRaw.trim(); + if (!normalizedHostUrl || !token) { + return null; + } + + const workerName = url.searchParams.get("workerName")?.trim() ?? ""; + const workerId = url.searchParams.get("workerId")?.trim() ?? ""; + const displayName = workerName || (workerId ? `Worker ${workerId.slice(0, 8)}` : ""); + const autoConnectRaw = + url.searchParams.get("autoConnect") ?? + url.searchParams.get("bypassModal") ?? + url.searchParams.get("bypassAddWorkerModal") ?? + ""; + const autoConnect = ["1", "true", "yes", "on"].includes(autoConnectRaw.trim().toLowerCase()); + + return { + openworkHostUrl: normalizedHostUrl, + openworkToken: token, + directory: null, + displayName: displayName || null, + autoConnect, + }; +} + +export function stripRemoteConnectQuery(rawUrl: string): string | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + let changed = false; + for (const key of [ + "openworkHostUrl", + "openworkUrl", + "openworkToken", + "accessToken", + "workerId", + "workerName", + "autoConnect", + "bypassModal", + "bypassAddWorkerModal", + "source", + ]) { + if (url.searchParams.has(key)) { + url.searchParams.delete(key); + changed = true; + } + } + + if (!changed) { + return null; + } + + const search = url.searchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; +} + +export function parseDenAuthDeepLink(rawUrl: string): DenAuthDeepLink | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const protocol = url.protocol.toLowerCase(); + if (!isSupportedDeepLinkProtocol(protocol)) { + return null; + } + + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + if (routeHost !== "den-auth" && routePath !== "den-auth" && routeTail !== "den-auth") { + return null; + } + + const grant = url.searchParams.get("grant")?.trim() ?? ""; + const denBaseUrl = normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? "") ?? DEFAULT_DEN_BASE_URL; + if (!grant) { + return null; + } + + return { grant, denBaseUrl }; +} + +function normalizeDebugDeepLinkInput(rawValue: string): string { + const trimmed = rawValue.trim(); + if (!trimmed) return ""; + + const directMatch = trimmed.match(/(?:openwork-dev|openwork|https?):\/\/[^\s"'<>]+/i); + if (directMatch) return directMatch[0]; + + const bareShareMatch = trimmed.match(/share\.openwork(?:labs\.com|\.software)\/b\/[^\s"'<>]+/i); + if (bareShareMatch) return `https://${bareShareMatch[0]}`; + + return trimmed; +} + +export function parseDebugDeepLinkInput(rawValue: string): + | { kind: "bundle"; link: BundleRequest } + | { kind: "remote"; link: RemoteWorkspaceDefaults } + | { kind: "auth"; link: DenAuthDeepLink } + | null { + const normalized = normalizeDebugDeepLinkInput(rawValue); + if (!normalized) return null; + + const denAuthLink = parseDenAuthDeepLink(normalized); + if (denAuthLink) { + return { kind: "auth", link: denAuthLink }; + } + + const bundleLink = parseBundleDeepLink(normalized); + if (bundleLink) { + return { kind: "bundle", link: bundleLink }; + } + + const remoteConnectLink = parseRemoteConnectDeepLink(normalized); + if (remoteConnectLink) { + return { kind: "remote", link: remoteConnectLink }; + } + + const bundleMatch = normalized.match(/ow_bundle=([^&\s]+)/i); + if (bundleMatch?.[1]) { + try { + const bundleUrl = decodeURIComponent(bundleMatch[1]); + const intentMatch = normalized.match(/(?:ow_intent|intent)=([^&\s]+)/i); + const labelMatch = normalized.match(/ow_label=([^&\s]+)/i); + const sourceMatch = normalized.match(/(?:ow_source|source)=([^&\s]+)/i); + return { + kind: "bundle", + link: { + bundleUrl, + intent: normalizeBundleImportIntent(intentMatch?.[1] ? decodeURIComponent(intentMatch[1]) : undefined), + label: labelMatch?.[1] ? decodeURIComponent(labelMatch[1]) : undefined, + source: sourceMatch?.[1] ? decodeURIComponent(sourceMatch[1]) : undefined, + }, + }; + } catch { + // ignore fallback parsing errors + } + } + + const shareIdMatch = normalized.match(/share\.openwork(?:labs\.com|\.software)\/b\/([^\s/?#"'<>]+)/i); + if (shareIdMatch?.[1]) { + return { + kind: "bundle", + link: { + bundleUrl: `https://share.openworklabs.com/b/${shareIdMatch[1]}`, + intent: "new_worker", + }, + }; + } + + return null; +} diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts new file mode 100644 index 0000000000..7b7eb19630 --- /dev/null +++ b/apps/app/src/app/lib/openwork-server.ts @@ -0,0 +1,1233 @@ +import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client"; +import { desktopFetch } from "./desktop"; +import { isDesktopRuntime } from "../utils"; +import type { ExecResult, OpencodeConfigFile, WorkspaceInfo, WorkspaceList } from "./desktop"; + +export type OpenworkServerCapabilities = { + skills: { read: boolean; write: boolean; source: "openwork" | "opencode" }; + hub?: { + skills?: { + read: boolean; + install: boolean; + repo?: { owner: string; name: string; ref: string }; + }; + }; + plugins: { read: boolean; write: boolean }; + mcp: { read: boolean; write: boolean }; + commands: { read: boolean; write: boolean }; + config: { read: boolean; write: boolean }; + sandbox?: { enabled: boolean; backend: "none" | "docker" | "container" }; + proxy?: { opencode: boolean }; + toolProviders?: { + browser?: { + enabled: boolean; + placement: "in-sandbox" | "host-machine" | "client-machine" | "external"; + mode: "none" | "headless" | "interactive"; + }; + files?: { + injection: boolean; + outbox: boolean; + inboxPath: string; + outboxPath: string; + maxBytes: number; + }; + }; +}; + +export type OpenworkServerStatus = "connected" | "disconnected" | "limited"; + +export type OpenworkServerDiagnostics = { + ok: boolean; + version: string; + uptimeMs: number; + readOnly: boolean; + approval: { mode: "manual" | "auto"; timeoutMs: number }; + corsOrigins: string[]; + workspaceCount: number; + activeWorkspaceId?: string | null; + selectedWorkspaceId?: string | null; + workspace: OpenworkWorkspaceInfo | null; + authorizedRoots: string[]; + server: { host: string; port: number; configPath?: string | null }; + tokenSource: { client: string; host: string }; +}; + +export type OpenworkRuntimeServiceName = "openwork-server" | "opencode"; + +export type OpenworkRuntimeServiceSnapshot = { + name: OpenworkRuntimeServiceName; + enabled: boolean; + running: boolean; + targetVersion: string | null; + actualVersion: string | null; + upgradeAvailable: boolean; +}; + +export type OpenworkRuntimeSnapshot = { + ok: boolean; + orchestrator?: { + version: string; + startedAt: number; + }; + worker?: { + workspace: string; + sandboxMode: string; + }; + upgrade?: { + status: "idle" | "running" | "failed"; + startedAt: number | null; + finishedAt: number | null; + error: string | null; + operationId: string | null; + services: OpenworkRuntimeServiceName[]; + }; + services: OpenworkRuntimeServiceSnapshot[]; +}; + +export type OpenworkServerSettings = { + urlOverride?: string; + portOverride?: number; + token?: string; + hostToken?: string; + remoteAccessEnabled?: boolean; +}; + +export type OpenworkWorkspaceInfo = WorkspaceInfo & { + opencode?: { + baseUrl?: string; + directory?: string; + username?: string; + password?: string; + }; +}; + +export type OpenworkWorkspaceList = { + items: OpenworkWorkspaceInfo[]; + workspaces?: WorkspaceInfo[]; + activeId?: string | null; +}; + +export type OpenworkSessionMessage = { + info: Message; + parts: Part[]; +}; + +export type OpenworkSessionSnapshot = { + session: Session; + messages: OpenworkSessionMessage[]; + todos: Todo[]; + status: + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number }; +}; + +export type OpenworkPluginItem = { + spec: string; + source: "config" | "dir.project" | "dir.global"; + scope: "project" | "global"; + path?: string; +}; + +export type OpenworkSkillItem = { + name: string; + path: string; + description: string; + scope: "project" | "global"; + trigger?: string; +}; + +export type OpenworkSkillContent = { + item: OpenworkSkillItem; + content: string; +}; + +export type OpenworkHubSkillItem = { + name: string; + description: string; + trigger?: string; + source: { + owner: string; + repo: string; + ref: string; + path: string; + }; +}; + +export type OpenworkHubRepo = { + owner?: string; + repo?: string; + ref?: string; +}; + +export type OpenworkWorkspaceFileContent = { + path: string; + content: string; + bytes: number; + updatedAt: number; +}; + +export type OpenworkWorkspaceFileWriteResult = { + ok: boolean; + path: string; + bytes: number; + updatedAt: number; + revision?: string; +}; + +export type OpenworkCommandItem = { + name: string; + description?: string; + template: string; + agent?: string; + model?: string | null; + subtask?: boolean; + scope: "workspace" | "global"; +}; + +export type OpenworkMcpItem = { + name: string; + config: Record; + source: "config.project" | "config.global" | "config.remote"; + disabledByTools?: boolean; +}; + +export type OpenworkWorkspaceExport = { + workspaceId: string; + exportedAt: number; + opencode?: Record; + openwork?: Record; + skills?: Array<{ name: string; description?: string; trigger?: string; content: string }>; + commands?: Array<{ name: string; description?: string; template?: string }>; + files?: Array<{ path: string; content: string }>; +}; + +export type OpenworkWorkspaceExportSensitiveMode = "auto" | "include" | "exclude"; + +export type OpenworkWorkspaceExportWarning = { + id: string; + label: string; + detail: string; +}; + +export type OpenworkBlueprintSessionsMaterializeResult = { + ok: boolean; + created: Array<{ templateId: string; sessionId: string; title: string }>; + existing: Array<{ templateId: string; sessionId: string }>; + openSessionId: string | null; +}; + +export type OpenworkArtifactItem = { + id: string; + name?: string; + path?: string; + size?: number; + createdAt?: number; + updatedAt?: number; + mime?: string; +}; + +export type OpenworkArtifactList = { + items: OpenworkArtifactItem[]; +}; + +export type OpenworkInboxItem = { + id: string; + name?: string; + path?: string; + size?: number; + updatedAt?: number; +}; + +export type OpenworkInboxList = { + items: OpenworkInboxItem[]; +}; + +export type OpenworkInboxUploadResult = { + ok: boolean; + path: string; + bytes: number; +}; + +export type OpenworkActor = { + type: "remote" | "host"; + clientId?: string; + tokenHash?: string; +}; + +export type OpenworkAuditEntry = { + id: string; + workspaceId: string; + actor: OpenworkActor; + action: string; + target: string; + summary: string; + timestamp: number; +}; + +export type OpenworkReloadTrigger = { + type: "skill" | "plugin" | "config" | "mcp" | "agent" | "command"; + name?: string; + action?: "added" | "removed" | "updated"; + path?: string; +}; + +export type OpenworkReloadEvent = { + id: string; + seq: number; + workspaceId: string; + reason: "plugins" | "skills" | "mcp" | "config" | "agents" | "commands"; + trigger?: OpenworkReloadTrigger; + timestamp: number; +}; + +// Fallback for explicit server-mode URL derivation. Desktop local workers replace this +// with the persisted runtime-discovered port once the host reports it. +export const DEFAULT_OPENWORK_SERVER_PORT = 8787; + +const STORAGE_URL_OVERRIDE = "openwork.server.urlOverride"; +const STORAGE_PORT_OVERRIDE = "openwork.server.port"; +const STORAGE_TOKEN = "openwork.server.token"; +const STORAGE_HOST_TOKEN = "openwork.server.hostToken"; +const STORAGE_REMOTE_ACCESS = "openwork.server.remoteAccessEnabled"; + +export function normalizeOpenworkServerUrl(input: string) { + const trimmed = input.trim(); + if (!trimmed) return null; + const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`; + return withProtocol.replace(/\/+$/, ""); +} + +export function isLoopbackOpenworkServerUrl(input: string) { + const normalized = normalizeOpenworkServerUrl(input) ?? ""; + if (!normalized) return false; + try { + const hostname = new URL(normalized).hostname.toLowerCase(); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]"; + } catch { + return false; + } +} + +export function parseOpenworkWorkspaceIdFromUrl(input: string) { + const normalized = normalizeOpenworkServerUrl(input) ?? ""; + if (!normalized) return null; + + try { + const url = new URL(normalized); + const segments = url.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1] ?? ""; + const prev = segments[segments.length - 2] ?? ""; + if (prev !== "w" || !last) return null; + return decodeURIComponent(last); + } catch { + const match = normalized.match(/\/w\/([^/?#]+)/); + if (!match?.[1]) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } + } +} + +export function buildOpenworkWorkspaceBaseUrl(hostUrl: string, workspaceId?: string | null) { + const normalized = normalizeOpenworkServerUrl(hostUrl) ?? ""; + if (!normalized) return null; + + try { + const url = new URL(normalized); + 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) { + return url.toString().replace(/\/+$/, ""); + } + + const id = (workspaceId ?? "").trim(); + if (!id) return url.toString().replace(/\/+$/, ""); + + const basePath = url.pathname.replace(/\/+$/, ""); + url.pathname = `${basePath}/w/${encodeURIComponent(id)}`; + return url.toString().replace(/\/+$/, ""); + } catch { + const id = (workspaceId ?? "").trim(); + if (!id) return normalized; + return `${normalized.replace(/\/+$/, "")}/w/${encodeURIComponent(id)}`; + } +} + +const OPENWORK_INVITE_PARAM_URL = "ow_url"; +const OPENWORK_INVITE_PARAM_TOKEN = "ow_token"; +const OPENWORK_INVITE_PARAM_STARTUP = "ow_startup"; +const OPENWORK_INVITE_PARAM_AUTO_CONNECT = "ow_auto_connect"; + +export type OpenworkConnectInvite = { + url: string; + token?: string; + startup?: "server"; + autoConnect?: boolean; +}; + +export function readOpenworkConnectInviteFromSearch(input: string | URLSearchParams) { + const search = + typeof input === "string" + ? new URLSearchParams(input.startsWith("?") ? input.slice(1) : input) + : input; + + const rawUrl = search.get(OPENWORK_INVITE_PARAM_URL)?.trim() ?? ""; + const url = normalizeOpenworkServerUrl(rawUrl); + if (!url) return null; + + const token = search.get(OPENWORK_INVITE_PARAM_TOKEN)?.trim() ?? ""; + const startupRaw = search.get(OPENWORK_INVITE_PARAM_STARTUP)?.trim() ?? ""; + const startup = startupRaw === "server" ? "server" : undefined; + const autoConnect = search.get(OPENWORK_INVITE_PARAM_AUTO_CONNECT)?.trim() === "1"; + + return { + url, + token: token || undefined, + startup, + autoConnect: autoConnect || undefined, + } satisfies OpenworkConnectInvite; +} + +export function stripOpenworkConnectInviteFromUrl(input: string) { + try { + const url = new URL(input); + url.searchParams.delete(OPENWORK_INVITE_PARAM_URL); + url.searchParams.delete(OPENWORK_INVITE_PARAM_TOKEN); + url.searchParams.delete(OPENWORK_INVITE_PARAM_STARTUP); + url.searchParams.delete(OPENWORK_INVITE_PARAM_AUTO_CONNECT); + return url.toString(); + } catch { + return input; + } +} + +export function readOpenworkServerSettings(): OpenworkServerSettings { + if (typeof window === "undefined") return {}; + try { + const urlOverride = normalizeOpenworkServerUrl( + window.localStorage.getItem(STORAGE_URL_OVERRIDE) ?? "", + ); + const portRaw = window.localStorage.getItem(STORAGE_PORT_OVERRIDE) ?? ""; + const portOverride = portRaw ? Number(portRaw) : undefined; + const token = window.localStorage.getItem(STORAGE_TOKEN) ?? undefined; + const hostToken = window.localStorage.getItem(STORAGE_HOST_TOKEN) ?? undefined; + const remoteAccessRaw = window.localStorage.getItem(STORAGE_REMOTE_ACCESS) ?? ""; + return { + urlOverride: urlOverride ?? undefined, + portOverride: Number.isNaN(portOverride) ? undefined : portOverride, + token: token?.trim() || undefined, + hostToken: hostToken?.trim() || undefined, + remoteAccessEnabled: remoteAccessRaw === "1", + }; + } catch { + return {}; + } +} + +export function writeOpenworkServerSettings(next: OpenworkServerSettings): OpenworkServerSettings { + if (typeof window === "undefined") return next; + try { + const urlOverride = normalizeOpenworkServerUrl(next.urlOverride ?? ""); + const portOverride = typeof next.portOverride === "number" ? next.portOverride : undefined; + const token = next.token?.trim() || undefined; + const hostToken = next.hostToken?.trim() || undefined; + const remoteAccessEnabled = next.remoteAccessEnabled === true; + + if (urlOverride) { + window.localStorage.setItem(STORAGE_URL_OVERRIDE, urlOverride); + } else { + window.localStorage.removeItem(STORAGE_URL_OVERRIDE); + } + + if (typeof portOverride === "number" && !Number.isNaN(portOverride)) { + window.localStorage.setItem(STORAGE_PORT_OVERRIDE, String(portOverride)); + } else { + window.localStorage.removeItem(STORAGE_PORT_OVERRIDE); + } + + if (token) { + window.localStorage.setItem(STORAGE_TOKEN, token); + } else { + window.localStorage.removeItem(STORAGE_TOKEN); + } + + if (hostToken) { + window.localStorage.setItem(STORAGE_HOST_TOKEN, hostToken); + } else { + window.localStorage.removeItem(STORAGE_HOST_TOKEN); + } + + if (remoteAccessEnabled) { + window.localStorage.setItem(STORAGE_REMOTE_ACCESS, "1"); + } else { + window.localStorage.removeItem(STORAGE_REMOTE_ACCESS); + } + + return readOpenworkServerSettings(); + } catch { + return next; + } +} + +export function hydrateOpenworkServerSettingsFromEnv() { + if (typeof window === "undefined") return; + + const envUrl = typeof import.meta.env?.VITE_OPENWORK_URL === "string" + ? import.meta.env.VITE_OPENWORK_URL.trim() + : ""; + const envPort = typeof import.meta.env?.VITE_OPENWORK_PORT === "string" + ? import.meta.env.VITE_OPENWORK_PORT.trim() + : ""; + const envToken = typeof import.meta.env?.VITE_OPENWORK_TOKEN === "string" + ? import.meta.env.VITE_OPENWORK_TOKEN.trim() + : ""; + const envHostToken = typeof import.meta.env?.VITE_OPENWORK_HOST_TOKEN === "string" + ? import.meta.env.VITE_OPENWORK_HOST_TOKEN.trim() + : ""; + + if (!envUrl && !envPort && !envToken && !envHostToken) return; + + try { + const current = readOpenworkServerSettings(); + const next: OpenworkServerSettings = { ...current }; + let changed = false; + + if (!current.urlOverride && envUrl) { + next.urlOverride = normalizeOpenworkServerUrl(envUrl) ?? undefined; + changed = true; + } + + if (!current.portOverride && envPort) { + const parsed = Number(envPort); + if (Number.isFinite(parsed) && parsed > 0) { + next.portOverride = parsed; + changed = true; + } + } + + if (!current.token && envToken) { + next.token = envToken; + changed = true; + } + + if (!current.hostToken && envHostToken) { + next.hostToken = envHostToken; + changed = true; + } + + if (changed) { + writeOpenworkServerSettings(next); + } + } catch { + // ignore + } +} + +export function clearOpenworkServerSettings() { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(STORAGE_URL_OVERRIDE); + window.localStorage.removeItem(STORAGE_PORT_OVERRIDE); + window.localStorage.removeItem(STORAGE_TOKEN); + window.localStorage.removeItem(STORAGE_HOST_TOKEN); + window.localStorage.removeItem(STORAGE_REMOTE_ACCESS); + } catch { + // ignore + } +} + +export class OpenworkServerError extends Error { + status: number; + code: string; + details?: unknown; + + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +function buildHeaders( + token?: string, + hostToken?: string, + extra?: Record, +) { + const headers: Record = { "Content-Type": "application/json" }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + if (hostToken) { + headers["X-OpenWork-Host-Token"] = hostToken; + } + if (extra) { + Object.assign(headers, extra); + } + return headers; +} + +function buildAuthHeaders(token?: string, hostToken?: string, extra?: Record) { + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + if (hostToken) { + headers["X-OpenWork-Host-Token"] = hostToken; + } + if (extra) { + Object.assign(headers, extra); + } + return headers; +} + +// 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 (!isDesktopRuntime()) return globalThis.fetch; + if (url && isStreamUrl(url)) { + return typeof window !== "undefined" ? window.fetch.bind(window) : globalThis.fetch; + } + return desktopFetch; +}; + +const DEFAULT_OPENWORK_SERVER_TIMEOUT_MS = 10_000; + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +async function fetchWithTimeout( + fetchImpl: FetchLike, + url: string, + init: RequestInit, + timeoutMs: number, +) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return fetchImpl(url, init); + } + + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const signal = controller?.signal; + const initWithSignal = signal && !init.signal ? { ...init, signal } : init; + + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + try { + controller?.abort(); + } catch { + // ignore + } + reject(new Error("Request timed out.")); + }, timeoutMs); + }); + + try { + return await Promise.race([fetchImpl(url, initWithSignal), timeoutPromise]); + } catch (error) { + const name = (error && typeof error === "object" && "name" in error ? (error as any).name : "") as string; + if (name === "AbortError") { + throw new Error("Request timed out."); + } + throw error; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +async function requestJson( + baseUrl: string, + path: string, + options: { method?: string; token?: string; hostToken?: string; body?: unknown; timeoutMs?: number } = {}, +): Promise { + const url = `${baseUrl}${path}`; + const fetchImpl = resolveFetch(url); + const response = await fetchWithTimeout( + fetchImpl, + url, + { + method: options.method ?? "GET", + headers: buildHeaders(options.token, options.hostToken), + body: options.body ? JSON.stringify(options.body) : undefined, + }, + options.timeoutMs ?? DEFAULT_OPENWORK_SERVER_TIMEOUT_MS, + ); + + const text = await response.text(); + const json = text ? JSON.parse(text) : null; + + if (!response.ok) { + const code = typeof json?.code === "string" ? json.code : "request_failed"; + const message = typeof json?.message === "string" ? json.message : response.statusText; + throw new OpenworkServerError(response.status, code, message, json?.details); + } + + return json as T; +} + +async function requestMultipartRaw( + baseUrl: string, + path: string, + 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(url); + const response = await fetchWithTimeout( + fetchImpl, + url, + { + method: options.method ?? "POST", + headers: buildAuthHeaders(options.token, options.hostToken), + body: options.body, + }, + options.timeoutMs ?? DEFAULT_OPENWORK_SERVER_TIMEOUT_MS, + ); + const text = await response.text(); + return { ok: response.ok, status: response.status, text }; +} + +async function requestBinary( + baseUrl: string, + path: string, + 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(url); + const response = await fetchWithTimeout( + fetchImpl, + url, + { + method: options.method ?? "GET", + headers: buildAuthHeaders(options.token, options.hostToken), + }, + options.timeoutMs ?? DEFAULT_OPENWORK_SERVER_TIMEOUT_MS, + ); + + if (!response.ok) { + const text = await response.text(); + let json: any = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + const code = typeof json?.code === "string" ? json.code : "request_failed"; + const message = typeof json?.message === "string" ? json.message : response.statusText; + throw new OpenworkServerError(response.status, code, message, json?.details); + } + + const contentType = response.headers.get("content-type"); + const disposition = response.headers.get("content-disposition") ?? ""; + const filenameMatch = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i); + const filenameRaw = filenameMatch?.[1] ?? filenameMatch?.[2] ?? null; + const filename = filenameRaw ? decodeURIComponent(filenameRaw) : null; + const data = await response.arrayBuffer(); + return { data, contentType, filename }; +} + +export function createOpenworkServerClient(options: { baseUrl: string; token?: string; hostToken?: string }) { + const baseUrl = options.baseUrl.replace(/\/+$/, ""); + const token = options.token; + const hostToken = options.hostToken; + + const timeouts = { + health: 3_000, + capabilities: 6_000, + listWorkspaces: 8_000, + activateWorkspace: 10_000, + deleteWorkspace: 10_000, + deleteSession: 12_000, + sessionRead: 12_000, + status: 6_000, + config: 10_000, + workspaceExport: 30_000, + workspaceImport: 30_000, + shareBundle: 20_000, + binary: 60_000, + }; + + return { + baseUrl, + token, + health: () => + requestJson<{ ok: boolean; version: string; uptimeMs: number }>(baseUrl, "/health", { token, hostToken, timeoutMs: timeouts.health }), + runtimeVersions: () => + requestJson(baseUrl, "/runtime/versions", { token, hostToken, timeoutMs: timeouts.status }), + status: () => requestJson(baseUrl, "/status", { token, hostToken, timeoutMs: timeouts.status }), + capabilities: () => requestJson(baseUrl, "/capabilities", { token, hostToken, timeoutMs: timeouts.capabilities }), + listWorkspaces: () => requestJson(baseUrl, "/workspaces", { token, hostToken, timeoutMs: timeouts.listWorkspaces }), + createLocalWorkspace: (payload: { folderPath: string; name: string; preset: string }) => + requestJson(baseUrl, "/workspaces/local", { + token, + hostToken, + method: "POST", + body: payload, + timeoutMs: timeouts.activateWorkspace, + }), + updateWorkspaceDisplayName: (workspaceId: string, displayName: string | null) => + requestJson(baseUrl, `/workspaces/${encodeURIComponent(workspaceId)}/display-name`, { + token, + hostToken, + method: "PATCH", + body: { displayName }, + timeoutMs: timeouts.activateWorkspace, + }), + activateWorkspace: (workspaceId: string) => + requestJson<{ activeId: string; workspace: OpenworkWorkspaceInfo }>( + baseUrl, + `/workspaces/${encodeURIComponent(workspaceId)}/activate`, + { token, hostToken, method: "POST", timeoutMs: timeouts.activateWorkspace }, + ), + deleteWorkspace: (workspaceId: string) => + requestJson<{ ok: boolean; deleted: boolean; persisted: boolean; activeId: string | null; items: OpenworkWorkspaceInfo[]; workspaces?: WorkspaceInfo[] }>( + baseUrl, + `/workspaces/${encodeURIComponent(workspaceId)}`, + { token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteWorkspace }, + ), + deleteSession: (workspaceId: string, sessionId: string) => + requestJson<{ ok: boolean }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`, + { token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession }, + ), + listSessions: ( + workspaceId: string, + options?: { roots?: boolean; start?: number; search?: string; limit?: number }, + ) => { + const query = new URLSearchParams(); + if (typeof options?.roots === "boolean") query.set("roots", String(options.roots)); + if (typeof options?.start === "number") query.set("start", String(options.start)); + if (options?.search?.trim()) query.set("search", options.search.trim()); + if (typeof options?.limit === "number") query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query.toString()}` : ""; + return requestJson<{ items: Session[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions${suffix}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ); + }, + getSession: (workspaceId: string, sessionId: string) => + requestJson<{ item: Session }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ), + getSessionMessages: (workspaceId: string, sessionId: string, options?: { limit?: number }) => { + const query = new URLSearchParams(); + if (typeof options?.limit === "number") query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query.toString()}` : ""; + return requestJson<{ items: OpenworkSessionMessage[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages${suffix}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ); + }, + getSessionSnapshot: (workspaceId: string, sessionId: string, options?: { limit?: number }) => { + const query = new URLSearchParams(); + if (typeof options?.limit === "number") query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query.toString()}` : ""; + return requestJson<{ item: OpenworkSessionSnapshot }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/snapshot${suffix}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ); + }, + exportWorkspace: ( + workspaceId: string, + options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode }, + ) => { + const query = new URLSearchParams(); + if (options?.sensitiveMode) { + query.set("sensitive", options.sensitiveMode); + } + const suffix = query.size ? `?${query.toString()}` : ""; + return requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/export${suffix}`, { + token, + hostToken, + timeoutMs: timeouts.workspaceExport, + }); + }, + importWorkspace: (workspaceId: string, payload: Record) => + requestJson<{ ok: boolean }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/import`, { + token, + hostToken, + method: "POST", + body: payload, + timeoutMs: timeouts.workspaceImport, + }), + materializeBlueprintSessions: (workspaceId: string) => + requestJson( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/blueprint/sessions/materialize`, + { + token, + hostToken, + method: "POST", + timeoutMs: timeouts.workspaceImport, + }, + ), + publishBundle: (payload: unknown, bundleType: "skill" | "skills-set", options?: { name?: string; timeoutMs?: number }) => + requestJson<{ url: string }>(baseUrl, "/share/bundles/publish", { + token, + hostToken, + method: "POST", + body: { + payload, + bundleType, + name: options?.name, + timeoutMs: options?.timeoutMs, + }, + timeoutMs: options?.timeoutMs ?? timeouts.shareBundle, + }), + fetchBundle: (bundleUrl: string, options?: { timeoutMs?: number }) => + requestJson>(baseUrl, "/share/bundles/fetch", { + token, + hostToken, + method: "POST", + body: { + bundleUrl, + timeoutMs: options?.timeoutMs, + }, + timeoutMs: options?.timeoutMs ?? timeouts.shareBundle, + }), + getConfig: (workspaceId: string) => + requestJson<{ opencode: Record; openwork: Record; updatedAt?: number | null }>( + baseUrl, + `/workspace/${workspaceId}/config`, + { token, hostToken, timeoutMs: timeouts.config }, + ), + patchConfig: (workspaceId: string, payload: { opencode?: Record; openwork?: Record }) => + requestJson<{ updatedAt?: number | null }>(baseUrl, `/workspace/${workspaceId}/config`, { + token, + hostToken, + method: "PATCH", + body: payload, + }), + readOpencodeConfigFile: (workspaceId: string, scope: "project" | "global" = "project") => { + const query = `?scope=${scope}`; + return requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/opencode-config${query}`, { + token, + hostToken, + }); + }, + writeOpencodeConfigFile: (workspaceId: string, scope: "project" | "global", content: string) => + requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/opencode-config`, { + token, + hostToken, + method: "POST", + body: { scope, content }, + }), + listReloadEvents: (workspaceId: string, options?: { since?: number }) => { + const query = typeof options?.since === "number" ? `?since=${options.since}` : ""; + return requestJson<{ items: OpenworkReloadEvent[]; cursor?: number }>( + baseUrl, + `/workspace/${workspaceId}/events${query}`, + { token, hostToken }, + ); + }, + reloadEngine: (workspaceId: string) => + requestJson<{ ok: boolean; reloadedAt?: number }>(baseUrl, `/workspace/${workspaceId}/engine/reload`, { + token, + hostToken, + method: "POST", + }), + listPlugins: (workspaceId: string, options?: { includeGlobal?: boolean }) => { + const query = options?.includeGlobal ? "?includeGlobal=true" : ""; + return requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>( + baseUrl, + `/workspace/${workspaceId}/plugins${query}`, + { token, hostToken }, + ); + }, + addPlugin: (workspaceId: string, spec: string) => + requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>( + baseUrl, + `/workspace/${workspaceId}/plugins`, + { token, hostToken, method: "POST", body: { spec } }, + ), + removePlugin: (workspaceId: string, name: string) => + requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>( + baseUrl, + `/workspace/${workspaceId}/plugins/${encodeURIComponent(name)}`, + { token, hostToken, method: "DELETE" }, + ), + listSkills: (workspaceId: string, options?: { includeGlobal?: boolean }) => { + const query = options?.includeGlobal ? "?includeGlobal=true" : ""; + return requestJson<{ items: OpenworkSkillItem[] }>( + baseUrl, + `/workspace/${workspaceId}/skills${query}`, + { token, hostToken }, + ); + }, + listHubSkills: (options?: { repo?: OpenworkHubRepo }) => { + const params = new URLSearchParams(); + const owner = options?.repo?.owner?.trim(); + const repo = options?.repo?.repo?.trim(); + const ref = options?.repo?.ref?.trim(); + if (owner) params.set("owner", owner); + if (repo) params.set("repo", repo); + if (ref) params.set("ref", ref); + const query = params.size ? `?${params.toString()}` : ""; + return requestJson<{ items: OpenworkHubSkillItem[] }>(baseUrl, `/hub/skills${query}`, { + token, + hostToken, + }); + }, + installHubSkill: ( + workspaceId: string, + name: string, + options?: { overwrite?: boolean; repo?: { owner?: string; repo?: string; ref?: string } }, + ) => + requestJson<{ ok: boolean; name: string; path: string; action: "added" | "updated"; written: number; skipped: number }>( + baseUrl, + `/workspace/${workspaceId}/skills/hub/${encodeURIComponent(name)}`, + { + token, + hostToken, + method: "POST", + body: { + ...(options?.overwrite ? { overwrite: true } : {}), + ...(options?.repo ? { repo: options.repo } : {}), + }, + }, + ), + getSkill: (workspaceId: string, name: string, options?: { includeGlobal?: boolean }) => { + const query = options?.includeGlobal ? "?includeGlobal=true" : ""; + return requestJson( + baseUrl, + `/workspace/${workspaceId}/skills/${encodeURIComponent(name)}${query}`, + { token, hostToken }, + ); + }, + upsertSkill: (workspaceId: string, payload: { name: string; content: string; description?: string }) => + requestJson(baseUrl, `/workspace/${workspaceId}/skills`, { + token, + hostToken, + method: "POST", + body: payload, + }), + deleteSkill: (workspaceId: string, name: string) => + requestJson<{ path: string }>( + baseUrl, + `/workspace/${workspaceId}/skills/${encodeURIComponent(name)}`, + { + token, + hostToken, + method: "DELETE", + }, + ), + listMcp: (workspaceId: string) => + requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, { token, hostToken }), + addMcp: (workspaceId: string, payload: { name: string; config: Record }) => + requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, { + token, + hostToken, + method: "POST", + body: payload, + }), + removeMcp: (workspaceId: string, name: string) => + requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp/${encodeURIComponent(name)}`, { + token, + hostToken, + method: "DELETE", + }), + setMcpEnabled: (workspaceId: string, name: string, enabled: boolean) => + requestJson<{ items: OpenworkMcpItem[] }>( + baseUrl, + `/workspace/${workspaceId}/mcp/${encodeURIComponent(name)}/enabled`, + { + token, + hostToken, + method: "POST", + body: { enabled }, + }, + ), + + logoutMcpAuth: (workspaceId: string, name: string) => + requestJson<{ ok: true }>(baseUrl, `/workspace/${workspaceId}/mcp/${encodeURIComponent(name)}/auth`, { + token, + hostToken, + method: "DELETE", + }), + + listCommands: (workspaceId: string, scope: "workspace" | "global" = "workspace") => + requestJson<{ items: OpenworkCommandItem[] }>( + baseUrl, + `/workspace/${workspaceId}/commands?scope=${scope}`, + { token, hostToken }, + ), + listAudit: (workspaceId: string, limit = 50) => + requestJson<{ items: OpenworkAuditEntry[] }>( + baseUrl, + `/workspace/${workspaceId}/audit?limit=${limit}`, + { token, hostToken }, + ), + upsertCommand: ( + workspaceId: string, + payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean }, + ) => + requestJson<{ items: OpenworkCommandItem[] }>(baseUrl, `/workspace/${workspaceId}/commands`, { + token, + hostToken, + method: "POST", + body: payload, + }), + deleteCommand: (workspaceId: string, name: string) => + requestJson<{ ok: boolean }>(baseUrl, `/workspace/${workspaceId}/commands/${encodeURIComponent(name)}`, { + token, + hostToken, + method: "DELETE", + }), + uploadInbox: async (workspaceId: string, file: File, options?: { path?: string }) => { + const id = workspaceId.trim(); + if (!id) throw new Error("workspaceId is required"); + if (!file) throw new Error("file is required"); + const form = new FormData(); + form.append("file", file); + if (options?.path?.trim()) { + form.append("path", options.path.trim()); + } + + const result = await requestMultipartRaw(baseUrl, `/workspace/${encodeURIComponent(id)}/inbox`, { + token, + hostToken, + method: "POST", + body: form, + timeoutMs: timeouts.binary, + }); + + if (!result.ok) { + let message = result.text.trim(); + try { + const json = message ? JSON.parse(message) : null; + if (json && typeof json.message === "string") { + message = json.message; + } + } catch { + // ignore + } + throw new OpenworkServerError( + result.status, + "request_failed", + message || "Shared folder upload failed", + ); + } + + const body = result.text.trim(); + if (body) { + try { + const parsed = JSON.parse(body) as Partial; + if (typeof parsed.path === "string" && parsed.path.trim()) { + return { + ok: parsed.ok ?? true, + path: parsed.path.trim(), + bytes: typeof parsed.bytes === "number" ? parsed.bytes : file.size, + } satisfies OpenworkInboxUploadResult; + } + } catch { + // ignore invalid JSON and fall back + } + } + + return { + ok: true, + path: options?.path?.trim() || file.name, + bytes: file.size, + } satisfies OpenworkInboxUploadResult; + }, + + listInbox: (workspaceId: string) => + requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/inbox`, { + token, + hostToken, + }), + + downloadInboxItem: (workspaceId: string, inboxId: string) => + requestBinary( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/inbox/${encodeURIComponent(inboxId)}`, + { token, hostToken, timeoutMs: timeouts.binary }, + ), + + readWorkspaceFile: (workspaceId: string, path: string) => + requestJson( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/files/content?path=${encodeURIComponent(path)}`, + { token, hostToken }, + ), + + writeWorkspaceFile: ( + workspaceId: string, + payload: { path: string; content: string; baseUpdatedAt?: number | null; force?: boolean }, + ) => + requestJson( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/files/content`, + { + token, + hostToken, + method: "POST", + body: payload, + }, + ), + + listArtifacts: (workspaceId: string) => + requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/artifacts`, { + token, + hostToken, + }), + + downloadArtifact: (workspaceId: string, artifactId: string) => + requestBinary( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/artifacts/${encodeURIComponent(artifactId)}`, + { token, hostToken, timeoutMs: timeouts.binary }, + ), + + // User-level env vars (host-auth only — desktop shell is the sole caller). + // See apps/server/src/env-file.ts and apps/app/pr/environment-variables.md. + listUserEnvKeys: () => + requestJson<{ keys: string[] }>( + baseUrl, + "/env/keys", + { token, hostToken, timeoutMs: timeouts.config }, + ), + + listUserEnv: () => + requestJson<{ items: Array<{ key: string; value: string; updatedAt: number }> }>( + baseUrl, + "/env", + { token, hostToken, timeoutMs: timeouts.config }, + ), + + upsertUserEnv: (entries: Array<{ key: string; value: string }>) => + requestJson<{ ok: true; count: number }>(baseUrl, "/env", { + token, + hostToken, + method: "PUT", + body: { entries }, + timeoutMs: timeouts.config, + }), + + deleteUserEnv: (key: string) => + requestJson<{ ok: true }>(baseUrl, `/env/${encodeURIComponent(key)}`, { + token, + hostToken, + method: "DELETE", + timeoutMs: timeouts.config, + }), + }; +} + +export type OpenworkServerClient = ReturnType; diff --git a/apps/app/src/app/lib/perf-log.ts b/apps/app/src/app/lib/perf-log.ts new file mode 100644 index 0000000000..aa699fe353 --- /dev/null +++ b/apps/app/src/app/lib/perf-log.ts @@ -0,0 +1,144 @@ +import { recordDevLog } from "./dev-log"; + +export type PerfLogRecord = { + id: number; + at: string; + ts: number; + scope: string; + event: string; + payload?: Record; +}; + +type PerfRoot = typeof globalThis & { + __openworkPerfSeq?: number; + __openworkPerfLogs?: PerfLogRecord[]; + __openworkPerfConsoleAt?: Record; + __openworkPerfConsoleSuppressed?: Record; +}; + +const PERF_LOG_LIMIT = 500; +const HOT_EVENT_MIN_INTERVAL_MS = 750; +const HOT_EVENT_KEYS = new Set([ + "session.sse:flush", + "session.sse:arrival-gap", + "session.event:message.part.delta", + "session.event:message.part.updated", + "session.compaction:synthetic-continue", + "session.input:draft-flush", + "session.render:message-blocks", + "session.render:tool-summary", + "session.render:batch-commit", + "session.main-thread:lag", + "session.window:state", +]); + +export const perfNow = () => { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +}; + +const round = (value: number) => Math.round(value * 100) / 100; + +export const recordPerfLog = ( + enabled: boolean, + scope: string, + event: string, + payload?: Record, +) => { + if (!enabled) return; + + const root = globalThis as PerfRoot; + const id = (root.__openworkPerfSeq ?? 0) + 1; + root.__openworkPerfSeq = id; + + const entry: PerfLogRecord = { + id, + at: new Date().toISOString(), + ts: Date.now(), + scope, + event, + payload, + }; + + const logs = root.__openworkPerfLogs ?? []; + logs.push(entry); + if (logs.length > PERF_LOG_LIMIT) { + logs.splice(0, logs.length - PERF_LOG_LIMIT); + } + root.__openworkPerfLogs = logs; + recordDevLog(enabled, { + level: "perf", + source: scope, + label: event, + payload, + }); + + try { + const key = `${scope}:${event}`; + const now = Date.now(); + const lastByKey = root.__openworkPerfConsoleAt ?? (root.__openworkPerfConsoleAt = {}); + const suppressedByKey = + root.__openworkPerfConsoleSuppressed ?? (root.__openworkPerfConsoleSuppressed = {}); + if (HOT_EVENT_KEYS.has(key)) { + const last = lastByKey[key] ?? 0; + if (now - last < HOT_EVENT_MIN_INTERVAL_MS) { + suppressedByKey[key] = (suppressedByKey[key] ?? 0) + 1; + return; + } + } + + lastByKey[key] = now; + const suppressed = suppressedByKey[key] ?? 0; + if (suppressed > 0) { + suppressedByKey[key] = 0; + } + + if (payload === undefined) { + if (suppressed > 0) { + console.log(`[OWPERF] ${scope}:${event}`, { suppressed }); + return; + } + console.log(`[OWPERF] ${scope}:${event}`); + return; + } + + if (suppressed > 0) { + console.log(`[OWPERF] ${scope}:${event}`, { ...payload, suppressed }); + return; + } + + console.log(`[OWPERF] ${scope}:${event}`, payload); + } catch { + // ignore + } +}; + +export const readPerfLogs = (limit = 120) => { + const root = globalThis as PerfRoot; + const logs = root.__openworkPerfLogs ?? []; + if (limit <= 0) return []; + if (logs.length <= limit) return logs.slice(); + return logs.slice(logs.length - limit); +}; + +export const clearPerfLogs = () => { + const root = globalThis as PerfRoot; + root.__openworkPerfLogs = []; + root.__openworkPerfSeq = 0; +}; + +export const finishPerf = ( + enabled: boolean, + scope: string, + event: string, + startedAt: number, + payload?: Record, +) => { + if (!enabled) return; + recordPerfLog(enabled, scope, event, { + ...(payload ?? {}), + ms: round(perfNow() - startedAt), + }); +}; diff --git a/apps/app/src/app/lib/publisher.ts b/apps/app/src/app/lib/publisher.ts new file mode 100644 index 0000000000..5efc2b8818 --- /dev/null +++ b/apps/app/src/app/lib/publisher.ts @@ -0,0 +1,80 @@ +export type OpenworkPublisherBundleType = "skill" | "skills-set"; + +export type PublishBundleResult = { + url: string; +}; + +const ENV_OPENWORK_PUBLISHER_BASE_URL = String(import.meta.env.VITE_OPENWORK_PUBLISHER_BASE_URL ?? "").trim(); + +export const DEFAULT_OPENWORK_PUBLISHER_BASE_URL = + ENV_OPENWORK_PUBLISHER_BASE_URL || "https://share.openworklabs.com"; + +function normalizeBaseUrl(input: string): string { + const trimmed = String(input ?? "").trim(); + if (!trimmed) { + throw new Error("Publisher baseUrl is required"); + } + return trimmed.replace(/\/+$/, ""); +} + +async function readErrorMessage(response: Response): Promise { + try { + const text = await response.text(); + if (!text.trim()) return ""; + try { + const json = JSON.parse(text) as Record; + if (json && typeof json.message === "string" && json.message.trim()) { + return json.message.trim(); + } + } catch { + // ignore + } + return text.trim(); + } catch { + return ""; + } +} + +export async function publishOpenworkBundleJson(input: { + payload: unknown; + bundleType: OpenworkPublisherBundleType; + name?: string; + baseUrl?: string; + timeoutMs?: number; +}): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl ?? DEFAULT_OPENWORK_PUBLISHER_BASE_URL); + const timeoutMs = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 15_000; + + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs)); + + try { + const response = await fetch(`${baseUrl}/v1/bundles`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-OpenWork-Bundle-Type": input.bundleType, + "X-OpenWork-Schema-Version": "v1", + ...(input.name?.trim() ? { "X-OpenWork-Name": input.name.trim() } : null), + }, + body: JSON.stringify(input.payload), + signal: controller.signal, + }); + + if (!response.ok) { + const details = await readErrorMessage(response); + const suffix = details ? `: ${details}` : ""; + throw new Error(`Publish failed (${response.status})${suffix}`); + } + + const json = (await response.json()) as Record; + const url = typeof json.url === "string" ? json.url.trim() : ""; + if (!url) { + throw new Error("Publisher response missing url"); + } + return { url }; + } finally { + window.clearTimeout(timer); + } +} 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 0000000000..e423baacb1 --- /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/session-scope.ts b/apps/app/src/app/lib/session-scope.ts new file mode 100644 index 0000000000..a3e38ff6c7 --- /dev/null +++ b/apps/app/src/app/lib/session-scope.ts @@ -0,0 +1,101 @@ +import { normalizeDirectoryPath } from "../utils"; +import { normalizeDirectoryQueryPath } from "../utils"; + +/** + * Branded string for directory values sent over the wire to the OpenCode server. + * + * The server compares `session.directory === query.directory` with strict + * equality, so every call site that creates, lists, or deletes sessions must + * use the same canonical format. The brand makes it a *compile error* to pass + * a raw `string` where a `TransportDirectory` is expected — you must go + * through {@link toSessionTransportDirectory} first. + * + * On Windows this preserves native backslashes (`C:\Users\…`); on Unix it + * normalises to forward-slashed paths without a trailing separator. + */ +export type TransportDirectory = string & { + readonly __transportDirectory: unique symbol; +}; + +type WorkspaceType = "local" | "remote"; + +export function resolveScopedClientDirectory(input: { + directory?: string | null; + targetRoot?: string | null; + workspaceType?: WorkspaceType | null; +}): TransportDirectory { + const directory = toSessionTransportDirectory(input.directory); + if (directory) return directory; + + if (input.workspaceType === "remote") return "" as TransportDirectory; + + return toSessionTransportDirectory(input.targetRoot); +} + +/** + * Canonical formatter for directory values sent to the OpenCode server. + * + * Returns a {@link TransportDirectory} — the only format the server accepts for + * exact directory matching. All session create / list / delete calls must use + * this (or {@link resolveScopedClientDirectory}) instead of the local-only + * {@link normalizeDirectoryQueryPath}. + */ +export function toSessionTransportDirectory(input?: string | null): TransportDirectory { + const trimmed = (input ?? "").trim(); + if (!trimmed) return "" as TransportDirectory; + + if (/^\\\\\?\\UNC\\/i.test(trimmed)) { + return `\\${trimmed.slice(7)}` as TransportDirectory; + } + + if (/^\\\\\?\\[a-zA-Z]:[\\/]/.test(trimmed)) { + return trimmed.slice(4) as TransportDirectory; + } + + if (/^(?:[a-zA-Z]:[\\/]|\\\\)/.test(trimmed)) { + return trimmed as TransportDirectory; + } + + return normalizeDirectoryQueryPath(trimmed) as TransportDirectory; +} + +export function describeDirectoryScope(input?: string | null) { + const raw = input ?? ""; + const trimmed = raw.trim(); + const transport = toSessionTransportDirectory(trimmed); + const normalized = normalizeDirectoryPath(trimmed); + return { + raw: trimmed || null, + transport: (transport || null) as TransportDirectory | null, + normalized: normalized || null, + }; +} + +export function scopedRootsMatch(a?: string | null, b?: string | null) { + const left = normalizeDirectoryPath(a ?? ""); + const right = normalizeDirectoryPath(b ?? ""); + if (!left || !right) return false; + return left === right; +} + +export function shouldApplyScopedSessionLoad(input: { + loadedScopeRoot?: string | null; + workspaceRoot?: string | null; +}) { + const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? ""); + if (!workspaceRoot) return true; + return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot); +} + +export function shouldRedirectMissingSessionAfterScopedLoad(input: { + loadedScopeRoot?: string | null; + workspaceRoot?: string | null; + hasMatchingSession: boolean; +}) { + if (input.hasMatchingSession) return false; + + const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? ""); + if (!workspaceRoot) return false; + + return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot); +} diff --git a/apps/app/src/app/lib/session-title.ts b/apps/app/src/app/lib/session-title.ts new file mode 100644 index 0000000000..aea996de92 --- /dev/null +++ b/apps/app/src/app/lib/session-title.ts @@ -0,0 +1,22 @@ +import { t } from "../../i18n"; + +/** Raw English string — used for prefix matching against stored titles. */ +export const DEFAULT_SESSION_TITLE = "New session"; + +const GENERATED_SESSION_TITLE_PREFIX = `${DEFAULT_SESSION_TITLE} - `; + +export function isGeneratedSessionTitle(title: string | null | undefined) { + const trimmed = title?.trim() ?? ""; + if (!trimmed.startsWith(GENERATED_SESSION_TITLE_PREFIX)) return false; + const suffix = trimmed.slice(GENERATED_SESSION_TITLE_PREFIX.length).trim(); + return Boolean(suffix) && Number.isFinite(Date.parse(suffix)); +} + +export function getDisplaySessionTitle( + title: string | null | undefined, + fallback?: string, +) { + const trimmed = title?.trim() ?? ""; + if (!trimmed || isGeneratedSessionTitle(trimmed)) return fallback ?? t("session.default_title"); + return trimmed; +} diff --git a/apps/app/src/app/lib/startup-boot.ts b/apps/app/src/app/lib/startup-boot.ts new file mode 100644 index 0000000000..87cd9c5674 --- /dev/null +++ b/apps/app/src/app/lib/startup-boot.ts @@ -0,0 +1,56 @@ +export type BootPhase = + | "nativeInit" + | "workspaceBootstrap" + | "engineProbe" + | "engineStartOrConnect" + | "sessionIndexReady" + | "firstSessionReady" + | "ready" + | "error"; + +export type StartupBranch = + | "firstRunNoWorkspace" + | "remoteWorkspace" + | "localAttachExisting" + | "localHostStart" + | "serverPreference" + | "localPreference" + | "welcome" + | "unknown"; + +export type StartupTraceEvent = { + at: number; + phase: BootPhase; + event: string; + detail?: Record; +}; + +export function classifyStartupBranch(input: { + workspaceCount: number; + activeWorkspaceType: "local" | "remote" | null; + startupPreference: "local" | "server" | null; + engineHasBaseUrl: boolean; + selectedWorkspacePath: string; +}): StartupBranch { + if (input.workspaceCount === 0) return "firstRunNoWorkspace"; + if (input.activeWorkspaceType === "remote") return "remoteWorkspace"; + if (input.startupPreference === "server") return "serverPreference"; + if (!input.selectedWorkspacePath.trim()) { + if (input.startupPreference === "local") return "localPreference"; + return "welcome"; + } + return input.engineHasBaseUrl ? "localAttachExisting" : "localHostStart"; +} + +export function pushStartupTraceEvent( + current: StartupTraceEvent[], + event: StartupTraceEvent, + maxEvents = 100, +): StartupTraceEvent[] { + if (!Number.isFinite(event.at) || !event.phase || !event.event) { + return current; + } + const base = current.length >= maxEvents ? current.slice(current.length - maxEvents + 1) : current.slice(); + base.push(event); + return base; +} diff --git a/apps/app/src/app/lib/tauri.ts b/apps/app/src/app/lib/tauri.ts new file mode 100644 index 0000000000..9c4641a4bc --- /dev/null +++ b/apps/app/src/app/lib/tauri.ts @@ -0,0 +1 @@ +export * from "./desktop"; 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 0000000000..64d8b92d9c --- /dev/null +++ b/apps/app/src/app/lib/version-gate.ts @@ -0,0 +1,222 @@ +// 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; +} + +function releasePart(value: string): number[] | null { + return parseComparableVersion(value)?.release ?? null; +} + +/** + * 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, + ); +} + +function maxAllowedDesktopVersion(desktopConfig: DenDesktopConfig | null | undefined): string | null { + if (!Array.isArray(desktopConfig?.allowedDesktopVersions)) { + return null; + } + + let maxVersion: string | null = null; + for (const version of desktopConfig.allowedDesktopVersions) { + if (parseComparableVersion(version) === null) continue; + if (maxVersion === null) { + maxVersion = version; + continue; + } + const comparison = compareVersions(version, maxVersion); + if (comparison !== null && comparison > 0) { + maxVersion = version; + } + } + return maxVersion; +} + +function effectiveMaxDesktopVersion( + denLatestAppVersion: string, + desktopConfig: DenDesktopConfig | null | undefined, +): string { + const orgMaxVersion = maxAllowedDesktopVersion(desktopConfig); + if (!orgMaxVersion) return denLatestAppVersion; + const comparison = compareVersions(orgMaxVersion, denLatestAppVersion); + return comparison !== null && comparison < 0 ? orgMaxVersion : denLatestAppVersion; +} + +function isWithinOnePatchAhead(updateVersion: string, maxVersion: string): boolean { + const directComparison = compareVersions(updateVersion, maxVersion); + if (directComparison !== null && directComparison <= 0) { + return true; + } + + const updateRelease = releasePart(updateVersion); + const maxRelease = releasePart(maxVersion); + if (!updateRelease || !maxRelease) return false; + + const updateMajor = updateRelease[0] ?? 0; + const updateMinor = updateRelease[1] ?? 0; + const updatePatch = updateRelease[2] ?? 0; + const maxMajor = maxRelease[0] ?? 0; + const maxMinor = maxRelease[1] ?? 0; + const maxPatch = maxRelease[2] ?? 0; + + return updateMajor === maxMajor && updateMinor === maxMinor && updatePatch <= maxPatch + 1; +} + +async function readDenLatestAppVersion(): 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(); + return metadata.latestAppVersion; + } catch { + return null; + } +} + +/** + * 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 { + const latestAppVersion = await readDenLatestAppVersion(); + if (!latestAppVersion) return false; + const comparison = compareVersions(updateVersion, latestAppVersion); + return comparison !== null && comparison <= 0; +} + +/** + * Alpha channel builds may run one patch ahead of the current Den/org maximum + * (e.g. Den allows 0.13.3, alpha 0.13.4-alpha.N is allowed). Larger jumps are + * still blocked so alpha cannot bypass staged rollout ceilings entirely. + */ +export async function isAlphaUpdateAllowed( + updateVersion: string, + desktopConfig: DenDesktopConfig | null | undefined, +): Promise { + const latestAppVersion = await readDenLatestAppVersion(); + if (!latestAppVersion) return false; + const effectiveMaxVersion = effectiveMaxDesktopVersion(latestAppVersion, desktopConfig); + return isWithinOnePatchAhead(updateVersion, effectiveMaxVersion); +} + +/** + * 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-blueprints.ts b/apps/app/src/app/lib/workspace-blueprints.ts new file mode 100644 index 0000000000..671f293385 --- /dev/null +++ b/apps/app/src/app/lib/workspace-blueprints.ts @@ -0,0 +1,248 @@ +import type { + WorkspaceBlueprint, + WorkspaceBlueprintMaterializedSession, + WorkspaceBlueprintSessionMessage, + WorkspaceBlueprintSessionTemplate, + WorkspaceBlueprintStarter, + WorkspaceOpenworkConfig, +} from "../types"; +import { parseTemplateFrontmatter } from "../utils"; +import { t } from "../../i18n"; + +import browserSetupTemplate from "../data/commands/browser-setup.md?raw"; + +const BROWSER_AUTOMATION_QUICKSTART_PROMPT = (() => { + const parsed = parseTemplateFrontmatter(browserSetupTemplate); + return (parsed?.body ?? browserSetupTemplate).trim(); +})(); + + +const defaultWelcomeBlueprintMessages = (): WorkspaceBlueprintSessionMessage[] => [ + { + role: "assistant", + text: t("blueprint.welcome_message"), + }, +]; + +export function defaultBlueprintSessionsForPreset(_preset: string): WorkspaceBlueprintSessionTemplate[] { + return [ + { + id: "welcome-to-openwork", + title: t("blueprint.welcome_title"), + messages: defaultWelcomeBlueprintMessages(), + openOnFirstLoad: true, + }, + { + id: "csv-playbook", + title: t("blueprint.csv_session_title"), + messages: [ + { + role: "assistant", + text: t("blueprint.csv_session_assistant"), + }, + { + role: "user", + text: t("blueprint.csv_session_user"), + }, + ], + openOnFirstLoad: false, + }, + ]; +} + +function normalizeSessionMessage(value: unknown): WorkspaceBlueprintSessionMessage | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const text = typeof record.text === "string" ? record.text.trim() : ""; + if (!text) return null; + const role = String(record.role ?? "assistant").trim().toLowerCase() === "user" ? "user" : "assistant"; + return { role, text }; +} + +function normalizeSessionTemplate(value: unknown, index: number): WorkspaceBlueprintSessionTemplate | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const title = typeof record.title === "string" ? record.title.trim() : ""; + const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : `template-session-${index + 1}`; + const messages = Array.isArray(record.messages) + ? record.messages.map(normalizeSessionMessage).filter((item): item is WorkspaceBlueprintSessionMessage => Boolean(item)) + : []; + if (!title && messages.length === 0) return null; + return { + id, + title: title || null, + messages, + openOnFirstLoad: record.openOnFirstLoad === true, + }; +} + +function normalizeMaterializedSession(value: unknown): WorkspaceBlueprintMaterializedSession | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const sessionId = typeof record.sessionId === "string" ? record.sessionId.trim() : ""; + const templateId = typeof record.templateId === "string" ? record.templateId.trim() : ""; + if (!sessionId || !templateId) return null; + return { sessionId, templateId }; +} + +function normalizeBlueprint(value: unknown): WorkspaceBlueprint | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as WorkspaceBlueprint & Record; + const sessions = Array.isArray(candidate.sessions) + ? candidate.sessions + .map((session, index) => normalizeSessionTemplate(session, index)) + .filter((item): item is WorkspaceBlueprintSessionTemplate => Boolean(item)) + : null; + const materializedSessions = Array.isArray(candidate.materialized?.sessions?.items) + ? candidate.materialized?.sessions?.items + .map(normalizeMaterializedSession) + .filter((item): item is WorkspaceBlueprintMaterializedSession => Boolean(item)) + : null; + + return { + emptyState: candidate.emptyState ?? null, + sessions, + materialized: candidate.materialized + ? { + sessions: candidate.materialized.sessions + ? { + hydratedAt: + typeof candidate.materialized.sessions.hydratedAt === "number" + ? candidate.materialized.sessions.hydratedAt + : null, + items: materializedSessions, + } + : null, + } + : null, + }; +} + +export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlueprintStarter[] { + switch (preset.trim().toLowerCase()) { + case "automation": + return [ + { + id: "automation-command", + kind: "prompt", + title: t("blueprint.starter_command_title"), + description: t("blueprint.starter_command_desc"), + prompt: t("blueprint.starter_command_prompt"), + }, + { + id: "automation-blueprint", + kind: "session", + title: t("blueprint.starter_blueprint_title"), + description: t("blueprint.starter_blueprint_desc"), + prompt: t("blueprint.starter_blueprint_prompt"), + }, + ]; + case "minimal": + return [ + { + id: "minimal-explore", + kind: "prompt", + title: t("blueprint.starter_explore_title"), + description: t("blueprint.starter_explore_desc"), + prompt: t("blueprint.starter_explore_prompt"), + }, + ]; + default: + return [ + { + id: "csv-help", + kind: "prompt", + title: t("blueprint.starter_csv_title"), + description: t("blueprint.starter_csv_desc"), + prompt: t("blueprint.starter_csv_prompt"), + }, + { + id: "starter-connect-openai", + kind: "action", + title: t("blueprint.starter_connect_openai_title"), + description: t("blueprint.starter_connect_openai_desc"), + action: "connect-openai", + }, + { + id: "browser-automation", + kind: "session", + title: t("blueprint.starter_chrome_title"), + description: t("blueprint.starter_chrome_desc"), + prompt: t("blueprint.starter_chrome_prompt"), + }, + ]; + } +} + +export function defaultBlueprintCopyForPreset(preset: string) { + switch (preset.trim().toLowerCase()) { + case "automation": + return { + title: t("blueprint.automation_title"), + body: t("blueprint.automation_body"), + }; + case "minimal": + return { + title: t("blueprint.minimal_title"), + body: t("blueprint.minimal_body"), + }; + default: + return { + title: t("blueprint.empty_title"), + body: t("blueprint.empty_body"), + }; + } +} + +export function buildDefaultWorkspaceBlueprint(preset: string): WorkspaceBlueprint { + const copy = defaultBlueprintCopyForPreset(preset); + return { + emptyState: { + title: copy.title, + body: copy.body, + starters: defaultBlueprintStartersForPreset(preset), + }, + sessions: defaultBlueprintSessionsForPreset(preset), + }; +} + +export function blueprintSessions(config: WorkspaceOpenworkConfig | null | undefined): WorkspaceBlueprintSessionTemplate[] { + return Array.isArray(config?.blueprint?.sessions) + ? config!.blueprint!.sessions!.filter((item): item is WorkspaceBlueprintSessionTemplate => Boolean(item)) + : []; +} + +export function blueprintMaterializedSessions(config: WorkspaceOpenworkConfig | null | undefined): WorkspaceBlueprintMaterializedSession[] { + return Array.isArray(config?.blueprint?.materialized?.sessions?.items) + ? config!.blueprint!.materialized!.sessions!.items!.filter((item): item is WorkspaceBlueprintMaterializedSession => Boolean(item)) + : []; +} + +export function normalizeWorkspaceOpenworkConfig( + value: unknown, + preset?: string | null, +): WorkspaceOpenworkConfig { + const candidate = + value && typeof value === "object" + ? (value as Partial) + : {}; + + const normalizedPreset = + candidate.workspace?.preset?.trim() || preset?.trim() || null; + + return { + version: typeof candidate.version === "number" ? candidate.version : 1, + workspace: + candidate.workspace || normalizedPreset + ? { + ...(candidate.workspace ?? {}), + preset: normalizedPreset, + } + : null, + authorizedRoots: Array.isArray(candidate.authorizedRoots) + ? candidate.authorizedRoots.filter((item): item is string => typeof item === "string") + : [], + blueprint: normalizeBlueprint(candidate.blueprint), + reload: candidate.reload ?? null, + }; +} diff --git a/apps/app/src/app/mcp.ts b/apps/app/src/app/mcp.ts new file mode 100644 index 0000000000..d740a6e32f --- /dev/null +++ b/apps/app/src/app/mcp.ts @@ -0,0 +1,108 @@ +import { parse } from "jsonc-parser"; +import type { McpServerConfig, McpServerEntry } from "./types"; +import { readOpencodeConfig, writeOpencodeConfig } from "./lib/desktop"; +import { CHROME_DEVTOOLS_MCP_COMMAND, CHROME_DEVTOOLS_MCP_ID } from "./constants"; + +type McpConfigValue = Record | null | undefined; + +export const CHROME_DEVTOOLS_AUTO_CONNECT_ARG = "--autoConnect"; + +type McpIdentity = { + id?: string; + name: string; +}; + +export function normalizeMcpSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + +export function getMcpIdentityKey(entry: McpIdentity): string { + return entry.id ?? normalizeMcpSlug(entry.name); +} + +export function isChromeDevtoolsMcp(entry: McpIdentity | string | null | undefined): boolean { + if (!entry) return false; + const key = typeof entry === "string" ? entry : getMcpIdentityKey(entry); + return key === CHROME_DEVTOOLS_MCP_ID || normalizeMcpSlug(typeof entry === "string" ? entry : entry.name) === "control-chrome"; +} + +export function usesChromeDevtoolsAutoConnect(command?: string[]): boolean { + return Array.isArray(command) && command.includes(CHROME_DEVTOOLS_AUTO_CONNECT_ARG); +} + +export function buildChromeDevtoolsCommand(command: string[] | undefined, useExistingProfile: boolean): string[] { + const base = Array.isArray(command) && command.length + ? command.filter((part) => part !== CHROME_DEVTOOLS_AUTO_CONNECT_ARG) + : [...CHROME_DEVTOOLS_MCP_COMMAND]; + return useExistingProfile ? [...base, CHROME_DEVTOOLS_AUTO_CONNECT_ARG] : base; +} + +export function validateMcpServerName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("server_name is required"); + } + if (trimmed.startsWith("-")) { + throw new Error("server_name must not start with '-'"); + } + if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) { + throw new Error("server_name must be alphanumeric with '-' or '_'"); + } + return trimmed; +} + +export async function removeMcpFromConfig( + projectDir: string, + name: string, +): Promise { + const configFile = await readOpencodeConfig("project", projectDir); + let existingConfig: Record = {}; + if (configFile.exists && configFile.content?.trim()) { + try { + existingConfig = parse(configFile.content) ?? {}; + } catch { + existingConfig = {}; + } + } + + const mcpSection = existingConfig["mcp"] as Record | undefined; + if (!mcpSection || !(name in mcpSection)) return; + + delete mcpSection[name]; + const writeResult = await writeOpencodeConfig( + "project", + projectDir, + `${JSON.stringify(existingConfig, null, 2)}\n`, + ); + if (!writeResult.ok) { + throw new Error(writeResult.stderr || writeResult.stdout || "Failed to write opencode.json"); + } +} + +export function parseMcpServersFromContent(content: string): McpServerEntry[] { + if (!content.trim()) return []; + + try { + const parsed = parse(content) as Record | undefined; + const mcp = parsed?.mcp as McpConfigValue; + + if (!mcp || typeof mcp !== "object") { + return []; + } + + return Object.entries(mcp).flatMap(([name, value]) => { + if (!value || typeof value !== "object") { + return []; + } + + const config = value as McpServerConfig; + if (config.type !== "remote" && config.type !== "local") { + return []; + } + + return [{ name, config, source: "config.project" as const }]; + }); + } catch { + return []; + } +} diff --git a/apps/app/src/app/theme.ts b/apps/app/src/app/theme.ts new file mode 100644 index 0000000000..efd5e24f45 --- /dev/null +++ b/apps/app/src/app/theme.ts @@ -0,0 +1,62 @@ +export type ThemeMode = "light" | "dark" | "system"; + +const THEME_PREF_KEY = "openwork.themePref"; + +const mediaQuery = "(prefers-color-scheme: dark)"; + +const getMediaQueryList = () => + typeof window === "undefined" ? null : window.matchMedia(mediaQuery); + +const readStoredMode = (): ThemeMode => { + if (typeof window === "undefined") return "system"; + try { + const stored = window.localStorage.getItem(THEME_PREF_KEY); + if (stored === "light" || stored === "dark" || stored === "system") { + return stored; + } + } catch { + // ignore + } + return "system"; +}; + +const resolveMode = (mode: ThemeMode) => { + if (mode !== "system") return mode; + return getMediaQueryList()?.matches ? "dark" : "light"; +}; + +const applyTheme = (mode: ThemeMode) => { + if (typeof document === "undefined") return; + const resolved = resolveMode(mode); + document.documentElement.dataset.theme = resolved; + document.documentElement.style.colorScheme = resolved; +}; + +export const bootstrapTheme = () => { + const mode = readStoredMode(); + applyTheme(mode); +}; + +export const getInitialThemeMode = () => readStoredMode(); + +export const persistThemeMode = (mode: ThemeMode) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(THEME_PREF_KEY, mode); + } catch { + // ignore + } +}; + +export const subscribeToSystemTheme = (onChange: (isDark: boolean) => void) => { + const list = getMediaQueryList(); + if (!list) return () => undefined; + + const handler = (event: MediaQueryListEvent) => onChange(event.matches); + list.addEventListener("change", handler); + return () => list.removeEventListener("change", handler); +}; + +export const applyThemeMode = (mode: ThemeMode) => { + applyTheme(mode); +}; diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts new file mode 100644 index 0000000000..9b215e0dae --- /dev/null +++ b/apps/app/src/app/types.ts @@ -0,0 +1,445 @@ +import type { + Message, + Part, + PermissionRequest as ApiPermissionRequest, + QuestionRequest, + ProviderListResponse, + Session, +} from "@opencode-ai/sdk/v2/client"; +import type { createClient } from "./lib/opencode"; +import type { OpencodeConfigFile, WorkspaceInfo } from "./lib/desktop"; + +export type Client = ReturnType; + +export type ProviderListItem = ProviderListResponse["all"][number]; + +export type SidebarSessionItem = { + id: string; + title: string; + slug?: string | null; + parentID?: string | null; + time?: { + updated?: number | null; + created?: number | null; + }; + directory?: string | null; +}; + +export type WorkspaceSessionGroup = { + workspace: WorkspaceInfo; + sessions: SidebarSessionItem[]; + status: "idle" | "loading" | "ready" | "error"; + error?: string | null; +}; + +export type PlaceholderMessageInfo = { + id: string; + sessionID: string; + role: "assistant" | "user"; + time: { + created: number; + completed?: number; + }; + parentID: string; + modelID: string; + providerID: string; + mode: string; + agent: string; + path: { + cwd: string; + root: string; + }; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cache: { + read: number; + write: number; + }; + }; +}; + +export type PlaceholderAssistantMessage = PlaceholderMessageInfo & { + role: "assistant"; +}; + +export type MessageInfo = Message | PlaceholderMessageInfo; + +export type MessageWithParts = { + info: MessageInfo; + parts: Part[]; +}; + +export type SessionErrorTurn = { + id: string; + text: string; + afterMessageID: string | null; + time: number; +}; + +export const SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX = "session-error:"; + +export type StepGroupMode = "exploration" | "standalone"; + +export type MessageGroup = + | { kind: "text"; part: Part; segment: "intent" | "result" } + | { kind: "steps"; id: string; parts: Part[]; segment: "execution"; mode: StepGroupMode }; + +export type PromptMode = "prompt" | "shell"; + +export type ComposerPart = + | { type: "text"; text: string } + | { type: "agent"; name: string } + | { type: "file"; path: string; label?: string } + | { type: "paste"; id: string; label: string; text: string; lines: number }; + +export type ComposerAttachment = { + id: string; + name: string; + mimeType: string; + size: number; + kind: "image" | "file"; + file: File; + previewUrl?: string; +}; + +export type SlashCommandOption = { + id: string; + name: string; + description?: string; + source?: "command" | "mcp" | "skill"; +}; + +export type ComposerDraft = { + mode: PromptMode; + parts: ComposerPart[]; + attachments: ComposerAttachment[]; + /** Editor-visible text (may include collapsed paste placeholders). */ + text: string; + /** + * Resolved text to send to the model. + * When a paste is collapsed into a placeholder (e.g. "[pasted text 1]"), + * this includes the full pasted text instead. + */ + resolvedText?: string; + /** When set, draft is a slash command invocation */ + command?: { name: string; arguments: string } | undefined; +}; + +export type ArtifactItem = { + id: string; + name: string; + path?: string; + kind: "file" | "text"; + size?: string; + messageId?: string; +}; + +export type OpencodeEvent = { + type: string; + properties?: unknown; +}; + +export type SessionCompactionState = { + running: boolean; + startedAt: number | null; + finishedAt: number | null; + mode: "auto" | "manual" | null; + messageID: string | null; +}; + +export type View = "settings" | "session" | "signin"; + +export type StartupPreference = "local" | "server"; + +/** + * Release channel the desktop app is subscribed to. + * + * - "stable": default. Auto-updates from the rolling stable GitHub release. + * - "alpha": macOS-only. Auto-updates from the rolling alpha release that + * every merge to `dev` publishes to. + * + * See `apps/app/src/app/lib/release-channels.ts` for URL resolution. + */ +export type ReleaseChannel = "stable" | "alpha"; + +export type EngineRuntime = "direct"; + +export type OnboardingStep = "welcome" | "local" | "server" | "connecting"; + +export type SettingsTab = + | "general" + | "den" + | "skills" + | "extensions" + | "environment" + | "advanced" + | "appearance" + | "updates" + | "recovery" + | "debug"; + +export type WorkspacePreset = "starter" | "automation" | "minimal"; + +export type WorkspaceConnectionStatus = "idle" | "connecting" | "connected" | "error"; + +export type WorkspaceConnectionState = { + status: WorkspaceConnectionStatus; + message?: string | null; + checkedAt?: number | null; +}; + +export type ResetOpenworkMode = "onboarding" | "all"; + +export type WorkspaceBlueprintStarterKind = "prompt" | "session" | "action"; + +export type WorkspaceBlueprintStarterAction = "connect-openai"; + +export type WorkspaceBlueprintStarter = { + id?: string | null; + kind?: WorkspaceBlueprintStarterKind | null; + title?: string | null; + description?: string | null; + prompt?: string | null; + action?: WorkspaceBlueprintStarterAction | null; +}; + +export type WorkspaceBlueprintSessionMessageRole = "assistant" | "user"; + +export type WorkspaceBlueprintSessionMessage = { + role?: WorkspaceBlueprintSessionMessageRole | null; + text?: string | null; +}; + +export type WorkspaceBlueprintSessionTemplate = { + id?: string | null; + title?: string | null; + messages?: WorkspaceBlueprintSessionMessage[] | null; + openOnFirstLoad?: boolean | null; +}; + +export type WorkspaceBlueprintMaterializedSession = { + templateId?: string | null; + sessionId?: string | null; +}; + +export type WorkspaceBlueprintMaterializedSessions = { + hydratedAt?: number | null; + items?: WorkspaceBlueprintMaterializedSession[] | null; +}; + +export type WorkspaceBlueprintEmptyState = { + title?: string | null; + body?: string | null; + starters?: WorkspaceBlueprintStarter[] | null; +}; + +export type WorkspaceBlueprint = { + emptyState?: WorkspaceBlueprintEmptyState | null; + sessions?: WorkspaceBlueprintSessionTemplate[] | null; + materialized?: { + sessions?: WorkspaceBlueprintMaterializedSessions | null; + } | null; +}; + +export type WorkspaceOpenworkConfig = { + version: number; + workspace?: { + name?: string | null; + createdAt?: number | null; + preset?: string | null; + } | null; + authorizedRoots: string[]; + blueprint?: WorkspaceBlueprint | null; + reload?: { + auto?: boolean; + resume?: boolean; + } | null; +}; + +export type SkillCard = { + name: string; + path: string; + description?: string; + trigger?: string; +}; + +export type HubSkillRepo = { + owner: string; + repo: string; + ref: string; +}; + +export type HubSkillCard = { + name: string; + description?: string; + trigger?: string; + source: HubSkillRepo & { + path: string; + }; +}; + +/** OpenWork Cloud (Den) org skill surfaced in the Skills catalog (team hub + shared). */ +export type DenOrgSkillCard = { + id: string; + title: string; + description: string | null; + skillText: string; + hubName: string | null; + shared: "org" | "public" | null; + updatedAt: string | null; +}; + +export type PluginInstallStep = { + title: string; + description: string; + command?: string; + url?: string; + path?: string; + note?: string; +}; + +export type SuggestedPlugin = { + name: string; + packageName: string; + description: string; + tags: string[]; + aliases?: string[]; + installMode?: "simple" | "guided"; + steps?: PluginInstallStep[]; +}; + +export type PluginScope = "project" | "global"; + +export type McpServerSource = "config.project" | "config.global" | "config.remote"; + +export type McpServerConfig = { + type: "remote" | "local"; + url?: string; + command?: string[]; + enabled?: boolean; + headers?: Record; + environment?: Record; + oauth?: Record | false; + timeout?: number; +}; + +export type McpServerEntry = { + name: string; + config: McpServerConfig; + source?: McpServerSource; +}; + +export type McpStatus = + | { status: "connected" } + | { status: "disabled" } + | { status: "failed"; error: string } + | { status: "needs_auth" } + | { status: "needs_client_registration"; error: string }; + +export type McpStatusMap = Record; + +export type ReloadReason = "plugins" | "skills" | "mcp" | "config" | "agents" | "commands"; + +export type OpencodeConnectStatus = { + at: number; + baseUrl: string; + directory?: string | null; + reason?: string | null; + status: "connecting" | "connected" | "error"; + error?: string | null; + metrics?: { + healthyMs?: number; + loadSessionsMs?: number; + pendingPermissionsMs?: number; + providersMs?: number; + totalMs?: number; + }; +}; + +export type ReloadTrigger = { + type: "skill" | "plugin" | "config" | "mcp" | "agent" | "command"; + name?: string; + action?: "added" | "removed" | "updated"; + path?: string; +}; + +export type PendingPermission = ApiPermissionRequest & { + receivedAt: number; +}; + +export type PendingQuestion = QuestionRequest & { + receivedAt: number; +}; + +export type TodoItem = { + id: string; + content: string; + status: string; + priority: string; +}; + +export type ModelRef = { + providerID: string; + modelID: string; +}; + +export type ModelBehaviorOption = { + value: string | null; + label: string; + description: string; +}; + +export type ModelOption = { + providerID: string; + modelID: string; + title: string; + description?: string; + footer?: string; + behaviorTitle: string; + behaviorLabel: string; + behaviorDescription: string; + behaviorValue: string | null; + behaviorOptions?: ModelBehaviorOption[]; + disabled?: boolean; + isFree: boolean; + isConnected: boolean; + isRecommended?: boolean; +}; + +export type SelectedSessionSnapshot = { + session: Session | null; + status: string; + modelLabel: string; +}; + +export type WorkspaceState = { + active: WorkspaceInfo | null; + path: string; + root: string; +}; + +export type PluginState = { + scope: PluginScope; + config: OpencodeConfigFile | null; + list: string[]; +}; + +export type WorkspaceDisplay = WorkspaceInfo & { + name: string; +}; + +export type UpdateHandle = { + available: boolean; + currentVersion: string; + version: string; + date?: string; + body?: string; + rawJson: Record; + close: () => Promise; + download: (onEvent?: (event: any) => void) => Promise; + install: () => Promise; + downloadAndInstall: (onEvent?: (event: any) => void) => Promise; +}; diff --git a/apps/app/src/app/utils/index.ts b/apps/app/src/app/utils/index.ts new file mode 100644 index 0000000000..aa556c228b --- /dev/null +++ b/apps/app/src/app/utils/index.ts @@ -0,0 +1,1062 @@ +import type { Part, Session } from "@opencode-ai/sdk/v2/client"; +import { t } from "../../i18n"; +import type { + ArtifactItem, + MessageGroup, + MessageInfo, + MessageWithParts, + ModelRef, + OpencodeEvent, + PlaceholderAssistantMessage, + ProviderListItem, +} from "../types"; +import type { WorkspaceInfo } from "../lib/desktop"; + +export function formatModelRef(model: ModelRef) { + return `${model.providerID}/${model.modelID}`; +} + +export function parseModelRef(raw: string | null): ModelRef | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const [providerID, ...rest] = trimmed.split("/"); + if (!providerID || rest.length === 0) return null; + return { providerID, modelID: rest.join("/") }; +} + +export function modelEquals(a: ModelRef, b: ModelRef) { + return a.providerID === b.providerID && a.modelID === b.modelID; +} + +const FRIENDLY_PROVIDER_LABELS: Record = { + opencode: "OpenCode", + openai: "OpenAI", + anthropic: "Anthropic", + google: "Google", + openrouter: "OpenRouter", +}; + +const humanizeModelLabel = (value: string) => { + const normalized = value.trim().toLowerCase(); + if (normalized && FRIENDLY_PROVIDER_LABELS[normalized]) { + return FRIENDLY_PROVIDER_LABELS[normalized]; + } + + const cleaned = value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); + if (!cleaned) return value; + + 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(" "); +}; + +export function formatModelLabel(model: ModelRef, providers: ProviderListItem[] = []) { + const provider = providers.find((p) => p.id === model.providerID); + const modelInfo = provider?.models?.[model.modelID]; + + const providerLabel = provider?.name ?? humanizeModelLabel(model.providerID); + const modelLabel = modelInfo?.name ?? humanizeModelLabel(model.modelID); + + return `${providerLabel} · ${modelLabel}`; +} + +export function isTauriRuntime() { + return typeof window !== "undefined" && (window as any).__TAURI_INTERNALS__ != null; +} + +export function isElectronRuntime() { + return typeof window !== "undefined" && (window as Window).__OPENWORK_ELECTRON__ != null; +} + +export function isDesktopRuntime() { + return isTauriRuntime() || isElectronRuntime(); +} + +export function isWindowsPlatform() { + if (typeof navigator === "undefined") return false; + + const ua = typeof navigator.userAgent === "string" ? navigator.userAgent : ""; + const platform = + typeof (navigator as any).userAgentData?.platform === "string" + ? (navigator as any).userAgentData.platform + : typeof navigator.platform === "string" + ? navigator.platform + : ""; + + return /windows/i.test(platform) || /windows/i.test(ua); +} + +export function isMacPlatform() { + if (typeof navigator === "undefined") return false; + + const ua = typeof navigator.userAgent === "string" ? navigator.userAgent : ""; + const platform = + typeof (navigator as any).userAgentData?.platform === "string" + ? (navigator as any).userAgentData.platform + : typeof navigator.platform === "string" + ? navigator.platform + : ""; + + return /mac/i.test(platform) || /macintosh|mac os x/i.test(ua); +} + +const STARTUP_PREF_KEY = "openwork.startupPref"; +const LEGACY_PREF_KEY = "openwork.modePref"; +const LEGACY_PREF_KEY_ALT = "openwork_mode_pref"; + +export function readStartupPreference(): "local" | "server" | null { + if (typeof window === "undefined") return null; + + try { + const pref = + window.localStorage.getItem(STARTUP_PREF_KEY) ?? + window.localStorage.getItem(LEGACY_PREF_KEY) ?? + window.localStorage.getItem(LEGACY_PREF_KEY_ALT); + + if (pref === "local" || pref === "server") return pref; + if (pref === "host") return "local"; + if (pref === "client") return "server"; + } catch { + // ignore + } + + return null; +} + +export function writeStartupPreference(nextPref: "local" | "server") { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(STARTUP_PREF_KEY, nextPref); + window.localStorage.removeItem(LEGACY_PREF_KEY); + window.localStorage.removeItem(LEGACY_PREF_KEY_ALT); + } catch { + // ignore + } +} + +export function clearStartupPreference() { + if (typeof window === "undefined") return; + + try { + window.localStorage.removeItem(STARTUP_PREF_KEY); + window.localStorage.removeItem(LEGACY_PREF_KEY); + window.localStorage.removeItem(LEGACY_PREF_KEY_ALT); + } catch { + // ignore + } +} + +export function safeStringify(value: unknown) { + const seen = new WeakSet(); + + try { + return JSON.stringify( + value, + (key, val) => { + if (val && typeof val === "object") { + if (seen.has(val as object)) { + return ""; + } + seen.add(val as object); + } + + const lowerKey = key.toLowerCase(); + if ( + lowerKey === "reasoningencryptedcontent" || + lowerKey.includes("api_key") || + lowerKey.includes("apikey") || + lowerKey.includes("access_token") || + lowerKey.includes("refresh_token") || + lowerKey.includes("token") || + lowerKey.includes("authorization") || + lowerKey.includes("cookie") || + lowerKey.includes("secret") + ) { + return "[redacted]"; + } + + return val; + }, + 2, + ); + } catch { + return ""; + } +} + +export function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"] as const; + const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / Math.pow(1024, idx); + const rounded = idx === 0 ? Math.round(value) : Math.round(value * 10) / 10; + return `${rounded} ${units[idx]}`; +} + +/** + * Convert a directory path to a forward-slash normalised form for **local** + * comparison only (e.g. case-insensitive matching via {@link normalizeDirectoryPath}). + * + * **Do NOT use this when building a directory value that will be sent to the + * OpenCode server** (session.list, session.create, mcp.status, etc.). The + * server compares directories with strict equality and on Windows it stores + * native backslash paths. Use + * {@link import("../lib/session-scope").toSessionTransportDirectory toSessionTransportDirectory} + * instead — it returns a branded {@link import("../lib/session-scope").TransportDirectory TransportDirectory} + * that the compiler can enforce. + */ +export function normalizeDirectoryQueryPath(input?: string | null) { + const trimmed = (input ?? "").trim(); + if (!trimmed) return ""; + const withoutVerbatim = /^\\\\\?\\UNC\\/i.test(trimmed) + ? `\\${trimmed.slice(7)}` + : /^\\\\\?\\[a-zA-Z]:[\\/]/.test(trimmed) + ? trimmed.slice(4) + : trimmed; + const unified = withoutVerbatim.replace(/\\/g, "/"); + const withoutTrailing = unified.replace(/\/+$/, ""); + return withoutTrailing || "/"; +} + +export function normalizeDirectoryPath(input?: string | null) { + const normalized = normalizeDirectoryQueryPath(input); + if (!normalized) return ""; + return isWindowsPlatform() || isMacPlatform() ? normalized.toLowerCase() : normalized; +} + +export function normalizeEvent(raw: unknown): OpencodeEvent | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const record = raw as Record; + + if (typeof record.type === "string") { + return { + type: record.type, + properties: record.properties, + }; + } + + if (record.payload && typeof record.payload === "object") { + const payload = record.payload as Record; + if (typeof payload.type === "string") { + return { + type: payload.type, + properties: payload.properties, + }; + } + } + + return null; +} + +export function formatRelativeTime(timestampMs: number) { + const delta = Date.now() - timestampMs; + + if (delta < 0) { + return t("time.just_now"); + } + + if (delta < 60_000) { + return t("time.seconds_ago", undefined, { count: Math.max(1, Math.round(delta / 1000)) }); + } + + if (delta < 60 * 60_000) { + return t("time.minutes_ago", undefined, { count: Math.max(1, Math.round(delta / 60_000)) }); + } + + if (delta < 24 * 60 * 60_000) { + return t("time.hours_ago", undefined, { count: Math.max(1, Math.round(delta / (60 * 60_000))) }); + } + + return new Date(timestampMs).toLocaleDateString(); +} + +export function addOpencodeCacheHint(message: string) { + const lower = message.toLowerCase(); + const cacheSignals = [ + ".cache/opencode", + "library/caches/opencode", + "appdata/local/opencode", + "fetch_jwks.js", + "opencode cache", + ]; + + if (cacheSignals.some((signal) => lower.includes(signal)) && lower.includes("enoent")) { + return `${message}\n\nOpenCode cache looks corrupted. Use Repair cache in Settings to rebuild it.`; + } + + return message; +} + +const SANDBOX_DOCKER_OFFLINE_HINTS = [ + "cannot connect to the docker daemon", + "is the docker daemon running", + "docker daemon", + "docker desktop", + "docker engine", + "error during connect", + "docker.sock", + "docker_socket", + "open //./pipe/docker_engine", +]; + +const SANDBOX_NETWORK_HINTS = [ + "failed to fetch", + "fetch failed", + "networkerror", + "request timed out", + "timeout", + "connection refused", + "econnrefused", + "connection reset", + "socket hang up", + "enotfound", + "getaddrinfo", + "could not connect", +]; + +export function isSandboxWorkspace(workspace: WorkspaceInfo) { + return ( + workspace.workspaceType === "remote" && + (workspace.sandboxBackend === "docker" || + workspace.sandboxBackend === "microsandbox" || + Boolean(workspace.sandboxRunId?.trim()) || + Boolean(workspace.sandboxContainerName?.trim())) + ); +} + +export function getWorkspaceTaskLoadErrorDisplay(workspace: WorkspaceInfo, error?: string | null) { + const raw = error?.trim() ?? ""; + const fallbackTitle = raw || "Failed to load tasks"; + if (!raw || !isSandboxWorkspace(workspace)) { + return { + tone: "error" as const, + label: "Error", + message: "Failed to load tasks", + title: fallbackTitle, + }; + } + + const normalized = raw.toLowerCase(); + const hasDockerHint = SANDBOX_DOCKER_OFFLINE_HINTS.some((hint) => normalized.includes(hint)); + const hasNetworkHint = SANDBOX_NETWORK_HINTS.some((hint) => normalized.includes(hint)); + const host = `${workspace.baseUrl ?? ""} ${workspace.openworkHostUrl ?? ""}`.toLowerCase(); + const localHost = host.includes("localhost") || host.includes("127.0.0.1"); + + if (!hasDockerHint && !(localHost && hasNetworkHint)) { + return { + tone: "error" as const, + label: "Error", + message: "Failed to load tasks", + title: fallbackTitle, + }; + } + + const message = "Sandbox is offline. Start Docker Desktop, then test connection."; + return { + tone: "offline" as const, + label: "Offline", + message, + title: `${message}\n\n${raw}`, + }; +} + +export function parseTemplateFrontmatter(raw: string) { + const trimmed = raw.trimStart(); + if (!trimmed.startsWith("---")) return null; + const endIndex = trimmed.indexOf("\n---", 3); + if (endIndex === -1) return null; + const header = trimmed.slice(3, endIndex).trim(); + const body = trimmed.slice(endIndex + 4).replace(/^\r?\n/, ""); + const data: Record = {}; + + const unescapeValue = (value: string) => { + if (value.startsWith("\"") && value.endsWith("\"")) { + const inner = value.slice(1, -1); + return inner.replace(/\\(\\|\"|n|r|t)/g, (_match, code) => { + switch (code) { + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + case "\\": + return "\\"; + case "\"": + return "\""; + default: + return code; + } + }); + } + + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/''/g, "'"); + } + + return value; + }; + + for (const line of header.split(/\r?\n/)) { + const entry = line.trim(); + if (!entry) continue; + const colonIndex = entry.indexOf(":"); + if (colonIndex === -1) continue; + const key = entry.slice(0, colonIndex).trim(); + let value = entry.slice(colonIndex + 1).trim(); + if (!key) continue; + value = unescapeValue(value); + data[key] = value; + } + + return { data, body }; +} + +export function upsertSession(list: Session[], next: Session) { + const idx = list.findIndex((s) => s.id === next.id); + if (idx === -1) return [...list, next]; + + const copy = list.slice(); + copy[idx] = next; + return copy; +} + +export function normalizeSessionStatus(status: unknown) { + if (!status || typeof status !== "object") return "idle"; + const record = status as Record; + if (record.type === "busy") return "running"; + if (record.type === "retry") return "retry"; + if (record.type === "idle") return "idle"; + return "idle"; +} + +export function modelFromUserMessage(info: MessageInfo): ModelRef | null { + if (!info || typeof info !== "object") return null; + if ((info as any).role !== "user") return null; + + const model = (info as any).model as unknown; + if (!model || typeof model !== "object") return null; + + const providerID = (model as any).providerID; + const modelID = (model as any).modelID; + + if (typeof providerID !== "string" || typeof modelID !== "string") return null; + return { providerID, modelID }; +} + +export function lastUserModelFromMessages(list: MessageWithParts[]): ModelRef | null { + for (let i = list.length - 1; i >= 0; i -= 1) { + const model = modelFromUserMessage(list[i]?.info); + if (model) return model; + } + + return null; +} + +export function isStepPart(part: Part) { + return part.type === "reasoning" || part.type === "tool"; +} + +export function isUserVisiblePart(part: Part) { + const flags = part as { synthetic?: boolean; ignored?: boolean }; + return !flags.synthetic && !flags.ignored; +} + +export function isVisibleTextPart(part: Part) { + return part.type === "text" && isUserVisiblePart(part); +} + +const EXPLORATION_TOOL_NAMES = new Set(["read", "glob", "grep", "search", "list", "list_files"]); + +function isExplorationToolPart(part: Part) { + if (part.type !== "tool") return false; + const tool = typeof (part as any).tool === "string" ? String((part as any).tool).toLowerCase() : ""; + return EXPLORATION_TOOL_NAMES.has(tool); +} + +export function groupMessageParts(parts: Part[], messageId: string): MessageGroup[] { + const groups: MessageGroup[] = []; + const explorationSteps: Part[] = []; + let textBuffer = ""; + let stepGroupIndex = 0; + let sawExecution = false; + + const flushText = () => { + if (!textBuffer) return; + groups.push({ + kind: "text", + part: { type: "text", text: textBuffer } as Part, + segment: sawExecution ? "result" : "intent", + }); + textBuffer = ""; + }; + + const pushSteps = (stepParts: Part[], mode: "exploration" | "standalone") => { + if (!stepParts.length) return; + groups.push({ + kind: "steps", + id: `steps-${messageId}-${stepGroupIndex}`, + parts: stepParts, + segment: "execution", + mode, + }); + stepGroupIndex += 1; + sawExecution = true; + }; + + const flushExplorationSteps = () => { + if (!explorationSteps.length) return; + pushSteps(explorationSteps.splice(0, explorationSteps.length), "exploration"); + }; + + parts.forEach((part) => { + if (part.type === "text") { + if (!isVisibleTextPart(part)) { + return; + } + flushExplorationSteps(); + textBuffer += (part as { text?: string }).text ?? ""; + return; + } + + if (part.type === "agent") { + flushExplorationSteps(); + const name = (part as { name?: string }).name ?? ""; + textBuffer += name ? `@${name}` : "@agent"; + return; + } + + if (part.type === "file") { + flushExplorationSteps(); + flushText(); + groups.push({ kind: "text", part, segment: sawExecution ? "result" : "intent" }); + return; + } + + if (part.type === "step-start" || part.type === "step-finish") { + return; + } + + flushText(); + + if (isExplorationToolPart(part)) { + explorationSteps.push(part); + return; + } + + if (part.type === "reasoning" && explorationSteps.length > 0) { + explorationSteps.push(part); + return; + } + + flushExplorationSteps(); + pushSteps([part], "standalone"); + }); + + flushText(); + + flushExplorationSteps(); + + return groups; +} + +/** Classify a tool name into a semantic category for icon selection */ +export function classifyTool(toolName: string): "read" | "edit" | "write" | "search" | "terminal" | "glob" | "task" | "skill" | "tool" { + const lower = toolName.toLowerCase(); + if (lower === "skill") return "skill"; + if (lower.includes("read") || lower.includes("cat") || lower.includes("fetch")) return "read"; + if (lower === "apply_patch") return "write"; + if (lower.includes("edit") || lower.includes("replace") || lower.includes("update")) return "edit"; + if (lower.includes("write") || lower.includes("create") || lower.includes("patch")) return "write"; + if (lower.includes("grep") || lower.includes("search") || lower.includes("find")) return "search"; + if (lower.includes("bash") || lower.includes("shell") || lower.includes("exec") || lower.includes("command") || lower.includes("run")) return "terminal"; + if (lower.includes("glob") || lower.includes("list") || lower.includes("ls")) return "glob"; + if (lower.includes("task") || lower.includes("agent") || lower.includes("todo")) return "task"; + return "tool"; +} + +/** Extract a clean filename from a file path */ +function extractFilename(filePath: string): string { + const parts = filePath.replace(/\\/g, "/").split("/"); + return parts[parts.length - 1] || filePath; +} + +function normalizeStepText(value: unknown): string { + if (typeof value !== "string") return ""; + return value.replace(/\s+/g, " ").trim(); +} + +function cleanReasoningText(value: string): string { + return value + .replace(/\[REDACTED\]/g, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .trim(); +} + +function truncateStepText(value: string, max = 80): string { + return value.length > max ? `${value.slice(0, Math.max(0, max - 3))}...` : value; +} + +function isPathLike(value: string): boolean { + return /^(?:[A-Za-z]:[\\/]|~[\\/]|\/|\.\.?[\\/])/.test(value) || /[\\/]/.test(value); +} + +function normalizePathToken(value: string): string { + const clean = value.trim().replace(/^[`'"([{]+|[`'"\])},.;:]+$/g, ""); + if (!isPathLike(clean)) return clean; + return extractFilename(clean); +} + +function formatAgentLabel(value: string): string { + const clean = value.trim().replace(/[_-]+/g, " "); + if (!clean) return ""; + return clean + .split(/\s+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function getToolInput(state: any): Record { + const input = state?.input; + if (input && typeof input === "object") return input as Record; + return {}; +} + +function pickInputText(input: Record, keys: string[]): string { + for (const key of keys) { + const value = input[key]; + const text = normalizeStepText(value); + if (text) return text; + } + return ""; +} + +function buildToolTitle(state: any, toolName: string): string { + const lower = toolName.toLowerCase(); + const input = getToolInput(state); + const pick = (...keys: string[]) => pickInputText(input, keys); + const file = (...keys: string[]) => { + const value = pick(...keys); + if (!value) return ""; + return normalizePathToken(value); + }; + + if (lower === "read") { + const target = file("filePath", "path", "file"); + return target ? `Reviewed ${target}` : "Reviewed file"; + } + + if (lower === "edit") { + const target = file("filePath", "path", "file"); + return target ? `Updated ${target}` : "Updated file"; + } + + if (lower === "write") { + const target = file("filePath", "path", "file"); + return target ? `Write ${target}` : "Write file"; + } + + if (lower === "apply_patch") { + return "Apply patch"; + } + + if (lower === "list" || lower === "list_files") { + const target = file("path"); + return target ? `Reviewed ${target}` : "Reviewed files"; + } + + if (lower === "grep" || lower === "glob" || lower === "search") { + const pattern = pick("pattern", "query"); + return pattern ? `Searched ${truncateStepText(pattern, 44)}` : "Searched code"; + } + + if (lower === "bash") { + const description = pick("description"); + if (description) return truncateStepText(description, 56); + const command = pick("command", "cmd"); + if (command) return truncateStepText(`Run ${command}`, 56); + return "Run command"; + } + + if (lower === "task") { + const agent = formatAgentLabel(pick("subagent_type")); + if (agent) return `${agent} task`; + return "Task"; + } + + if (lower === "todowrite") { + return "Update todo list"; + } + + if (lower === "todoread") { + return "Read todo list"; + } + + if (lower === "webfetch") { + const url = pick("url"); + return url ? `Checked ${truncateStepText(url, 44)}` : "Checked web page"; + } + + if (lower === "skill") { + const name = pick("name"); + return name ? `Load skill ${name}` : "Load skill"; + } + + const stateTitle = normalizeStepText(state?.title); + if (stateTitle) { + return truncateStepText(isPathLike(stateTitle) ? normalizePathToken(stateTitle) : stateTitle, 56); + } + + const fallback = normalizeStepText(toolName) + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " "); + return fallback || "Tool"; +} + +/** Build a concise detail line for a tool call — avoids dumping raw output */ +function buildToolDetail(state: any, toolName: string): string | undefined { + const lower = toolName.toLowerCase(); + const input = getToolInput(state); + const pick = (...keys: string[]) => pickInputText(input, keys); + + if (lower === "read") { + const chunks: string[] = []; + const offset = input.offset; + const limit = input.limit; + if (typeof offset === "number") chunks.push(`offset ${offset}`); + if (typeof limit === "number") chunks.push(`limit ${limit}`); + if (chunks.length > 0) return chunks.join(" - "); + return undefined; + } + + if (lower === "bash") { + const command = pick("command", "cmd"); + if (command) return truncateStepText(command, 80); + } + + if (lower === "grep" || lower === "glob" || lower === "search") { + const root = pick("path"); + if (root) return `in ${normalizePathToken(root)}`; + } + + if (lower === "task") { + const description = pick("description"); + if (description) return truncateStepText(description, 80); + const agent = formatAgentLabel(pick("subagent_type")); + if (agent) return `${agent} agent`; + } + + if (lower === "todowrite" || lower === "todoread") { + return undefined; + } + + if (lower === "webfetch") { + const url = pick("url"); + if (url) return truncateStepText(url, 80); + } + + // For file operations, show the filename + const filePath = state?.path ?? state?.file; + if (typeof filePath === "string" && filePath.trim()) { + const name = extractFilename(filePath.trim()); + const status = state?.status; + if (status === "completed" || status === "done") { + return name; + } + return name; + } + + // For edits that report updated files, show filename(s) + const files = state?.files; + if (Array.isArray(files) && files.length > 0) { + const names = files.filter((f: any) => typeof f === "string").map(extractFilename); + if (names.length === 1) return names[0]; + if (names.length > 1) return `${names[0]} +${names.length - 1} more`; + } + + // For bash/terminal commands, show the command + const command = state?.command ?? state?.cmd; + if (typeof command === "string" && command.trim()) { + const clean = command.trim(); + return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean; + } + + // For search/grep, show the pattern + const pattern = state?.pattern ?? state?.query; + if (typeof pattern === "string" && pattern.trim()) { + return `"${pattern.trim().length > 60 ? pattern.trim().slice(0, 57) + "..." : pattern.trim()}"`; + } + + // Subtitle/detail from state as fallback + const subtitle = state?.subtitle ?? state?.detail ?? state?.summary; + if (typeof subtitle === "string" && subtitle.trim()) { + const clean = subtitle.trim(); + return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean; + } + + // For completed tools with output, show a very short summary + const outputRaw = typeof state?.output === "string" ? state.output.trim() : ""; + if (outputRaw) { + if (lower === "read") return undefined; + + const output = outputRaw.length > 3000 ? outputRaw.slice(0, 3000) : outputRaw; + + // Extract just the first meaningful line (skip line numbers and raw file markers) + const lines = output.split("\n").filter((l: string) => { + const trimmed = l.trim(); + return ( + trimmed && + !trimmed.startsWith("") && + !trimmed.startsWith("") && + !trimmed.startsWith("") && + !trimmed.startsWith("") && + !trimmed.startsWith("") && + !/^\d{5}\|/.test(trimmed) && + !/^\d+:\s/.test(trimmed) + ); + }); + if (lines.length > 0) { + const first = lines[0].trim(); + if (first.startsWith("Success")) { + // "Success. Updated the following files: M foo.ts" -> "foo.ts" + const match = first.match(/:\s*[MADR]\s+(.+)/); + if (match) return extractFilename(match[1].trim()); + return "Done"; + } + return first.length > 80 ? `${first.slice(0, 77)}...` : first; + } + } + + return undefined; +} + +const ARTIFACT_PATH_PATTERN = + /(?:^|[\s"'`([{])((?:[a-zA-Z]:[/\\]|\.{1,2}[/\\]|~[/\\]|[/\\])[\w./\\\-]*\.[a-z][a-z0-9]{0,9}|[\w.\-]+[/\\][\w./\\\-]*\.[a-z][a-z0-9]{0,9})/gi; +const ARTIFACT_OUTPUT_SCAN_LIMIT = 4000; +const ARTIFACT_OUTPUT_SKIP_TOOLS = new Set(["webfetch"]); + +// Patterns that indicate a path is a truncated system/absolute path rather than a workspace-relative path +const TRUNCATED_SYSTEM_PATH_PATTERNS = [ + /com\.[^/]+\.(openwork|opencode)/i, // macOS app bundle identifiers + /\.openwork\.dev\//i, // OpenWork dev paths + /Application Support\//i, // macOS Application Support + /AppData[/\\]/i, // Windows AppData + /\.local\/share\//i, // Linux XDG data + /workspaces\/[^/]+\/workspaces\//i, // Nested workspaces paths (clearly malformed) +]; + +/** + * Clean up an artifact path to extract the workspace-relative portion. + * Returns null if the path should be rejected entirely. + */ +function cleanArtifactPath(rawPath: string): string | null { + const normalized = rawPath.trim().replace(/[\\/]+/g, "/"); + if (!normalized) return null; + + // Check if this looks like a truncated system path + for (const pattern of TRUNCATED_SYSTEM_PATH_PATTERNS) { + if (pattern.test(normalized)) { + // Try to extract just the relative part after "workspaces//" + const workspacesMatch = normalized.match(/workspaces\/[^/]+\/(.+)$/i); + if (workspacesMatch && workspacesMatch[1]) { + const relative = workspacesMatch[1]; + // Validate the extracted path doesn't still contain system patterns + if (!TRUNCATED_SYSTEM_PATH_PATTERNS.some((p) => p.test(relative))) { + return relative; + } + } + // Reject the path entirely if we can't extract a clean relative path + return null; + } + } + + return normalized; +} + +type DeriveArtifactsOptions = { + maxMessages?: number; +}; + +export function summarizeStep(part: Part): { title: string; detail?: string; isSkill?: boolean; skillName?: string; toolCategory?: string; status?: string } { + if (part.type === "tool") { + const record = part as any; + const toolName = record.tool ? String(record.tool) : "Tool"; + const state = record.state ?? {}; + const title = buildToolTitle(state, toolName); + const category = classifyTool(toolName); + const status = state.status ? String(state.status) : undefined; + const detail = buildToolDetail(state, toolName); + const normalizedTitle = normalizeStepText(title).toLowerCase(); + const finalDetail = detail && normalizeStepText(detail).toLowerCase() !== normalizedTitle ? detail : undefined; + + // Detect skill trigger + if (category === "skill") { + const skillName = state.metadata?.name || title.replace(/^(Loaded skill:\s*|Load skill\s+)/i, ""); + return { title, isSkill: true, skillName, detail: finalDetail, toolCategory: category, status }; + } + + return { title, detail: finalDetail, toolCategory: category, status }; + } + + if (part.type === "reasoning") { + const record = part as any; + const text = typeof record.text === "string" ? cleanReasoningText(record.text) : ""; + if (!text) return { title: "Thinking", toolCategory: "tool" }; + + const lines = text + .split(/\r?\n/) + .map((line: string) => line.trim()) + .filter(Boolean); + const compact = lines.join(" "); + + let headline = ""; + let detail = ""; + if (lines.length > 1) { + headline = lines[0]; + detail = lines.slice(1).join("\n"); + } else { + const sentenceBreak = compact.indexOf(". "); + if (sentenceBreak > 18 && sentenceBreak < 120) { + headline = compact.slice(0, sentenceBreak + 1).trim(); + detail = compact.slice(sentenceBreak + 2).trim(); + } else { + headline = compact; + detail = compact; + } + } + + headline = headline.replace(/^thinking[:\s-]*/i, "").trim(); + const title = truncateStepText(headline || "Thinking", 96); + return { title, detail: detail || undefined, toolCategory: "tool" }; + } + + if (part.type === "step-start" || part.type === "step-finish") { + const reason = (part as any).reason; + return { + title: part.type === "step-start" ? "Step started" : "Step finished", + detail: reason ? String(reason) : undefined, + toolCategory: "tool", + }; + } + + return { title: "Step", toolCategory: "tool" }; +} + +export function deriveArtifacts(list: MessageWithParts[], options: DeriveArtifactsOptions = {}): ArtifactItem[] { + const results = new Map(); + const maxMessages = + typeof options.maxMessages === "number" && Number.isFinite(options.maxMessages) && options.maxMessages > 0 + ? Math.floor(options.maxMessages) + : null; + const source = maxMessages && list.length > maxMessages ? list.slice(list.length - maxMessages) : list; + + source.forEach((message) => { + const messageId = String((message.info as any)?.id ?? ""); + + message.parts.forEach((part) => { + if (part.type !== "tool") return; + const record = part as any; + const state = record.state ?? {}; + const matches = new Set(); + + const explicit = [ + state.path, + state.file, + ...(Array.isArray(state.files) ? state.files : []), + ]; + + explicit.forEach((f) => { + if (typeof f === "string") { + const trimmed = f.trim(); + if ( + trimmed.length > 0 && + trimmed.length <= 500 && + trimmed.includes(".") && + !/^\.{2,}$/.test(trimmed) + ) { + matches.add(trimmed); + } + } + }); + + const toolName = + typeof record.tool === "string" && record.tool.trim() + ? record.tool.trim().toLowerCase() + : ""; + const titleText = typeof state.title === "string" ? state.title : ""; + const outputText = + typeof state.output === "string" && !ARTIFACT_OUTPUT_SKIP_TOOLS.has(toolName) + ? state.output.slice(0, ARTIFACT_OUTPUT_SCAN_LIMIT) + : ""; + + const text = [titleText, outputText] + .filter((v): v is string => Boolean(v)) + .join(" "); + + if (text) { + ARTIFACT_PATH_PATTERN.lastIndex = 0; + Array.from(text.matchAll(ARTIFACT_PATH_PATTERN)) + .map((m) => m[1]) + .filter((f) => f && f.length <= 500) + .forEach((f) => matches.add(f)); + } + + if (matches.size === 0) return; + + matches.forEach((match) => { + const cleanedPath = cleanArtifactPath(match); + if (!cleanedPath) return; + + const key = cleanedPath.toLowerCase(); + const name = cleanedPath.split("/").pop() ?? cleanedPath; + const id = `artifact-${encodeURIComponent(cleanedPath)}`; + + // Delete and re-add to move to end (most recent) + if (results.has(key)) results.delete(key); + results.set(key, { + id, + name, + path: cleanedPath, + kind: "file" as const, + size: state.size ? String(state.size) : undefined, + messageId: messageId || undefined, + }); + }); + }); + }); + + return Array.from(results.values()); +} + +export function deriveWorkingFiles(items: ArtifactItem[]): string[] { + const results: string[] = []; + const seen = new Set(); + + for (const item of items) { + const rawKey = item.path ?? item.name; + const normalizedPath = rawKey.trim().replace(/[\\/]+/g, "/"); + const normalizedKey = normalizedPath.toLowerCase(); + if (!normalizedPath || seen.has(normalizedKey)) continue; + seen.add(normalizedKey); + results.push(normalizedPath); + if (results.length >= 5) break; + } + + return results; +} diff --git a/apps/app/src/app/utils/plugins.ts b/apps/app/src/app/utils/plugins.ts new file mode 100644 index 0000000000..8026a6477d --- /dev/null +++ b/apps/app/src/app/utils/plugins.ts @@ -0,0 +1,88 @@ +import { parse } from "jsonc-parser"; + +import type { OpencodeConfigFile } from "../lib/desktop"; + +type PluginListValue = string | string[] | null | undefined; + +type PluginConfig = { + content: string | null; +} | null; + +export function normalizePluginList(value: PluginListValue) { + if (!value) return [] as string[]; + if (Array.isArray(value)) { + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + return [] as string[]; +} + +export function stripPluginVersion(spec: string) { + const trimmed = spec.trim(); + if (!trimmed) return ""; + + const looksLikeVersion = (suffix: string) => + /^(latest|next|beta|alpha|canary|rc|stable|\d)/i.test(suffix); + + if (trimmed.startsWith("@")) { + const slashIndex = trimmed.indexOf("/"); + if (slashIndex === -1) return trimmed; + + const atIndex = trimmed.indexOf("@", slashIndex + 1); + if (atIndex === -1) return trimmed; + + const suffix = trimmed.slice(atIndex + 1); + return looksLikeVersion(suffix) ? trimmed.slice(0, atIndex) : trimmed; + } + + const atIndex = trimmed.indexOf("@"); + if (atIndex === -1) return trimmed; + + const suffix = trimmed.slice(atIndex + 1); + return looksLikeVersion(suffix) ? trimmed.slice(0, atIndex) : trimmed; +} + +export function isPluginInstalled(pluginList: string[], pluginName: string, aliases: string[] = []) { + const normalized = pluginList.flatMap((entry) => { + const raw = entry.toLowerCase(); + const stripped = stripPluginVersion(entry).toLowerCase(); + return stripped && stripped !== raw ? [raw, stripped] : [raw]; + }); + + const list = new Set(normalized); + return [pluginName, ...aliases].some((entry) => list.has(entry.toLowerCase())); +} + +export function loadPluginsFromConfig( + config: PluginConfig, + onList: (next: string[]) => void, + onError: (message: string) => void, +) { + if (!config?.content) { + onList([]); + return; + } + + try { + const parsed = parse(config.content) as Record | undefined; + const next = normalizePluginList(parsed?.plugin as PluginListValue); + onList(next); + } catch (e) { + onList([]); + onError(e instanceof Error ? e.message : "Failed to parse opencode.json"); + } +} + +export function parsePluginListFromContent(content: string) { + try { + const parsed = parse(content) as Record | undefined; + return normalizePluginList(parsed?.plugin as PluginListValue); + } catch { + return [] as string[]; + } +} diff --git a/apps/app/src/app/utils/providers.ts b/apps/app/src/app/utils/providers.ts new file mode 100644 index 0000000000..966c0822b6 --- /dev/null +++ b/apps/app/src/app/utils/providers.ts @@ -0,0 +1,51 @@ +import type { Provider as ConfigProvider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"; + +const PINNED_PROVIDER_ORDER = ["opencode", "openai", "anthropic"] as const; + +export const providerPriorityRank = (id: string) => { + const normalized = id.trim().toLowerCase(); + const index = PINNED_PROVIDER_ORDER.indexOf( + normalized as (typeof PINNED_PROVIDER_ORDER)[number], + ); + return index === -1 ? PINNED_PROVIDER_ORDER.length : index; +}; + +export const compareProviders = ( + a: { id: string; name?: string }, + b: { id: string; name?: string }, +) => { + const rankDiff = providerPriorityRank(a.id) - providerPriorityRank(b.id); + if (rankDiff !== 0) return rankDiff; + + const aName = (a.name ?? a.id).trim(); + const bName = (b.name ?? b.id).trim(); + return aName.localeCompare(bName); +}; + +// Starting with @opencode-ai/sdk@1.4.x, `ConfigProvider` (from `config.providers()`) +// and the provider items in `ProviderListResponse.all` share the same shape, so +// this mapper is effectively an identity function. It is kept for call-site +// stability and to normalize optional fields (`name`, `env`). +export const mapConfigProvidersToList = ( + providers: ConfigProvider[], +): ProviderListResponse["all"] => + providers.map((provider) => ({ + ...provider, + name: provider.name ?? provider.id, + env: provider.env ?? [], + })); + +export const filterProviderList = ( + value: ProviderListResponse, + disabledProviders: string[], +): ProviderListResponse => { + const disabled = new Set(disabledProviders.map((id) => id.trim()).filter(Boolean)); + if (!disabled.size) return value; + return { + all: value.all.filter((provider) => !disabled.has(provider.id)), + connected: value.connected.filter((id) => !disabled.has(id)), + default: Object.fromEntries( + Object.entries(value.default).filter(([id]) => !disabled.has(id)), + ), + }; +}; diff --git a/apps/app/src/i18n/index.ts b/apps/app/src/i18n/index.ts new file mode 100644 index 0000000000..d989368b73 --- /dev/null +++ b/apps/app/src/i18n/index.ts @@ -0,0 +1,156 @@ +import en from "./locales/en"; +import ja from "./locales/ja"; +import zh from "./locales/zh"; +import vi from "./locales/vi"; +import ptBR from "./locales/pt-BR"; +import th from "./locales/th"; +import fr from "./locales/fr"; +import ca from "./locales/ca"; +import es from "./locales/es"; +import { LANGUAGE_PREF_KEY } from "../app/constants"; + +/** + * Supported languages + */ +export type Language = "en" | "ja" | "zh" | "vi" | "pt-BR" | "th" | "fr" | "ca" | "es"; +export type Locale = Language; + +/** + * All supported languages - single source of truth + */ +export const LANGUAGES: Language[] = ["en", "ja", "zh", "vi", "pt-BR", "th", "fr", "ca", "es"]; + +/** + * Language options for UI - single source of truth + */ +export const LANGUAGE_OPTIONS = [ + { value: "en" as Language, label: "English", nativeName: "English" }, + { value: "ja" as Language, label: "日本語", nativeName: "日本語" }, + { value: "zh" as Language, label: "简体中文", nativeName: "简体中文" }, + { value: "vi" as Language, label: "Vietnamese", nativeName: "Tiếng Việt" }, + { value: "pt-BR" as Language, label: "Portuguese (BR)", nativeName: "Português (BR)" }, + { value: "th" as Language, label: "ไทย", nativeName: "ไทย" }, + { value: "fr" as Language, label: "French", nativeName: "Français" }, + { value: "ca" as Language, label: "Català", nativeName: "Català" }, + { value: "es" as Language, label: "Español", nativeName: "Español" }, +] as const; + +/** + * Translation maps + */ +const TRANSLATIONS: Record> = { + en, + ja, + zh, + vi, + "pt-BR": ptBR, + th, + fr, + ca, + es, +}; + +/** + * Type guard to validate if a value is a Language + * Replaces long chains like: value === "en" || value === "zh" + */ +export const isLanguage = (value: unknown): value is Language => { + return typeof value === "string" && LANGUAGES.includes(value as Language); +}; + +let localeValue: Language = "en"; + +/** + * Get current locale + */ +export const currentLocale = (): Language => locale(); +function locale(): Language { + return localeValue; +} + +/** + * Set locale and persist to localStorage + */ +export const setLocale = (newLocale: Language) => { + if (!isLanguage(newLocale)) { + console.warn(`Invalid locale: ${newLocale}, falling back to "en"`); + newLocale = "en"; + } + + localeValue = newLocale; + + if (typeof document !== "undefined") { + document.documentElement.setAttribute("lang", newLocale); + } + + // Persist to localStorage + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(LANGUAGE_PREF_KEY, newLocale); + } catch (e) { + console.warn("Failed to persist language preference:", e); + } + } +}; + +/** + * Translation function with fallback behavior + * Fallback chain: target language → English → key itself + * + * @param key - Translation key + * @param localeOverride - Optional locale override (defaults to current locale) + * @returns Translated string or fallback + */ +export const t = (key: string, localeOverride?: Language, params?: Record): string => { + const loc = localeOverride ?? locale(); + + // Try target language first + let result: string; + if (TRANSLATIONS[loc]?.[key]) { + result = TRANSLATIONS[loc][key]; + } else if (loc !== "en" && TRANSLATIONS.en?.[key]) { + // Fallback to English + result = TRANSLATIONS.en[key]; + } else { + // Final fallback to key itself (prevents raw keys from showing in UI) + return key; + } + + // Replace params if provided + if (params) { + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, String(v)); + } + } + + return result; +}; + +/** + * Initialize locale from localStorage + * Call this during app initialization + */ +export const initLocale = (): Language => { + if (typeof window === "undefined") { + return "en"; + } + + try { + const stored = window.localStorage.getItem(LANGUAGE_PREF_KEY); + if (isLanguage(stored)) { + localeValue = stored; + if (typeof document !== "undefined") { + document.documentElement.setAttribute("lang", stored); + } + return stored; + } + } catch (e) { + console.warn("Failed to read language preference:", e); + } + + if (typeof document !== "undefined") { + document.documentElement.setAttribute("lang", "en"); + } + + return "en"; +}; diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts new file mode 100644 index 0000000000..12b9d68e39 --- /dev/null +++ b/apps/app/src/i18n/locales/ca.ts @@ -0,0 +1,2021 @@ +/** + * Catalan translations (Català) + * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) are NOT translated + */ + +export default { + "app.compact_command_desc": "Resumeix aquesta sessió per reduir la mida del context.", + "app.connection_lost": "S'ha perdut la connexió del servidor. Recarrega.", + "app.deep_link_auth_queued": "Hem posat en cua el deep link d'autenticació d'OpenWork Cloud.", + "app.deep_link_remote_queued": "Hem posat en cua l'enllaç del worker remot. OpenWork hauria d'intentar connectar.", + "app.error.choose_folder": "Tria una carpeta per continuar.", + "app.error.host_requires_local": "Selecciona un workspace local per iniciar el motor.", + "app.error.install_failed": "La instal·lació d'OpenCode ha fallat. Mira els registres de més amunt.", + "app.error.pick_workspace_folder": "Tria primer una carpeta de workspace.", + "app.error.remote_base_url_required": "Afegeix l’URL del servidor per continuar.", + "app.error.tauri_required": "Aquesta acció requereix el temps d'execució de l'aplicació d'escriptori d'OpenWork.", + "app.error_audit_load": "No s'ha pogut carregar el registre d'auditoria.", + "app.error_auth_failed": "L'autenticació ha fallat", + "app.error_auto_compact_scope": "La compactació automàtica del context només es pot canviar per a un workspace local o per a un workspace d'un servidor OpenWork amb permisos d'escriptura.", + "app.error_cloud_signin": "No s'ha pogut completar l'inici de sessió a OpenWork Cloud.", + "app.error_command_not_resolved": "No s'ha pogut resoldre la comanda.", + "app.error_compact_empty": "Encara no hi ha res per compactar.", + "app.error_compact_no_session": "Selecciona una sessió amb missatges abans d'executar /compact.", + "app.error_compact_no_session_id": "Selecciona una sessió abans de compactar.", + "app.error_connect_first": "Connecta't a aquest worker abans d'aplicar canvis de runtime.", + "app.error_connection_failed": "La connexió ha fallat", + "app.error_connection_failed_url": "La connexió ha fallat. Comprova l'URL i el token.", + "app.error_deep_link_unrecognized": "Aquest enllaç no és cap deep link d'OpenWork reconegut ni una URL compartida.", + "app.error_desktop_signin": "S'ha completat l'inici de sessió a l'escriptori, però OpenWork Cloud no ha retornat cap token de sessió.", + "app.error_not_connected": "No connectat a un servidor", + "app.error_pick_local_folder": "Tria una carpeta local del worker abans de reiniciar el servidor local.", + "app.error_rate_limit": "Has superat el límit de peticions", + "app.error_remote_access": "No s'ha pogut actualitzar l'accés remot.", + "app.error_request_failed": "La sol·licitud ha fallat", + "app.error_reset_config": "No s'han pogut restablir els valors predeterminats de la configuració de l'aplicació.", + "app.error_restart_local_worker": "No s'ha pogut reiniciar el worker local amb la configuració de compartició actualitzada.", + "app.error_runtime_changes": "No s'han pogut aplicar els canvis de runtime.", + "app.error_session_name_required": "El nom de la sessió és obligatori", + "app.error_update_opencode_json": "No s'ha pogut actualitzar opencode.json", + "app.import_bundle_desc": "Tria com vols importar aquest paquet.", + "app.import_shared_bundle": "Importa el paquet compartit", + "app.local_disabled_reason": "Crea workspaces locals des de l'app d'escriptori. Els workspaces remots i compartits aquí continuen funcionant.", + "app.local_worker_detail": "Worker local", + "app.model_behavior_desc": "Tria primer el model per veure els controls de comportament específics del proveïdor.", + "app.model_behavior_title": "Comportament del model", + "app.plugins_hint_disconnected": "El servidor OpenWork no està disponible. Els Plugins són de només lectura.", + "app.plugins_hint_limited": "El servidor OpenWork necessita un token per editar Plugins.", + "app.plugins_hint_readonly": "El servidor OpenWork és de només lectura per als Plugins.", + "app.reload_later": "Més tard", + "app.reload_now": "Recarrega ara", + "app.reload_stop_tasks": "Recarrega i atura les tasques", + "app.remote_worker_detail": "Worker remot", + "app.reset_config_ok": "Restableix els valors predeterminats de la configuració de l'aplicació. Reinicia OpenWork si hi ha cap configuració obsoleta.", + "app.shared_setup": "Configuració compartida", + "app.skill_added": "Skill afegida", + "app.skills_hint_disconnected": "El servidor OpenWork no està disponible. Afegeix l'URL i el token del servidor a Avançat per gestionar les Skills.", + "app.skills_hint_limited": "El servidor OpenWork necessita un token d'host per instal·lar o actualitzar Skills. Afegeix-lo a Avançat i torna't a connectar.", + "app.skills_hint_readonly": "El servidor OpenWork és de només lectura per a Skills. Afegeix un token d'host a Avançat per habilitar-ne la instal·lació.", + "app.unknown_error": "Error desconegut", + "app.worker_fallback": "worker", + "blueprint.automation_body": "Comença amb un flux de treball reutilitzable o escriu la teva pròpia tasca aquí sota.", + "blueprint.automation_title": "Què vols automatitzar?", + "blueprint.csv_session_assistant": "Et puc ajudar a generar, netejar, combinar i resumir fitxers CSV. Quin tipus de feina amb CSV vols automatitzar?", + "blueprint.csv_session_title": "Idees de flux de treball CSV", + "blueprint.csv_session_user": "Vull combinar les exportacions de diverses eines en un sol CSV.", + "blueprint.empty_body": "Tria un punt de partida o escriu directament aquí sota.", + "blueprint.empty_title": "Què vols fer?", + "blueprint.minimal_body": "Fes una pregunta sobre aquest workspace o fes servir un missatge d'inici.", + "blueprint.minimal_title": "Comença amb una tasca", + "blueprint.starter_blueprint_desc": "Dissenya un flux de treball reutilitzable amb Skills, Commands i passos de traspàs.", + "blueprint.starter_blueprint_prompt": "Ajuda'm a dissenyar un blueprint d'automatització reutilitzable per a aquest workspace. Pregunta què s'hauria d'estandarditzar i després proposa el flux de treball.", + "blueprint.starter_blueprint_title": "Planifica un blueprint d'automatització", + "blueprint.starter_chrome_desc": "Comença ara mateix una conversa d'automatització del navegador.", + "blueprint.starter_chrome_prompt": "Ajuda'm a connectar-me a Chrome i automatitzar una tasca repetitiva.", + "blueprint.starter_chrome_title": "Automatitza Chrome", + "blueprint.starter_command_desc": "Converteix un flux de treball repetit en una /command per a aquest workspace.", + "blueprint.starter_command_prompt": "Ajuda'm a crear una /command reutilitzable per a aquest workspace. Pregunta quin flux de treball vull automatitzar i després redacta la Command.", + "blueprint.starter_command_title": "Crea una /command reutilitzable", + "blueprint.starter_connect_openai_desc": "Afegeix el teu proveïdor d'OpenAI perquè els models ChatGPT estiguin preparats en sessions noves.", + "blueprint.starter_connect_openai_title": "Connecta ChatGPT", + "blueprint.starter_csv_desc": "Neteja o genera dades de fulls de càlcul.", + "blueprint.starter_csv_prompt": "Ajuda'm a crear o editar fitxers CSV en aquest ordinador.", + "blueprint.starter_csv_title": "Treballa en un CSV", + "blueprint.starter_explore_desc": "Resumeix els fitxers i proposa la millor primera tasca per començar.", + "blueprint.starter_explore_prompt": "Resumeix aquest workspace, assenyala els fitxers més importants i proposa la millor primera tasca.", + "blueprint.starter_explore_title": "Explora aquest workspace", + "blueprint.welcome_message": "Hola, benvingut a OpenWork!\n\nLa gent ens fa servir per escriure fitxers .csv al seu ordinador, connectar-se a Chrome i automatitzar tasques repetitives, i sincronitzar contactes amb Notion.\n\nPerò l'únic límit és la teva imaginació.\n\nQuè vols fer?", + "blueprint.welcome_title": "Benvingut a OpenWork", + "common.add": "Afegeix", + "common.cancel": "Cancel·la", + "common.choose": "Tria", + "common.close": "Tancar", + "common.default_parens": "(per defecte)", + "common.done": "Fet", + "common.edit": "Edita", + "common.hide": "Amaga", + "common.install": "Instal·lar", + "common.navigate": "navegar", + "common.next": "Següent", + "common.off": "Apagat", + "common.on": "Encès", + "common.path": "Ruta", + "common.question": "Pregunta", + "common.refresh": "Actualitza", + "common.remove": "Eliminar", + "common.reset": "Restableix", + "common.retry": "Torna-ho a provar", + "common.save": "Desa", + "common.select": "seleccionar", + "common.show": "Mostra", + "common.something_went_wrong": "Hi ha hagut un problema", + "common.submit": "Envia", + "common.unknown": "Desconegut", + "composer.agent_label": "Agent", + "composer.attach_files": "Adjuntar fitxers", + "composer.attachments_unavailable": "Els fitxers adjunts no estan disponibles.", + "composer.behavior_label": "Comportament", + "composer.configure": "Configura", + "composer.default_agent": "Agent per defecte", + "composer.expand_pasted": "Fes clic per ampliar el text enganxat", + "composer.failed_read_attachment": "No s'ha pogut llegir el fitxer adjunt", + "composer.file_exceeds_limit": "{name} supera el límit de 8 MB.", + "composer.file_kind": "Fitxer", + "composer.file_too_large_encoding": "{name} és massa gran després de la codificació. Prova una imatge més petita.", + "composer.image_kind": "Imatge", + "composer.inserted_links_unsupported": "S'han inserit enllaços per a fitxers no compatibles.", + "composer.loading_agents": "S'estan carregant agents...", + "composer.loading_commands": "S'estan carregant les Commands...", + "composer.mcps_label": "MCPs", + "composer.no_commands": "No s'ha trobat cap Command.", + "composer.no_matches": "No s'han trobat coincidències.", + "composer.placeholder": "Descriu la teva tasca...", + "composer.remote_worker_paste_warning": "Aquest és un worker remot. Sandboxes també són remots. Per compartir fitxers amb ell, pengeu-los a la carpeta compartida de la barra lateral.", + "composer.run_task": "Executar la tasca", + "composer.skill_source": "Skill", + "composer.stop": "Atura", + "composer.tools_label": "Commands, Skills i MCPs", + "composer.unsupported_attachment_type": "Tipus de fitxer adjunt no compatible.", + "composer.upload_failed_local_links": "No s'ha pogut carregar a la carpeta compartida. S'han inserit enllaços locals.", + "composer.upload_to_shared_folder": "Penja a la carpeta compartida", + "composer.uploaded_multiple_files": "S'han penjat fitxers {count} a la carpeta compartida i s'han inserit enllaços.", + "composer.uploaded_single_file": "S'ha penjat {name} a la carpeta compartida i s'ha inserit un enllaç.", + "config.auto_reload_desc": "Recarrega automàticament quan canviïn agents, Skills, Commands o la configuració (només quan no hi hagi activitat).", + "config.auto_reload_title": "Recàrrega automàtica (local)", + "config.auto_reload_unavailable": "Disponible per als workspaces locals des de l'app d'escriptori.", + "config.collaborator_token_disabled_hint": "Es desa amb antelació per compartir remotament, però ara mateix l'accés remot està desactivat.", + "config.collaborator_token_label": "Token de col·laborador", + "config.collaborator_token_remote_hint": "Accés remot de rutina per a telèfons o ordinadors portàtils connectats a aquest servidor.", + "config.connection_failed": "La connexió ha fallat.", + "config.connection_failed_check": "La connexió ha fallat. Comprova l'URL de l'host i el token.", + "config.connection_status_updated": "Estat de connexió actualitzat.", + "config.connection_successful": "Connexió correcta.", + "config.copied": "Copiat", + "config.copy": "Copia", + "config.desktop_only_hint": "Algunes funcions de configuració (compartir el servidor local i el pont de missatgeria) requereixen l'app d'escriptori.", + "config.diagnostics_desc": "Copia l'estat del runtime, ja depurat, per diagnosticar problemes.", + "config.diagnostics_title": "Paquet de diagnòstic", + "config.enable_auto_reload_first": "Activa primer la recàrrega automàtica", + "config.engine_reload_desc": "Reinicia el servidor OpenCode per a aquest workspace.", + "config.engine_reload_title": "Recàrrega del motor", + "config.host_admin_token_hint": "Token intern només per a l'host per a la CLI d'aprovacions i les API d'administració. No el facis servir en el flux de connexió remota de l'app.", + "config.host_admin_token_label": "Token d'administrador de l'host", + "config.host_local_only": "Només local", + "config.host_offline": "Fora de línia", + "config.host_remote_enabled": "Accés remot habilitat", + "config.local_ip_hint": "Fes servir la teva IP local a la mateixa Wi-Fi per tenir la connexió més ràpida.", + "config.mdns_hint": "Els noms .local són més fàcils de recordar, però pot ser que no es resolguin en totes les xarxes.", + "config.messaging_identities_desc": "Gestiona les identitats i l'encaminament Telegram/Slack a la pestanya Identitats.", + "config.messaging_identities_title": "Identitats de missatgeria", + "config.not_set": "No configurat", + "config.owner_token_disabled_hint": "Només rellevant després d'habilitar l'accés remot per a aquest worker.", + "config.owner_token_label": "Token del propietari", + "config.owner_token_remote_hint": "Fes servir-ho quan un client remot necessiti respondre sol·licituds de permís o fer accions només per al propietari.", + "config.reload_active_tasks_warning": "La recàrrega aturarà les tasques actives.", + "config.reload_availability_hint": "La recàrrega només està disponible per a workers locals o per a servidors OpenWork connectats.", + "config.reload_connect_hint": "Connecta't a aquest worker per tornar a carregar.", + "config.reload_engine": "Torna a carregar el motor", + "config.reload_now_desc": "Aplica actualitzacions de configuració i torna a connectar la sessió.", + "config.reload_now_title": "Torna a carregar ara", + "config.reloading": "S'està tornant a carregar...", + "config.remote_access_off_hint": "L'accés remot està desactivat. Fes servir Comparteix el workspace per activar-lo abans de connectar-te des d'una altra màquina.", + "config.resolved_worker_url": "URL de worker resolt:", + "config.resume_sessions_desc": "Si s'ha posat una recàrrega a la cua mentre s'executaven les tasques, envia un missatge de represa després.", + "config.resume_sessions_title": "Reprèn les sessions després de la recàrrega automàtica", + "config.server_needed_hint": "Cal una connexió al servidor OpenWork per sincronitzar Skills, Plugins i Commands.", + "config.server_section_desc": "Connecta't a un servidor OpenWork. Fes servir l'URL i un token de col·laborador o de propietari que et faciliti l'administrador del servidor.", + "config.server_section_title": "Servidor OpenWork", + "config.server_sharing_desc": "Comparteix aquests detalls amb un dispositiu de confiança. Mantingueu el servidor a la mateixa xarxa per a la configuració més ràpida.", + "config.server_sharing_menu_hint": "Per als enllaços de compartició per workspace, fes servir Compartir... al menú del workspace.", + "config.server_sharing_title": "Compartició del servidor OpenWork", + "config.server_url_hint": "Fes servir l'URL que t'hagi compartit el teu servidor OpenWork. Els workers locals de l'app d'escriptori reutilitzen un port alt persistent dins del rang 48000-51000.", + "config.server_url_input_label": "URL del servidor OpenWork", + "config.server_url_label": "URL del servidor OpenWork", + "config.starting_server": "S'està iniciant el servidor...", + "config.status_connected": "Connectat", + "config.status_limited": "Limitat", + "config.status_not_connected": "No connectat", + "config.test_connection": "Prova de connexió", + "config.testing": "Prova...", + "config.testing_connection": "S'està provant la connexió...", + "config.token_hint": "Opcional. Enganxa un token de col·laborador per a l'accés habitual o un token de propietari si aquest client ha de respondre a sol·licituds de permisos.", + "config.token_label": "Token de col·laborador o propietari", + "config.token_placeholder": "Enganxa el token", + "config.unavailable": "No disponible", + "config.worker_id": "ID del worker:", + "config.workspace_config_desc": "Aquests paràmetres afecten el workspace seleccionat. Les accions només de runtime s'apliquen a qualsevol workspace connectat actualment.", + "config.workspace_config_title": "Configuració del workspace", + "config.workspace_id_prefix": "Workspace:", + "context_panel.add_button": "Afegeix", + "context_panel.add_folder_hint": "Afegeix una carpeta per permetre que aquest workspace llegeixi i editi fitxers fora del seu directori arrel.", + "context_panel.adding_button": "S'està afegint...", + "context_panel.always_available": "Sempre disponible", + "context_panel.authorized_folders": "Carpetes autoritzades", + "context_panel.authorized_folders_desc": "Concedeix accés a aquest workspace per llegir i editar fitxers en directoris fora de la seva arrel.", + "context_panel.authorized_folders_no_access": "Connecta't a un workspace d'un servidor OpenWork amb permisos d'escriptura per editar les carpetes autoritzades.", + "context_panel.browse_button": "Navega", + "context_panel.config_access_unavailable": "L'accés a la configuració del servidor OpenWork no està disponible per a aquest workspace.", + "context_panel.config_read_only": "El servidor OpenWork està connectat en mode de només lectura per a la configuració d'aquest workspace.", + "context_panel.context": "Context", + "context_panel.folder_already_authorized": "La carpeta ja està autoritzada.", + "context_panel.folders_updated": "Carpetes autoritzades actualitzades.", + "context_panel.input_placeholder": "Escriu la ruta de la carpeta que vols autoritzar...", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "Connectat", + "context_panel.mcp_disabled": "Desactivat", + "context_panel.mcp_disconnected": "Desconnectat", + "context_panel.mcp_failed": "Ha fallat", + "context_panel.mcp_needs_auth": "Necessita autenticació", + "context_panel.mcp_register_client": "Registre client", + "context_panel.no_external_folders": "No hi ha carpetes externes autoritzades", + "context_panel.no_mcp": "No s'han carregat servidors MCP.", + "context_panel.no_plugins": "No s'han carregat Plugins.", + "context_panel.no_server_workspace": "No s'ha seleccionat cap workspace del servidor actiu.", + "context_panel.no_skills": "No s'ha carregat cap Skill.", + "context_panel.none_yet": "Cap encara.", + "context_panel.plugins": "Plugins", + "context_panel.preserving_entries": "Es conserven {count} entrades de permisos que no corresponen a carpetes.", + "context_panel.preserving_entry": "Es conserva 1 entrada de permisos que no correspon a cap carpeta.", + "context_panel.remove_folder": "Elimina {name}", + "context_panel.saving_folders": "S'estan desant les carpetes autoritzades...", + "context_panel.server_disconnected": "El servidor OpenWork està desconnectat.", + "context_panel.skills": "Skills", + "context_panel.working_files": "Fitxers de treball", + "context_panel.workspace_root_available": "L'arrel del workspace ja està disponible.", + "context_panel.workspace_root_badge": "Arrel del workspace", + "context_panel.writable_workspace_required": "Cal un workspace d'un servidor OpenWork amb permisos d'escriptura per actualitzar les carpetes autoritzades.", + "dashboard.access_token": "Token d'accés", + "dashboard.access_token_optional_hint": "Afegeix un token només si el worker ho requereix.", + "dashboard.blueprints_workspace": "Blueprints", + "dashboard.blueprints_workspace_desc": "Comença amb un workspace preparat per a automatitzacions, amb Skills, Commands i fluxos compartits reutilitzables.", + "dashboard.change": "Canviar", + "dashboard.choose_folder": "Tria una carpeta", + "dashboard.choose_folder_continue": "Tria una carpeta per continuar.", + "dashboard.choose_folder_next": "Comparteix fitxers amb el teu workspace.", + "dashboard.choose_preset": "Selecciona un preset", + "dashboard.chooser_local_desc": "Crea un workspace en aquest dispositiu i, si vols, parteix d'una plantilla d'equip.", + "dashboard.chooser_remote_desc": "Connecta't a un worker OpenWork autoallotjat amb una URL i un token d'accés.", + "dashboard.chooser_shared_desc": "Explora els workers del núvol compartits amb la teva organització i connecta't en un sol pas.", + "dashboard.close_settings": "Tanca la configuració", + "dashboard.cloud_signin_button": "Continua amb OpenWork Cloud", + "dashboard.cloud_signin_hint": "Accedeix a workers remots compartits amb la teva organització.", + "dashboard.cloud_signin_next": "Després podràs triar un equip i connectar-te a un workspace existent.", + "dashboard.cloud_signin_title": "Inicia la sessió a OpenWork Cloud", + "dashboard.cloud_worker": "Worker Cloud", + "dashboard.commands": "Commands", + "dashboard.connect_remote_button": "Connecta un worker remot", + "dashboard.connected": "Connectat", + "dashboard.connecting": "S'està connectant...", + "dashboard.create_local_workspace_subtitle": "Crea un workspace en aquest dispositiu i, si vols, parteix d'una plantilla d'equip.", + "dashboard.create_local_workspace_title": "Workspace local", + "dashboard.create_remote_custom_subtitle": "Connecta't a un worker OpenWork autoallotjat.", + "dashboard.create_remote_custom_title": "Connecta un worker remot personalitzat", + "dashboard.create_remote_workspace_confirm": "Afegeix un workspace", + "dashboard.create_remote_workspace_subtitle": "Desa un servidor OpenWork com a workspace.", + "dashboard.create_remote_workspace_title": "Afegeix un workspace remot", + "dashboard.create_sandbox_confirm": "Crea com a sandbox", + "dashboard.create_shared_subtitle_signed_in": "Explora els workers del núvol compartits amb la teva organització i connecta't en un sol pas.", + "dashboard.create_shared_subtitle_signed_out": "Inicia la sessió a OpenWork Cloud per accedir als workers compartits amb la teva organització.", + "dashboard.create_shared_title": "Workspaces compartits", + "dashboard.create_workspace_confirm": "Crea un workspace", + "dashboard.create_workspace_subtitle": "Inicialitza un workspace nou basat en una carpeta.", + "dashboard.create_workspace_title": "Crea un workspace", + "dashboard.creating": "S'està creant...", + "dashboard.desktop_badge": "Desktop", + "dashboard.display_name_label": "Nom de visualització", + "dashboard.display_name_optional": "(opcional)", + "dashboard.docker_debug_details": "Detalls de depuració de Docker", + "dashboard.edit_remote_workspace_confirm": "Desa la connexió", + "dashboard.edit_remote_workspace_subtitle": "Actualitza les dades del servidor OpenWork per a aquest workspace.", + "dashboard.edit_remote_workspace_title": "Edita la connexió remota", + "dashboard.empty_workspace": "Workspace buit", + "dashboard.empty_workspace_desc": "Comença amb una carpeta buida i afegeix-hi el que necessitis.", + "dashboard.error_choose_org": "Tria una organització abans d'obrir un workspace.", + "dashboard.error_connect_worker": "No s'ha pogut connectar a {name}.", + "dashboard.error_create_template": "No s'ha pogut crear {name}.", + "dashboard.error_load_orgs": "No s'han pogut carregar les organitzacions.", + "dashboard.error_load_shared_workspaces": "No s'han pogut carregar els workspaces compartits.", + "dashboard.error_workspace_not_ready": "El workspace encara no està preparat per connectar-se. Torna-ho a provar d'aquí a un moment.", + "dashboard.import_config": "Importa la configuració", + "dashboard.importing": "S'està important…", + "dashboard.modal_back": "Enrere", + "dashboard.modal_close": "Tanca el modal d'afegir workspace", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "Encara no s'ha seleccionat cap carpeta.", + "dashboard.open_cloud_dashboard": "Obre el tauler d'OpenWork Cloud", + "dashboard.opening": "S'està obrint...", + "dashboard.openwork_host_hint": "Fes servir l'URL que t'hagi compartit el teu servidor OpenWork.", + "dashboard.openwork_host_label": "URL del servidor OpenWork", + "dashboard.openwork_host_placeholder": "https://el-teu-servidor.openwork.app", + "dashboard.openwork_host_token_hint": "Opcional. Enganxa un token de col·laborador per a l'accés rutinari o un token de propietari quan aquest client hagi de respondre a les sol·licituds de permís.", + "dashboard.openwork_host_token_label": "Token de col·laborador o propietari", + "dashboard.openwork_host_token_placeholder": "Enganxa el token", + "dashboard.recently_updated": "Actualitzat recentment", + "dashboard.remote": "Remot", + "dashboard.remote_base_url_required": "Afegeix l’URL del servidor per continuar.", + "dashboard.remote_connection_direct": "Directe", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "Deixa-ho en blanc per fer servir el directori predeterminat del servidor.", + "dashboard.remote_directory_label": "Directori del workspace (opcional)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "Nom de visualització (opcional)", + "dashboard.remote_display_name_placeholder": "Disseny del workspace en equip", + "dashboard.remote_server_details_hint": "Connecta't a un worker OpenWork autoallotjat.", + "dashboard.remote_server_details_title": "Detalls del servidor remot", + "dashboard.remote_workspace_hint": "Desa un servidor OpenWork com a workspace i reconnecta't-hi quan vulguis.", + "dashboard.remote_workspace_title": "Workspace remot", + "dashboard.repair_cache": "Repara la memòria cache", + "dashboard.repairing_cache": "Reparació de la memòria cache", + "dashboard.sandbox_checking_docker": "S'està comprovant el Docker...", + "dashboard.sandbox_get_ready_action": "Prepara el teu sistema", + "dashboard.sandbox_get_ready_desc": "Executa aquest workspace dins d'un contenidor Docker aïllat per tenir execucions més segures i reproduïbles.", + "dashboard.sandbox_get_ready_title": "Els sandboxes necessiten Docker", + "dashboard.sandbox_hide_logs": "Amaga els registres", + "dashboard.sandbox_live_logs": "Registres en directe", + "dashboard.sandbox_setup": "Configuració del sandbox", + "dashboard.sandbox_show_logs": "Mostra els registres", + "dashboard.search_shared_workspaces": "Cerca workspaces compartits", + "dashboard.select_folder": "Selecciona una carpeta", + "dashboard.settings": "Configuració", + "dashboard.shared_workspaces_loading": "S'estan carregant workspaces compartits...", + "dashboard.shared_workspaces_no_match": "Cap workspace compartit coincideix amb aquesta cerca.", + "dashboard.shared_workspaces_none": "Encara no hi ha workspaces compartits disponibles.", + "dashboard.shared_workspaces_refreshing": "S'estan actualitzant els workspaces...", + "dashboard.skills": "Skills", + "dashboard.starter_workspace": "Workspace inicial", + "dashboard.starter_workspace_desc": "Preconfigurat perquè vegis com fer servir Plugins, Commands i Skills.", + "dashboard.unknown_creator": "Creador desconegut", + "dashboard.worker_status_attention": "Atenció", + "dashboard.worker_status_ready": "A punt", + "dashboard.worker_status_starting": "Començant", + "dashboard.worker_status_stopped": "Parat", + "dashboard.worker_status_unknown": "Desconegut", + "dashboard.worker_url_hint": "Enganxa l'URL del worker OpenWork al qual et vols connectar.", + "dashboard.worker_url_label": "URL del worker", + "dashboard.workspace_connect": "Connecta", + "dashboard.workspace_connect_unavailable": "La connexió a workspaces compartits no està disponible aquí.", + "dashboard.workspace_connecting": "Connectant", + "dashboard.workspace_folder_hint": "Tria on vols que visqui aquest workspace al teu dispositiu.", + "dashboard.workspace_folder_title": "Carpeta del workspace", + "dashboard.workspace_not_ready_title": "Aquest workspace encara no està preparat per connectar-se.", + "dashboard.workspaces": "Workspaces", + "den.active_org_hint": "Els workers del núvol i les plantilles d'equip depenen de l'organització seleccionada.", + "den.active_org_title": "Organització activa", + "den.auto_reconnect_hint": "Acaba l'autenticació al navegador i OpenWork es reconnectarà aquí automàticament.", + "den.checking_session": "Comprovant la sessió", + "den.choose_org_for_providers": "Tria una organització per veure els proveïdors de núvol.", + "den.choose_org_for_skills": "Tria una organització per veure les Skills del núvol.", + "den.choose_org_for_skill_hubs": "Tria una organització per veure els centres d'Skills al núvol.", + "den.cloud_account_hint": "Gestiona el teu compte connectat i l'organització.", + "den.cloud_account_title": "Compte Cloud", + "den.cloud_control_plane_open": "Obre al navegador", + "den.cloud_control_plane_reset": "Restableix", + "den.cloud_control_plane_save": "Desa l'URL", + "den.cloud_control_plane_url_hint": "Només el mode desenvolupador. Fes servir-ho per orientar un pla de control Cloud local o autoallotjat. En canviar-lo, es tanca la sessió perquè l'aplicació es pugui rehidratar contra el nou pla de control.", + "den.cloud_control_plane_url_label": "URL del pla de control Cloud", + "den.cloud_provider_detail": "{count} models · proveïdor {source}", + "den.cloud_provider_removed_detail": "Aquest proveïdor importat ja no està al núvol. Desinstal·la la configuració local de {providerId}.", + "den.cloud_provider_sync_detail": "El proveïdor de Cloud ha canviat. Sincronitza la configuració dels {count} models {source} a opencode.jsonc.", + "den.cloud_skill_detail": "Instal·la aquesta Skill al núvol a .opencode/skills.", + "den.cloud_skill_imported_detail": "Instal·lat localment com a {name}.", + "den.cloud_skill_removed_detail": "Aquesta Skill del núvol es va eliminar aigües amunt. Desinstal·la la còpia local de {name}.", + "den.cloud_skill_sync_detail": "Hi ha disponible una versió al núvol més recent per a {name}. Actualitza la còpia local per mantenir-se sincronitzada.", + "den.cloud_skills_hint": "Explora les Skills del núvol a què tens accés, instal·la-les localment i actualitza-les quan canviï la versió remota.", + "den.cloud_skills_title": "Skills", + "den.cloud_providers_hint": "Importa proveïdors de LLM gestionats a opencode.jsonc i fes servir la credencial de l'organització en aquest workspace.", + "den.cloud_providers_title": "Proveïdors del núvol", + "den.cloud_section_desc": "Inicia la sessió, tria una organització i obre workers del núvol o plantilles d'equip.", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "Inicia la sessió a OpenWork Cloud per mantenir les tasques actives encara que l'ordinador entri en repòs.", + "den.cloud_workers_hint": "Obre workers directament a OpenWork mitjançant el mateix flux de connexió remota que l'aplicació ja utilitza en altres llocs.", + "den.cloud_workers_title": "Workers del núvol", + "den.create_account": "Crea un compte", + "den.credentials_ready_badge": "Credencial a punt", + "den.error_base_url": "Introdueix un URL del pla de control http:// o https:// Cloud vàlid.", + "den.error_choose_org": "Tria una organització abans d'obrir un worker.", + "den.error_load_orgs": "No s'han pogut carregar les organitzacions.", + "den.error_load_skills": "No s'han pogut carregar les Skills al núvol.", + "den.error_load_workers": "No s'han pogut carregar els workers.", + "den.error_no_session": "No s'ha trobat cap sessió Cloud activa.", + "den.error_no_token": "S'ha completat l'inici de sessió a l'escriptori, però OpenWork Cloud no ha retornat cap token de sessió.", + "den.error_open_worker": "No s'ha pogut obrir {name} a OpenWork.", + "den.error_open_worker_fallback": "No s'ha pogut obrir {name}.", + "den.error_paste_valid_code": "Enganxa un enllaç d'inici de sessió OpenWork vàlid o un codi d'inici de sessió únic.", + "den.error_signin_failed": "No s'ha pogut completar l'inici de sessió a OpenWork Cloud.", + "den.error_worker_not_ready": "El worker encara no està preparat per obrir. Torna-ho a provar quan finalitzi l'aprovisionament.", + "den.finish_signin": "Acaba l'inici de sessió", + "den.finishing": "S'està acabant...", + "den.hide_signin_code": "Amaga el codi d'inici de sessió", + "den.import_all": "Importa-ho tot", + "den.import_skill": "Instal·lar", + "den.import_skill_failed": "No s'ha pogut instal·lar {name}.", + "den.import_provider": "Importar", + "den.import_provider_failed": "No s'ha pogut importar {name}.", + "den.imported_badge": "Importat", + "den.imported_provider": "{name} importat.", + "den.importing": "S'està important...", + "den.needs_attention": "Necessita atenció", + "den.no_cloud_providers": "Encara no hi ha cap proveïdor de núvol disponible per a aquesta organització.", + "den.no_cloud_skills": "Encara no hi ha Skills al núvol disponibles per a aquesta organització.", + "den.no_cloud_workers": "Encara no hi ha cap worker del núvol visible per a aquesta organització. Crea'n un a Cloud i després actualitza aquesta pestanya.", + "den.no_org_selected": "No s'ha seleccionat cap organització", + "den.no_skill_hubs": "Encara no hi ha cap centre d'Skills al núvol disponible per a aquesta organització.", + "den.open": "Obre", + "den.opening": "S'està obrint...", + "den.org_member_suffix": "(Membre)", + "den.org_owner_suffix": "(Propietari)", + "den.org_switched": "S'ha canviat a {name}.", + "den.out_of_sync_badge": "No sincronitzat", + "den.private_badge": "Privat", + "den.paste_signin_code": "Enganxa el codi d'inici de sessió", + "den.refresh": "Actualitza", + "den.reload_workspace": "Torna a carregar el workspace per aplicar els canvis de configuració.", + "den.remove_provider_failed": "No s'ha pogut eliminar {name}.", + "den.remove_skill_failed": "No s'ha pogut desinstal·lar {name}.", + "den.removed_from_cloud_badge": "Eliminat del núvol", + "den.removed_provider": "S'ha eliminat {name}.", + "den.removing": "S'està eliminant...", + "den.sign_out": "Tanca la sessió", + "den.signed_out": "Tancat la sessió", + "den.signin_button": "Inicia la sessió", + "den.signin_code_note": "Accepta un enllaç openwork://den-auth o el codi temporal en brut.", + "den.signin_link_hint": "Si el navegador no torna a OpenWork automàticament, enganxa aquí l'enllaç d'inici de sessió o el codi temporal d'OpenWork Cloud.", + "den.signin_link_label": "Enllaç d'inici de sessió o codi únic", + "den.signin_link_placeholder": "openwork://den-auth?... o codi enganxat", + "den.signin_title": "Inicia la sessió a OpenWork Cloud", + "den.signing_in": "S'està acabant l'inici de sessió de OpenWork Cloud...", + "den.signing_out": "Tancant la sessió...", + "den.skill_hub_detail": "Importa les Skills compartides de {count} a .opencode/skills.", + "den.skill_hub_imported_detail": "S'han importat les Skills {count} a aquest workspace.", + "den.skill_hub_removed_detail": "Aquest concentrador s'ha eliminat del núvol. Desinstal·la les Skills importades {importedCount} d'aquest workspace.", + "den.skill_hub_skills_badge": "Skills {count}", + "den.skill_hub_sync_detail": "Cloud ara té Skills {liveCount}; aquest workspace ha importat {importedCount}. Sincronitza per actualitzar el conjunt instal·lat.", + "den.skill_hubs_hint": "Importa totes les Skills d'un concentrador de núvol compartit a aquest workspace en un sol pas.", + "den.skill_hubs_title": "Centres d'Skills", + "den.status_base_url_updated": "S'ha actualitzat l'URL del pla de control Cloud. Torna a iniciar la sessió per continuar.", + "den.status_browser_signin": "Acabeu d'iniciar la sessió al vostre navegador per connectar OpenWork.", + "den.status_browser_signup": "Acabeu la creació del compte al vostre navegador per connectar OpenWork.", + "den.status_cloud_signed_in_as": "OpenWork Cloud connectat com a {email}.", + "den.status_cloud_signin_done": "OpenWork Cloud connectat.", + "den.status_loaded_orgs": "S'ha carregat {count} org{plural}.", + "den.status_loaded_skills": "S'han carregat {count} Skill{plural} del núvol per a {name}.", + "den.status_loaded_workers": "S'ha carregat {count} worker{plural} per a {name}.", + "den.status_no_skills": "No s'han trobat Skills al núvol per a {name}.", + "den.status_no_workers": "No s'ha trobat cap worker per a {name}.", + "den.status_opened_worker": "S'ha obert {name} a OpenWork.", + "den.status_signed_in_as": "S'ha iniciat la sessió com a {email}.", + "den.status_signed_out": "Heu tancat la sessió i heu esborrat la vostra sessió OpenWork Cloud en aquest dispositiu.", + "den.sync": "Sincronitza", + "den.sync_provider_failed": "No s'ha pogut sincronitzar {name}.", + "den.sync_skill_failed": "No s'ha pogut actualitzar {name}.", + "den.synced_provider": "{name} sincronitzat.", + "den.syncing": "S'està sincronitzant...", + "den.installed_name_badge": "Local: {name}", + "den.uninstall": "Desinstal·la", + "den.worker_mine_badge": "El meu", + "den.worker_not_ready_title": "Aquest worker encara no està preparat per obrir-se.", + "den.worker_provider_label": "{provider} worker", + "den.worker_secondary_cloud": "Worker Cloud", + "extensions.app_count_one": "Aplicació {count} connectada", + "extensions.app_count_many": "Aplicacions {count} connectades", + "extensions.apps_mcp_header": "Aplicacions (MCP)", + "extensions.filter_all": "Tots", + "extensions.filter_apps": "Aplicacions", + "extensions.filter_plugins": "Plugins", + "extensions.plugin_count_one": "{count} plugin", + "extensions.plugin_count_many": "{count} plugins", + "extensions.plugins_opencode_header": "Plugins (OpenCode)", + "extensions.subtitle": "Les apps (MCP) i els Plugins d'OpenCode són al mateix lloc.", + "extensions.title": "Extensions", + "identities.agent_behavior_desc": "Un fitxer per workspace. Afegeix una primera línia opcional @agent per enviar-ho a un agent OpenCode concret.", + "identities.agent_behavior_title": "Comportament de l'agent de missatgeria", + "identities.agent_created": "S'ha creat un fitxer d'agent de missatgeria predeterminat.", + "identities.agent_file_changed": "El fitxer ha canviat de forma remota. Torna a carregar i desa de nou.", + "identities.agent_loading": "S'està carregant el fitxer de l'agent...", + "identities.agent_none": "cap", + "identities.agent_not_found": "Encara no s'ha trobat el fitxer d'agent en aquest workspace.", + "identities.agent_saved": "Comportament de missatgeria desat.", + "identities.agent_scope_status": "Àmbit actiu: workspace · estat: {status} · agent seleccionat: {agent}", + "identities.agent_status_loaded": "carregat", + "identities.agent_status_missing": "no trobat", + "identities.agent_worker_scope_unavailable": "L'àmbit del worker no està disponible.", + "identities.all_channels": "Tots els canals", + "identities.app_token_label": "Token d'aplicació", + "identities.auto_bind_label": "Enllaça automàticament entre iguals al directori en enviament directe", + "identities.available_channels": "Canals disponibles", + "identities.bot_token_label": "Token de bot", + "identities.bot_token_placeholder": "Enganxa el token de bot Telegram de @BotFather", + "identities.botfather_step1_open": "1. Obre @BotFather a Telegram", + "identities.botfather_step1_run": "i executa /newbot", + "identities.botfather_step3_choose": "3. Tria un nom i un nom d'usuari per al teu bot", + "identities.botfather_step3_or_private": "per a la safata d'entrada oberta o", + "identities.botfather_step3_private": "Privat", + "identities.botfather_step3_public": "Públic", + "identities.botfather_step3_to_require": "per exigir", + "identities.channel_label": "Canal", + "identities.channels_connected": "connectat", + "identities.channels_label": "Canals", + "identities.configured_suffix": "configurat", + "identities.connect_server_desc": "Les identitats estan disponibles quan esteu connectat a un host OpenWork.", + "identities.connect_server_title": "Connecta't a un servidor OpenWork", + "identities.connect_slack": "Connecta Slack", + "identities.connected_badge": "Connectat", + "identities.connecting": "S'està connectant...", + "identities.copy_bot_token_hint": "Copia el token del bot i enganxa'l aquí sota.", + "identities.copy_code": "Copia el codi", + "identities.create_default_file": "Crea un fitxer predeterminat", + "identities.create_private_bot": "Crea un bot privat", + "identities.create_public_bot": "Crea un bot públic", + "identities.days_ago": "Fa {days}d", + "identities.default_routing": "Encaminament per defecte", + "identities.directory_label": "Directori (opcional)", + "identities.disable_messaging": "Desactiva la missatgeria", + "identities.disable_messaging_message": "Això desactivarà la missatgeria per a aquest workspace. La configuració de Telegram i Slack quedarà oculta fins que la tornis a activar, i hauràs de reiniciar el worker per aturar completament el sidecar de missatgeria.", + "identities.disable_messaging_title": "Vols desactivar la missatgeria per a aquest worker?", + "identities.disabled_label": "Desactivat", + "identities.disabling": "S'està desactivant...", + "identities.disconnect": "Desconnecta", + "identities.dispatched_messages": "Missatges {sent}/{attempted} enviats.", + "identities.enable_messaging": "Activa la missatgeria", + "identities.enable_messaging_risk": "La missatgeria pot exposar aquest worker a ordres remotes. Si un bot és públic o queda compromès, pot accedir a fitxers, credencials i claus API disponibles per a aquest worker.", + "identities.enable_messaging_title": "Vols activar la missatgeria per a aquest worker?", + "identities.enabled_label": "Habilitat", + "identities.enabling": "S'està habilitant...", + "identities.health_offline": "Fora de línia", + "identities.health_running": "En execució", + "identities.health_unavailable": "No disponible", + "identities.health_unknown": "Desconegut", + "identities.hours_ago": "fa {hours}h", + "identities.identities_label": "Identitats", + "identities.just_now": "Just ara", + "identities.last_activity": "Última activitat", + "identities.later": "Més tard", + "identities.message_label": "Missatge", + "identities.message_routing_desc": "Controla a quina carpeta de workspace va cada conversa. Per defecte, els missatges s'envien a la carpeta predeterminada del worker, tret que hi configuris regles.", + "identities.message_routing_title": "Encaminament de missatges", + "identities.messages_today": "Missatges avui", + "identities.messaging_disabled_hint": "Activa la missatgeria només si entens el risc i tens previst protegir-ne l'accés (per exemple, amb aparellament privat de Telegram).", + "identities.messaging_disabled_restart": "Missatgeria desactivada. Reinicia aquest worker per aturar la missatgeria sidecar.", + "identities.messaging_disabled_risk": "Els bots de missatgeria poden executar accions contra el teu worker local. Si s'exposen públicament, poden donar accés als fitxers, credencials i claus API disponibles per a aquest worker.", + "identities.messaging_disabled_title": "La missatgeria està desactivada per defecte", + "identities.messaging_enabled_restart": "Missatgeria activada. Reinicia aquest worker per aplicar-lo abans de configurar els canals.", + "identities.messaging_sidecar_not_running": "La missatgeria està activada en aquest workspace, però la missatgeria sidecar encara no s'està executant. Reinicia aquest worker i, a continuació, torna a la configuració de missatgeria per connectar Telegram o Slack.", + "identities.minutes_ago": "fa {minutes}m", + "identities.not_set": "No configurat", + "identities.open_bot_link": "Obre @{username} a Telegram", + "identities.pairing_code_copied": "S'ha copiat el codi d'aparellament.", + "identities.pairing_code_copy_failed": "No s'ha pogut copiar el codi d'aparellament. Copia'l manualment.", + "identities.pairing_code_instruction_prefix": "Enviar", + "identities.peer_id_label": "ID del peer (opcional)", + "identities.peer_id_placeholder_slack": "p. ex. slack:U12345678", + "identities.peer_id_placeholder_telegram": "p. ex. telegram:123456789", + "identities.private_label": "Privat", + "identities.private_pairing_code": "Codi d'aparellament privat", + "identities.public_bot_confirm": "Sí, entenc el risc", + "identities.public_bot_warning_message": "El teu bot serà accessible públicament i qualsevol persona que hi tingui accés podrà tenir control total del teu worker local, inclosos els fitxers i les claus API que hi hagis posat. Si crees un bot privat, pots limitar qui hi accedeix exigint un token d'aparellament. Segur que vols fer públic aquest bot?", + "identities.public_bot_warning_title": "Voleu fer públic aquest bot?", + "identities.public_label": "Públic", + "identities.quick_setup": "Configuració ràpida", + "identities.reconnect_failed": "No s'ha pogut reconnectar. Comprova l'URL i el token d'OpenWork i torna-ho a provar.", + "identities.reconnected": "Reconnectat.", + "identities.reconnected_refreshing": "Reconnectat. S'està actualitzant l'estat del worker...", + "identities.reload": "Torna a carregar", + "identities.repair_reconnect": "Repara i torna a connectar", + "identities.restart_failed": "Ha fallat el reinici. Reinicia el worker des de Configuració i torna-ho a provar.", + "identities.restart_to_disable_messaging": "La missatgeria s'ha desactivat per a aquest workspace. Reinicia el worker ara per aturar la missatgeria sidecar.", + "identities.restart_to_enable_messaging": "S'ha activat la missatgeria per a aquest workspace. Reinicia el worker ara per arrencar el sidecar de missatgeria i desbloquejar la configuració de Telegram i Slack.", + "identities.restart_worker": "Reinicia el worker", + "identities.restart_worker_title": "Vols reiniciar el worker ara?", + "identities.restarting": "S'està reiniciant...", + "identities.routing_override_prefix": "Tots els missatges dirigits a", + "identities.routing_override_suffix": "(anul·lació activa)", + "identities.running_label": "En execució", + "identities.save_behavior": "Desa el comportament", + "identities.saving": "S'està desant...", + "identities.send_test_button": "Envia un missatge de prova", + "identities.send_test_desc": "Valida la configuració de sortida. Fes servir un peer ID per a l'enviament directe, o deixa'l buit perquè s'enviï segons els enllaços d'un directori.", + "identities.send_test_title": "Envia un missatge de prova", + "identities.sending": "S'està enviant...", + "identities.slack_desc": "El teu worker apareix com a bot als canals de Slack. Els membres de l'equip li poden escriure directament o mencionar-lo en fils.", + "identities.slack_intro": "Connecta el teu workspace de Slack perquè els membres de l'equip puguin interactuar amb aquest worker des de canals i missatges directes.", + "identities.slack_unavailable": "Les identitats Slack no estan disponibles.", + "identities.status_active": "Actiu", + "identities.status_label": "Estat", + "identities.status_stopped": "Parat", + "identities.stopped_label": "Parat", + "identities.subtitle": "Permet que la gent arribi al teu worker a través d'apps de missatgeria. Connecta un canal i el worker llegirà i respondrà automàticament els missatges.", + "identities.tab_general": "General", + "identities.telegram_bot_access_desc": "Bot públic: el primer xat de Telegram queda enllaçat automàticament. Bot privat: cal un codi d'aparellament abans que cap missatge pugui executar eines.", + "identities.telegram_delete_failed": "No s'ha pogut suprimir.", + "identities.telegram_deleted": "S'ha suprimit.", + "identities.telegram_deleted_pending": "S'ha suprimit (pendent d'aplicar).", + "identities.telegram_desc": "Connecta un bot de Telegram en mode públic (bústia oberta) o en mode privat (cal codi d'aparellament).", + "identities.telegram_private_saved_pair": "Bot privat desat. Aparella't amb /pair {code}", + "identities.telegram_save_failed": "No s'ha pogut desar.", + "identities.telegram_saved": "Desat.", + "identities.telegram_saved_pending": "Desat (pendent d'aplicar).", + "identities.telegram_saved_username": "Desat (@{username})", + "identities.telegram_unavailable": "Les identitats Telegram no estan disponibles.", + "identities.title": "Canals de missatgeria", + "identities.unsaved_changes": "Canvis no desats", + "identities.worker_offline": "worker fora de línia", + "identities.worker_online": "worker en línia", + "identities.worker_restarted": "worker reiniciat.", + "identities.worker_restarted_refreshing": "worker reiniciat. S'està actualitzant l'estat dels missatges...", + "identities.worker_scope_unavailable": "L'àmbit del worker no està disponible.", + "identities.worker_scope_unavailable_detail": "L'àmbit del worker no està disponible. Torna't a connectar amb una URL de worker o canvia a un worker conegut.", + "identities.worker_unavailable": "worker no disponible", + "identities.workspace_id_required": "Cal l'ID del workspace per gestionar les identitats. Torna a connectar-te amb una URL de workspace o selecciona'n un d'assignat en aquest host.", + "identities.workspace_scope_prefix": "Àmbit del workspace:", + "inbox_panel.connect_to_download": "Connecta't a un worker per baixar fitxers compartits.", + "inbox_panel.connect_to_see": "Connecta't per veure els fitxers compartits.", + "inbox_panel.connect_to_upload": "Connecta't a un worker per pujar", + "inbox_panel.copy_failed": "La còpia ha fallat. El vostre navegador pot bloquejar l'accés al porta-retalls.", + "inbox_panel.download": "Descarregar", + "inbox_panel.drop_to_upload": "Deixa fitxers aquí per pujar-los", + "inbox_panel.helper_text": "Comparteix fitxers amb aquest worker des de l'aplicació.", + "inbox_panel.load_failed": "No s'ha pogut carregar la carpeta compartida", + "inbox_panel.missing_file_id": "Falta l'identificador del fitxer compartit.", + "inbox_panel.no_files": "Encara no s'ha compartit cap fitxer.", + "inbox_panel.refresh_tooltip": "Actualitza la carpeta compartida", + "inbox_panel.shared_folder": "Carpeta compartida", + "inbox_panel.showing_first": "Es mostra el primer {count}.", + "inbox_panel.upload_failed": "La càrrega de la carpeta compartida ha fallat", + "inbox_panel.upload_needs_worker": "Connecta't a un worker per carregar fitxers a la carpeta compartida.", + "inbox_panel.upload_prompt": "Deixa anar els fitxers o fes clic per pujar", + "inbox_panel.upload_success": "S'ha penjat a la carpeta compartida.", + "inbox_panel.uploading": "S'està carregant...", + "inbox_panel.uploading_label": "S'està carregant {label}...", + "mcp.activate_button": "Activar", + "mcp.add_modal_subtitle": "Connecta un servidor MCP personalitzat mitjançant una URL o una comanda local.", + "mcp.add_modal_title": "Afegeix una aplicació personalitzada", + "mcp.add_server_button": "Afegeix una aplicació", + "mcp.advanced": "Avançat", + "mcp.advanced_settings": "Configuració avançada", + "mcp.advanced_settings_hint": "Edita els fitxers de configuració i gestiona les connexions manualment.", + "mcp.app_connected": "aplicació connectada", + "mcp.apps_connected": "aplicacions connectades", + "mcp.apps_subtitle": "Connecta les teves eines preferides perquè OpenWork les pugui fer servir per tu.", + "mcp.apps_title": "Aplicacions", + "mcp.auth.already_connected": "Ja està connectat", + "mcp.auth.already_connected_description": "{server} ja està autenticat i llest per utilitzar.", + "mcp.auth.applying_changes_body": "Estem reiniciant el worker perquè el nou MCP estigui llest per autenticar-se.", + "mcp.auth.applying_changes_title": "Aplicant canvis abans d'iniciar la sessió", + "mcp.auth.authorization_link": "Enllaç d'autorització", + "mcp.auth.authorization_still_required": "Encara cal autorització. Torna-ho a provar per reiniciar el flux.", + "mcp.auth.callback_invalid": "Enganxa l'URL de devolució de trucada o el paràmetre de codi per finalitzar OAuth.", + "mcp.auth.callback_label": "URL de retorn o codi", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "Cancel·la", + "mcp.auth.client_registration_required": "Cal registrar el client abans que OAuth pugui continuar.", + "mcp.auth.complete_connection": "Connexió completa", + "mcp.auth.configured_previously": "És possible que el MCP s'hagi configurat globalment o en una sessió anterior. Podeu tancar aquest modal i començar a utilitzar les eines MCP immediatament.", + "mcp.auth.connect_server": "Connecta {server}", + "mcp.auth.copied": "Copiat", + "mcp.auth.copy_link": "Copia l'enllaç", + "mcp.auth.done": "Fet", + "mcp.auth.failed_to_start_oauth": "No s'ha pogut iniciar el flux OAuth", + "mcp.auth.follow_browser_steps": "Segueix els passos d'autorització al navegador.", + "mcp.auth.force_stop": "Força l'aturada", + "mcp.auth.force_stopping": "Aturant...", + "mcp.auth.im_done": "Ja està", + "mcp.auth.invalid_refresh_token": "El token d'actualització OAuth no és vàlid o ha caducat. Torna a autoritzar per continuar.", + "mcp.auth.manual_finish_hint": "Enganxa l'URL de retorn (localhost:19876) o només el codi per acabar la connexió.", + "mcp.auth.manual_finish_title": "Servidor remot?", + "mcp.auth.oauth_completed_reload": "OAuth completat. Torna a carregar el motor per activar el MCP.", + "mcp.auth.oauth_failed": "L'autenticació OAuth ha fallat.", + "mcp.auth.oauth_not_supported_hint": "Això pot voler dir:\n• El servidor MCP no anuncia capacitats OAuth\n• Cal recarregar el motor per descobrir les capacitats del servidor\n• Prova: opencode mcp auth {server} des de la CLI", + "mcp.auth.open_browser_signin": "Obrirem el vostre navegador per finalitzar l'inici de sessió.", + "mcp.auth.port_forward_hint": "Consell: reenvia el port de callback si cal: ssh -L 19876:127.0.0.1:19876 usuari@host", + "mcp.auth.reauth_action": "Torna a autoritzar OAuth", + "mcp.auth.reauth_cli_hint": "Executa: opencode mcp auth {server}", + "mcp.auth.reauth_failed": "La reautorització ha fallat.", + "mcp.auth.reauth_remote_hint": "Torna a autoritzar des de la màquina que executa aquest worker.", + "mcp.auth.reauth_running": "S'està reautoritzant...", + "mcp.auth.reload_blocked": "La recàrrega queda aturada mentre s'està executant una sessió. Atura l'execució per acabar la configuració.", + "mcp.auth.reload_engine_retry": "Aplica els canvis i torna-ho a provar", + "mcp.auth.reload_failed": "No s'ha pogut tornar a carregar el worker abans d'iniciar la sessió.", + "mcp.auth.reload_notice": "Perquè això tingui efecte, OpenWork ha de refrescar el servei del worker. Això pot interrompre una sessió en curs.", + "mcp.auth.reload_remote_confirm": "Perquè això tingui efecte, OpenWork ha d'actualitzar el servei worker. Això pot aturar la sessió de carrera. Continuar?", + "mcp.auth.reopen_browser_link": "Fes clic aquí per tornar a obrir el navegador", + "mcp.auth.request_timed_out": "La sol·licitud s'ha esgotat.", + "mcp.auth.retry": "Torna-ho a provar", + "mcp.auth.retry_now": "Torna-ho a provar ara", + "mcp.auth.server_disabled": "Aquest servidor MCP està desactivat. Activa'l i torna-ho a provar.", + "mcp.auth.step1_description": "Llançarem automàticament el flux d'inici de sessió de {server}.", + "mcp.auth.step1_title": "Obrint el teu navegador", + "mcp.auth.step2_description": "Inicia la sessió i aprova l'accés quan se't demani.", + "mcp.auth.step2_title": "Autoritzeu OpenWork", + "mcp.auth.step3_description": "Acabarem la connexió tan bon punt s'hagi completat l'autorització.", + "mcp.auth.step3_title": "Torna aquí quan hagis acabat", + "mcp.auth.try_reload_engine": "{message}. Prova primer de recarregar el motor.", + "mcp.auth.waiting_authorization": "S'està esperant que es completi l'autorització al vostre navegador...", + "mcp.auth.waiting_for_conversation_body": "Us redirigirem per autenticar-vos tan aviat com sigui possible.", + "mcp.auth.waiting_for_conversation_title": "Esperant que finalitzi la conversa", + "mcp.auth.waiting_for_session": "S'està esperant que {session} acabi de treballar", + "mcp.available_apps": "Aplicacions disponibles", + "mcp.cap_signin": "Inici de sessió al compte", + "mcp.cap_tools": "Eines d'IA", + "mcp.config_file": "Fitxer de configuració", + "mcp.config_load_failed": "No s'ha pogut carregar el fitxer de configuració", + "mcp.config_not_loaded": "Encara no s'ha carregat", + "mcp.config_source": "Des de config", + "mcp.configured": "configurat", + "mcp.connect": "Connecta't", + "mcp.connect_failed": "No s'ha pogut connectar. Torna-ho a provar.", + "mcp.connect_server_first": "Connecta't primer al servidor.", + "mcp.connected": "Connectat", + "mcp.connected_badge": "Connectat", + "mcp.connecting": "S'està connectant...", + "mcp.connection_failed": "Problema de connexió: torna-ho a provar", + "mcp.connection_type": "Connexió", + "mcp.control_chrome_browser_hint": "A Chrome 144 o superior, fes això primer:", + "mcp.control_chrome_browser_step_one": "Obre chrome://inspect/#remote-debugging.", + "mcp.control_chrome_browser_step_two": "Activa la depuració remota.", + "mcp.control_chrome_browser_step_three": "Permet connexions de depuració entrants quan Chrome ho demani.", + "mcp.control_chrome_browser_title": "1. Activa l'accés a Chrome", + "mcp.control_chrome_connect": "Afegeix Control Chrome", + "mcp.control_chrome_docs": "Guia oficial MCP", + "mcp.control_chrome_edit": "Edita la configuració", + "mcp.control_chrome_profile_hint": "Control Chrome normalment obre un perfil Chrome independent. Activeu-ho si voleu que OpenWork reutilitzi la finestra Chrome que ja teniu oberta.", + "mcp.control_chrome_profile_title": "2. Tria quin Chrome vols fer servir", + "mcp.control_chrome_save": "Desa la configuració", + "mcp.control_chrome_setup_subtitle": "Activa l'accés a Chrome i tria si OpenWork ha de fer servir un perfil net propi o enganxar-se al Chrome que ja tens obert.", + "mcp.control_chrome_setup_title": "Configura Control Chrome", + "mcp.control_chrome_toggle_hint": "Quan això està activat, OpenWork afegeix --autoConnect de manera que el MCP s'adjunta a una instància Chrome que ja heu iniciat.", + "mcp.control_chrome_toggle_label": "Utilitza el meu perfil Chrome existent", + "mcp.control_chrome_toggle_off": "OpenWork llançarà un perfil Chrome independent només per a l'automatització.", + "mcp.control_chrome_toggle_on": "OpenWork reutilitzarà les vostres pestanyes, galetes i inicis de sessió actuals.", + "mcp.custom_app_cta_hint": "Connecta el teu servidor MCP, una eina interna o una app allotjada.", + "mcp.desktop_required": "Les aplicacions requereixen el app d'escriptori.", + "mcp.docs_link": "Més informació", + "mcp.file_not_found": "El fitxer de configuració encara no s'ha creat", + "mcp.finish_setup": "Gairebé allà", + "mcp.finish_setup_hint": "Toca Activa per acabar de connectar l'app.", + "mcp.friendly_status_issue": "Problema", + "mcp.friendly_status_needs_signin": "Cal iniciar sessió", + "mcp.friendly_status_offline": "Fora de línia", + "mcp.friendly_status_paused": "En pausa", + "mcp.friendly_status_ready": "A punt", + "mcp.last_synced": "Sincronitzat", + "mcp.login_action": "Inicia la sessió", + "mcp.login_hint": "Connecta el teu compte per acabar de configurar aquesta app.", + "mcp.login_unavailable": "Aquesta app no admet l'inici de sessió des d'OpenWork.", + "mcp.logout_action": "Tanca la sessió", + "mcp.logout_failed": "No s'ha pogut tancar la sessió.", + "mcp.logout_hint": "Elimina les credencials OAuth emmagatzemades. Haureu de tornar a iniciar la sessió.", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "Això eliminarà les credencials OAuth emmagatzemades per a {server}. Haureu de tornar a iniciar la sessió per utilitzar aquesta aplicació.", + "mcp.logout_modal_title": "Tanca la sessió d'aquesta aplicació?", + "mcp.logout_success": "S'ha tancat la sessió de {server}.", + "mcp.logout_working": "Tancant la sessió...", + "mcp.name_required": "Introdueix un nom de servidor.", + "mcp.no_apps_hint": "Connecta'n una a dalt per començar.", + "mcp.no_apps_yet": "Encara no hi ha cap aplicació connectada", + "mcp.oauth": "Inicia la sessió", + "mcp.oauth_optional_hint": "Utilitza OAuth al navegador per connectar el vostre compte.", + "mcp.oauth_optional_label": "Aquesta aplicació requereix iniciar sessió", + "mcp.one_click_connect": "Connecta amb un sol clic", + "mcp.open_file": "Obre el fitxer", + "mcp.opening_label": "S'està obrint...", + "mcp.pick_workspace_error": "Tria primer una carpeta de workspace.", + "mcp.pick_workspace_first": "Tria primer una carpeta de workspace.", + "mcp.quick_connect_chrome_desc": "Controla pestanyes de Chrome amb automatització del navegador.", + "mcp.quick_connect_chrome_title": "Control Chrome", + "mcp.quick_connect_context7_desc": "Cerca documentació del producte amb més context.", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "Planifica sprints i lliura tickets més de pressa.", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "Pàgines, bases de dades i documents del projecte sincronitzats.", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "Segueix les releases i resol errors de producció.", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "Consulta pagaments, factures i subscripcions.", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "Atura la tasca en execució per activar-la.", + "mcp.reload_banner_description": "Toca Activa per acabar de connectar l'aplicació.", + "mcp.reload_banner_description_blocked": "S'està executant una tasca. Atura-la primer i després activa-la.", + "mcp.remote_workspace_url_hint": "Els workers remots es connecten més ràpidament amb servidors MCP basats en URL.", + "mcp.remove_app": "Eliminar", + "mcp.remove_failed": "No s'ha pogut eliminar l'aplicació.", + "mcp.remove_modal_message": "Esteu segur que voleu eliminar {server}? Sempre el podeu tornar a afegir més tard.", + "mcp.remove_modal_title": "Elimina l'aplicació", + "mcp.reveal_config_failed": "No s'ha pogut obrir el fitxer de configuració", + "mcp.reveal_in_finder": "Mostra a Finder", + "mcp.scope_global": "Tots els workspaces", + "mcp.scope_project": "Aquest workspace", + "mcp.server_command": "Command", + "mcp.server_command_hint": "La comanda de shell per arrencar el servidor.", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "Nom de l'aplicació", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "Tipus", + "mcp.server_url": "URL del servidor", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "Inicia la sessió", + "mcp.tap_to_connect": "Toca per connectar", + "mcp.technical_details": "Detalls tècnics", + "mcp.type_cloud": "Cloud (inicieu la sessió amb el vostre compte)", + "mcp.type_local": "Local (s'executa en aquest dispositiu)", + "mcp.type_local_cmd": "Local (comanda)", + "mcp.type_remote": "Remot (URL)", + "mcp.url_or_command_required": "Introdueix una URL per als servidors remots o una comanda per als servidors locals.", + "mcp.your_apps": "Les teves aplicacions", + "message.tool_request_label": "Sol·licitud", + "message.tool_result_label": "Resultat", + "message.waiting_subagent": "Esperant que arribi la transcripció del subagent.", + "message_list.copy_message": "Copia el missatge", + "message_list.open_session": "Sessió oberta", + "message_list.step_updates_progress": "Progrés de les actualitzacions", + "message_list.subagent_loading_transcript": "S'està carregant la transcripció", + "message_list.subagent_message_count": "missatge {count}{plural}", + "message_list.subagent_running": "Córrer", + "message_list.subagent_session_fallback": "Sessió de subagent", + "message_list.subagent_type_task": "tasca {agentType}", + "message_list.subagent_waiting_transcript": "Esperant la transcripció", + "message_list.tool_checked_url": "S'ha comprovat {url}", + "message_list.tool_checked_web_fallback": "Pàgina web consultada", + "message_list.tool_delegate_agent": "Delegat {agent}", + "message_list.tool_delegate_task_fallback": "Delegar tasca", + "message_list.tool_load_skill_fallback": "Carregar Skill", + "message_list.tool_load_skill_named": "Carrega l'Skill {name}", + "message_list.tool_read_todo": "Llegeix la llista de tasques", + "message_list.tool_reviewed_file": "S'ha revisat {file}", + "message_list.tool_reviewed_file_fallback": "Fitxer revisat", + "message_list.tool_reviewed_files_fallback": "Fitxers revisats", + "message_list.tool_reviewed_path": "S'ha revisat {path}", + "message_list.tool_run_command": "Executeu {command}", + "message_list.tool_run_command_fallback": "Executa la Command", + "message_list.tool_searched_code_fallback": "Codi cercat", + "message_list.tool_searched_pattern": "S'ha cercat {pattern}", + "message_list.tool_update_file": "Actualitza {file}", + "message_list.tool_update_file_fallback": "Actualitza el fitxer", + "message_list.tool_update_todo": "Actualitza la llista de tasques", + "message_list.tool_updated_file": "{file} actualitzat", + "message_list.tool_updated_file_fallback": "Fitxer actualitzat", + "model_behavior.desc_builtin": "Aquest model decideix el seu propi camí de raonament i no exposa perfils aquí.", + "model_behavior.desc_generic": "Fes servir el perfil {label}.", + "model_behavior.desc_high": "Dedica més temps a raonar abans de respondre.", + "model_behavior.desc_high_anthropic": "Fes servir el pressupost estàndard de pensament estès.", + "model_behavior.desc_low": "Feu servir una passada de raonament més lleugera abans de respondre.", + "model_behavior.desc_low_google": "Fes servir un pressupost de raonament més lleuger per a respostes més ràpides.", + "model_behavior.desc_max": "Fes servir el perfil de raonament més profund del proveïdor.", + "model_behavior.desc_max_anthropic": "Fes servir el pressupost de pensament ampli més gran disponible.", + "model_behavior.desc_medium": "Equilibra velocitat i profunditat de raonament.", + "model_behavior.desc_minimal": "Feu servir una quantitat molt petita de raonament.", + "model_behavior.desc_none": "Afavoreix la velocitat amb el camí de raonament més lleuger.", + "model_behavior.desc_standard": "Aquest model no exposa controls de raonament addicionals.", + "model_behavior.label_balanced": "Equilibrat", + "model_behavior.label_builtin": "Integrat", + "model_behavior.label_deep": "Profund", + "model_behavior.label_extended": "Estesa", + "model_behavior.label_fast": "Ràpid", + "model_behavior.label_light": "Llum", + "model_behavior.label_maximum": "Màxim", + "model_behavior.label_quick": "Ràpid", + "model_behavior.label_standard": "Estàndard", + "model_behavior.title_builtin_reasoning": "Raonament incorporat", + "model_behavior.title_extended_thinking": "Pensament estès", + "model_behavior.title_reasoning_budget": "Raonament pressupostari", + "model_behavior.title_reasoning_effort": "Esforç de raonament", + "model_behavior.title_standard_generation": "Generació estàndard", + "model_picker.chat_model_desc": "Tria el model per a aquest xat. Si un model admet perfils de raonament, configureu-los a la seva targeta.", + "model_picker.chat_model_title": "Model de xat", + "model_picker.connect_provider_hint": "Connecta aquest proveïdor per veure i desar models", + "model_picker.default_model_desc": "Tria el model predeterminat per a xats nous i, a continuació, ajusteu els perfils de raonament a la seva targeta abans de prémer Fet.", + "model_picker.default_model_title": "Model per defecte", + "model_picker.model_count": "Models {count}", + "model_picker.model_count_one": "1 model", + "model_picker.more_providers": "Més proveïdors", + "model_picker.no_results": "No hi ha cap model que coincideixi amb la teva cerca.", + "model_picker.other_connected_models": "Altres models connectats", + "model_picker.recommended": "Recomanat", + "onboarding.access_label": "Accés", + "onboarding.add": "Afegeix", + "onboarding.add_folder_path": "Afegeix el camí de la carpeta", + "onboarding.advanced_settings": "Configuració avançada", + "onboarding.attach": "Adjuntar", + "onboarding.attach_description": "Adjunta a la sessió existent en aquest dispositiu.", + "onboarding.authorize_folder": "Carpeta d'autorització", + "onboarding.back": "Enrere", + "onboarding.checking_cli": "S'està comprovant OpenCode CLI...", + "onboarding.choose_workspace_folder": "Tria la carpeta del workspace", + "onboarding.cli_checking": "S'està comprovant la instal·lació...", + "onboarding.cli_install_commands": "Instal·la OpenCode amb una de les ordres següents i reinicia OpenWork.", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "OpenCode CLI necessita una actualització per al servei.", + "onboarding.cli_not_found": "No s'ha trobat OpenCode CLI.", + "onboarding.cli_not_found_hint": "No s'ha trobat. Instal·la per executar el servidor local.", + "onboarding.cli_ready": "OpenCode CLI llest.", + "onboarding.cli_recheck": "Torna a comprovar", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "Instal·lat", + "onboarding.create_first_workspace": "Crea el teu primer workspace", + "onboarding.create_workspace": "Crea un workspace", + "onboarding.engine_running": "El motor ja funciona", + "onboarding.folders_allowed": "Carpeta {count} {plural} permesa", + "onboarding.getting_ready": "Preparant-ho tot", + "onboarding.install": "Instal·la OpenCode", + "onboarding.install_instruction": "Instal·la OpenCode per habilitar el servidor local (no cal cap terminal).", + "onboarding.last_checked": "Darrera comprovació {time}", + "onboarding.manage_access_hint": "Podeu gestionar l'accés a la configuració avançada.", + "onboarding.open_settings": "Obre Configuració", + "onboarding.open_settings_hint": "Necessites motor o opcions d'accés? Obre Configuració.", + "onboarding.pick": "Tria", + "onboarding.ready_message": "OpenCode està preparat per iniciar el servidor local.", + "onboarding.remember_choice": "Recordeu la meva elecció per a la propera vegada", + "onboarding.remote_workspace_action": "Connecta't", + "onboarding.remote_workspace_card_description": "Connecta't a un servidor OpenWork per accedir a un workspace compartit.", + "onboarding.remote_workspace_card_title": "Connecta un workspace remot", + "onboarding.remote_workspace_description": "Connecta't a un servidor OpenWork per accedir a un workspace des de qualsevol lloc.", + "onboarding.remote_workspace_title": "Connecta't al servidor OpenWork", + "onboarding.remove": "Eliminar", + "onboarding.resolved_path": "Ruta resolta", + "onboarding.run_local": "Executar localment", + "onboarding.run_local_description": "OpenWork executa OpenCode localment i manté la vostra feina privada.", + "onboarding.search_notes": "Cerca notes", + "onboarding.searching_host": "S'està connectant al servidor OpenWork...", + "onboarding.serve_help": "servir --ajudar a la sortida", + "onboarding.show_search_notes": "Mostra les notes de cerca", + "onboarding.start": "Inicia OpenWork", + "onboarding.starting_host": "S'està iniciant el servidor OpenWork...", + "onboarding.theme_current": "Actual: {mode}", + "onboarding.theme_dark": "Fosc", + "onboarding.theme_label": "Tema", + "onboarding.theme_light": "Llum", + "onboarding.theme_system": "Sistema", + "onboarding.verifying": "S'està verificant l'encaixada de mans segura", + "onboarding.version": "Versió", + "onboarding.welcome_title": "Com t'agradaria executar OpenWork avui?", + "onboarding.windows_install_instruction": "Instal·la OpenCode per a Windows i reinicia OpenWork. Assegura't que l'opencode.exe estigui a PATH.", + "onboarding.workspace_folder_label": "Un workspace és una carpeta amb les seves pròpies Skills, Plugins i Commands.", + "plugins.add": "Afegeix", + "plugins.add_hint": "Afegeix noms de paquets npm, p. ex. opencode-wakatime", + "plugins.add_label": "Afegeix un Plugin", + "plugins.added": "Afegit", + "plugins.config": "Config", + "plugins.config_label": "Config", + "plugins.desc": "Gestiona `opencode.json` per al vostre projecte o OpenCode plugins global.", + "plugins.empty": "Encara no s'ha configurat cap Plugin.", + "plugins.enabled": "Habilitat", + "plugins.hide_setup": "Amaga la configuració", + "plugins.not_loaded": "Encara no s'ha carregat", + "plugins.not_loaded_yet": "Encara no s'ha carregat", + "plugins.remove": "Eliminar", + "plugins.scope_global": "Global", + "plugins.scope_project": "Projecte", + "plugins.setup": "Configuració", + "plugins.suggested": "Connectors suggerits", + "plugins.suggested_heading": "Connectors suggerits", + "plugins.title": "OpenCode plugins", + "providers.api_key_label": "Tecla API", + "providers.api_key_required": "La clau API és necessària", + "providers.auth_failed": "L'autenticació ha fallat", + "providers.connect_failed": "No s'ha pogut connectar el proveïdor", + "providers.disabled_in_config_suffix": "i el va desactivar a la configuració de OpenCode.", + "providers.disconnect_failed": "No s'ha pogut desconnectar el proveïdor", + "providers.disconnected_prefix": "Desconnectat", + "providers.load_failed": "No s'han pogut carregar els proveïdors", + "providers.no_oauth_prefix": "No hi ha cap flux OAuth disponible", + "providers.no_providers_available": "No hi ha proveïdors disponibles", + "providers.not_connected": "No connectat a un servidor", + "providers.not_oauth_flow_prefix": "El mètode d'autenticació seleccionat no és un flux OAuth per", + "providers.oauth_failed": "No s'ha pogut completar OAuth", + "providers.oauth_method_required": "És necessari el mètode OAuth", + "providers.provider_error": "Error del proveïdor ({provider})", + "providers.provider_id_required": "Es requereix l'identificador del proveïdor", + "providers.rate_limit_exceeded": "S'ha superat el límit de tarifa", + "providers.removal_unsupported": "Aquest client no admet l'eliminació de l'autenticació del proveïdor.", + "providers.request_failed": "La sol·licitud ha fallat", + "providers.save_api_key_failed": "No s'ha pogut desar la clau API", + "providers.still_connected_suffix": ", però el worker encara informa que està connectat. Esborra qualsevol clau API o credencial OAuth que quedi i reinicia el worker per desconnectar-lo completament.", + "providers.unknown_provider": "Proveïdor desconegut", + "providers.use_api_key_suffix": "Fes servir una clau API.", + "question_modal.custom_answer_label": "O escriviu una resposta personalitzada", + "question_modal.custom_answer_placeholder": "Escriu la teva resposta aquí...", + "question_modal.question_counter": "Pregunta {current} de {total}", + "session.allow_for_session": "Permet la sessió", + "session.allow_once": "Permet una vegada", + "session.api_key_saved": "S'ha desat la clau API", + "session.attachments_add_token": "Afegeix un token de servidor per adjuntar fitxers.", + "session.attachments_connect_server": "Connecta't al servidor OpenWork per adjuntar fitxers.", + "session.back": "Enrere", + "session.close_quick_actions": "Tanca les accions ràpides", + "session.close_search": "Tanca la cerca", + "session.cmd_compact_detail": "Envieu una instrucció compacta a OpenCode per a aquesta sessió", + "session.cmd_compact_detail_empty": "Encara no hi ha missatges d'usuari per compactar", + "session.cmd_compact_meta": "Compacte", + "session.cmd_compact_title": "Conversa compacta", + "session.cmd_current_workspace": "Workspace actual", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "Model", + "session.cmd_model_meta": "Obert", + "session.cmd_model_title": "Canviar de model", + "session.cmd_new_session_detail": "Inicia una tasca nova al workspace actual", + "session.cmd_new_session_meta": "Crear", + "session.cmd_new_session_title": "Crea una sessió nova", + "session.cmd_provider_detail": "Obre el flux de connexió del proveïdor", + "session.cmd_provider_meta": "Obert", + "session.cmd_provider_title": "Connecta el proveïdor", + "session.cmd_rename_detail_fallback": "Doneu un nom més clar a la sessió seleccionada", + "session.cmd_rename_meta": "Canvia el nom", + "session.cmd_rename_title": "Canvia el nom de la sessió actual", + "session.cmd_sessions_detail": "{count} disponible a tots els workspaces", + "session.cmd_sessions_meta": "Saltar", + "session.cmd_sessions_title": "Sessions de cerca", + "session.cmd_switch": "Canvia", + "session.compacted": "Sessió compactada.", + "session.compacting": "S'està compactant el context de la sessió...", + "session.compacting_auto": "OpenCode està compactant automàticament aquesta sessió", + "session.compacting_manual": "OpenCode està compactant aquesta sessió", + "session.compaction_finished": "OpenCode ha acabat de compactar el context de sessió.", + "session.compaction_started": "OpenCode va començar a compactar el context de sessió.", + "session.conflict_sync_toast": "Sincronització de conflictes {path}. S'han desat els canvis locals a {conflictPath}.", + "session.connect_failed": "La connexió ha fallat", + "session.connect_to_sync": "Connecta't al servidor OpenWork per sincronitzar fitxers remots.", + "session.create_or_connect_workspace": "Crea o connecteu un workspace", + "session.create_workspace_desc": "Obre el creador del workspace i tria com voleu començar.", + "session.create_workspace_title": "Crea un workspace", + "session.default_agent": "Agent per defecte", + "session.default_title": "Nova sessió", + "session.delete": "Suprimeix", + "session.delete_named_session_message": "Això suprimirà permanentment \"{title}\" i els seus missatges.", + "session.delete_session_generic": "Això suprimirà permanentment la sessió seleccionada i els seus missatges.", + "session.delete_session_title": "Vols suprimir la sessió?", + "session.deleted": "S'ha suprimit la sessió", + "session.deleting": "S'està suprimint...", + "session.deny": "Negar", + "session.details": "Detalls", + "session.details_label": "Detalls", + "session.doom_loop_label": "Doom Loop", + "session.doom_loop_message": "OpenCode ha detectat trucades d'eina repetides amb entrada idèntica i pregunta si hauria de continuar després d'errors repetits.", + "session.doom_loop_note": "Rebutja per aturar el bucle o permet si vols que l'agent ho continuï intentant.", + "session.doom_loop_repeated_call_label": "Trucada repetida", + "session.doom_loop_repeated_tool_call": "Crida repetida a l'eina", + "session.doom_loop_title": "S'ha detectat Doom Loop", + "session.doom_loop_tool_label": "Eina", + "session.downloading": "Descàrrega", + "session.downloading_percent": "S'està baixant {percent}%", + "session.downloading_update_title": "S'està baixant l'actualització {version}", + "session.export_already_running": "L'exportació ja s'està executant.", + "session.export_desktop_only": "L'exportació està disponible a l'app d'escriptori.", + "session.export_desktop_only_local": "L'exportació està disponible per a workers locals a l'app d'escriptori.", + "session.export_local_only": "L'exportació només és compatible amb workers locals.", + "session.failed_to_compact": "No s'ha pogut compactar la sessió", + "session.failed_to_create_session": "No s'ha pogut crear la sessió", + "session.failed_to_delete": "No s'ha pogut suprimir la sessió", + "session.failed_to_load_agents": "No s'han pogut carregar els agents", + "session.failed_to_load_providers": "No s'han pogut carregar els proveïdors", + "session.failed_to_redo": "No s'ha pogut refer", + "session.failed_to_save_api_key": "No s'ha pogut desar la clau API", + "session.failed_to_stop": "No s'ha pogut aturar", + "session.failed_to_undo": "No s'ha pogut desfer", + "session.file_open_desktop_only": "Aquesta funció només està disponible a l'app d'escriptori.", + "session.file_open_failed": "No s'ha pogut obrir el fitxer", + "session.file_open_remote_unavailable": "El fitxer obert no està disponible per a workspaces remots.", + "session.flyout_file_modified": "Fitxer modificat", + "session.flyout_new_task": "Nova tasca", + "session.install_update": "Instal·la l'actualització", + "session.jump_to_latest": "Ves a l'últim", + "session.jump_to_start": "Salta a l'inici del missatge", + "session.load_earlier": "Carrega missatges anteriors", + "session.loading_detail": "Recollint els missatges més recents per a aquesta tasca.", + "session.loading_earlier": "S'estan carregant missatges anteriors...", + "session.loading_session": "Sessió de càrrega", + "session.loading_title": "Sessió de càrrega", + "session.menu_label": "Menú", + "session.model": "Model", + "session.model_fallback": "Model", + "session.new_task": "Nova tasca", + "session.next_match": "Proper partit", + "session.no_matches": "Sense coincidències", + "session.no_matches_command": "Sense coincidències.", + "session.no_session_selected": "No s'ha seleccionat cap sessió", + "session.nothing_to_compact": "Encara no hi ha res per compactar.", + "session.nothing_to_redo": "Res a refer.", + "session.nothing_to_retry": "Encara no hi ha res per tornar a provar", + "session.nothing_to_undo": "Encara no hi ha res a desfer.", + "session.oauth_failed": "OAuth ha fallat", + "session.obsidian_worker_relative_only": "Només es poden obrir fitxers relatius a worker a Obsidian.", + "session.open": "Obert", + "session.palette_hint_navigate": "Tecles de fletxa per navegar", + "session.palette_hint_run": "Entra per executar · Esc per tancar", + "session.palette_placeholder_actions": "Accions de cerca", + "session.palette_placeholder_sessions": "Cerca per títol de sessió o workspace", + "session.palette_title_actions": "Accions ràpides", + "session.palette_title_sessions": "Sessions de cerca", + "session.permission_detail_command": "Ordre", + "session.permission_detail_cwd": "Directori de treball", + "session.permission_detail_description": "Descripció", + "session.permission_detail_diff": "Diferència", + "session.permission_detail_file": "Fitxer", + "session.permission_detail_files": "Fitxers", + "session.permission_detail_agent": "Agent", + "session.permission_detail_parent_directory": "Directori pare", + "session.permission_detail_path": "Camí", + "session.permission_detail_query": "Consulta", + "session.permission_detail_target": "Objectiu", + "session.permission_detail_tool": "Eina", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "Edició de fitxer", + "session.permission_kind_external_directory": "Directori extern", + "session.permission_kind_question": "Pregunta", + "session.permission_kind_read": "Lectura de fitxer", + "session.permission_kind_skill": "Skill", + "session.permission_kind_task": "Subtasca", + "session.permission_kind_todowrite": "Escriptura de tasques", + "session.permission_label": "Permís", + "session.permission_message": "OpenCode està sol·licitant permís per continuar.", + "session.permission_message_bash": "Revisa l'abast de l'ordre abans de permetre que OpenCode continuï.", + "session.permission_message_edit": "Revisa el fitxer i la diferència abans de permetre que OpenCode faci canvis.", + "session.permission_message_external_directory": "Revisa la carpeta abans de permetre l'accés fora del workspace.", + "session.permission_message_read": "Revisa l'abast de fitxers sol·licitat abans de permetre l'accés.", + "session.permission_message_task": "Revisa la subtasca sol·licitada abans de permetre que comenci.", + "session.permission_metadata_unavailable": "No s'han pogut mostrar les metadades.", + "session.permission_required": "Permís necessari", + "session.permission_review_label": "Revisió", + "session.permission_scope_empty": "No s'ha proporcionat cap àmbit específic.", + "session.permission_decision_hint": "Permet una vegada per a aquesta sol·licitud, o per a la sessió quan confiïs en aquest àmbit.", + "session.permission_title_bash": "Executar una ordre de shell?", + "session.permission_title_edit": "Modificar fitxers?", + "session.permission_title_external_directory": "Accedir a una carpeta externa?", + "session.permission_title_generic": "Aprovar {permission}?", + "session.permission_title_read": "Llegir fitxers?", + "session.permission_title_task": "Iniciar una subtasca?", + "session.phase_responding": "Responent", + "session.phase_retrying": "S'està tornant a provar", + "session.phase_run_failed": "L'execució ha fallat", + "session.phase_sending": "Enviament", + "session.pick_folder_desc": "Tria un projecte existent o una carpeta de notes i OpenWork l'utilitzarà com a workspace.", + "session.pick_folder_title": "Tria una carpeta on vulguis treballar", + "session.pick_workspace_to_open": "Tria un workspace per obrir fitxers.", + "session.prev_match": "Partit anterior", + "session.provider_auth_in_progress": "L'autenticació del proveïdor ja està en curs.", + "session.provider_connected": "Proveïdor connectat", + "session.quick_actions_label": "Accions ràpides", + "session.quick_actions_title": "Accions ràpides (Ctrl/Cmd+K)", + "session.redo_aria_label": "Refeix l'últim missatge revertit", + "session.redo_label": "Refer", + "session.redo_title": "Refeix l'últim missatge revertit", + "session.remote_sync_failed": "La sincronització remota de fitxers ha fallat", + "session.rename_description": "Actualitza el nom d'aquesta sessió.", + "session.rename_label": "Nom de la sessió", + "session.rename_placeholder": "Introdueix un nom nou", + "session.rename_title": "Canvia el nom de la sessió", + "session.resize_workspace_column": "Canvia la mida de la columna del workspace", + "session.restart_update_title": "Reinicia per aplicar l'actualització {version}", + "session.restored_message": "S'ha restaurat el missatge revertit.", + "session.reveal": "Revelar", + "session.reveal_desktop_only": "Aquesta funció només està disponible a l'app d'escriptori.", + "session.revert_label": "Revertir", + "session.reverted_last_message": "S'ha revertit l'últim missatge de l'usuari.", + "session.run": "Corre", + "session.scope_label": "Àmbit", + "session.search_conversation_label": "Cerca conversa", + "session.search_conversation_title": "Cerca conversa (Ctrl/Cmd+F)", + "session.search_next": "Següent", + "session.search_placeholder": "Cerca en aquest xat", + "session.search_position": "{current} de {total}", + "session.search_prev": "Anterior", + "session.share_active_cloud_org": "Organització activa de Cloud", + "session.share_choose_org": "Tria una organització a Configuració -> Cloud abans de compartir-la amb el teu equip.", + "session.share_collaborator_hint": "Accés remot de rutina quan no necessiteu accions només del propietari.", + "session.share_collaborator_host_hint": "Accés remot habitual a aquest host sense accions només del propietari.", + "session.share_collaborator_label": "Token de col·laborador", + "session.share_collaborator_token": "Token de col·laborador", + "session.share_connected_with_hint": "Aquest workspace està connectat actualment amb aquesta contrasenya.", + "session.share_desktop_app_required": "Cal l'app d'escriptori", + "session.share_desktop_required": "Cal l'app d'escriptori", + "session.share_host_url_and_token_required": "Calen l'URL i el token del host d'OpenWork.", + "session.share_local_host_not_ready": "El host local d'OpenWork encara no està a punt.", + "session.share_missing_host_url": "Falta l'URL del host d'OpenWork.", + "session.share_missing_token": "Falta el token d'OpenWork.", + "session.share_no_skills": "No s'han trobat Skills en aquest workspace.", + "session.share_note_direct_runtime": "El runtime del motor està configurat en Direct. Canviar de worker local pot reiniciar el host i desconnectar els clients. El token pot canviar després del reinici.", + "session.share_opencode_base_url": "URL base OpenCode", + "session.share_openwork_workers_only": "Els enllaços de servei de compartició estan disponibles per a OpenWork workers.", + "session.share_owner_permission_hint": "Fes servir-lo quan el client remot hagi de respondre a les sol·licituds de permís.", + "session.share_password": "Contrasenya", + "session.share_password_owner_hint": "Fes servir-lo quan el client remot hagi de respondre a les sol·licituds de permís.", + "session.share_publish_skills_failed": "No s'ha pogut publicar el conjunt d'Skills", + "session.share_publish_workspace_failed": "No s'ha pogut publicar el perfil del workspace", + "session.share_resolve_local_workspace_failed": "No s'ha pogut resoldre aquest workspace al host local d'OpenWork.", + "session.share_resolve_remote_workspace_failed": "No s'ha pogut resoldre aquest workspace al host d'OpenWork.", + "session.share_save_team_template_failed": "No s'ha pogut desar la plantilla d'equip", + "session.share_saved_to_org": "S'ha desat {name} a {org}.", + "session.share_select_workspace": "Selecciona primer un workspace.", + "session.share_set_token_hint": "Defineix el token a la configuració del workspace", + "session.share_sign_in_required": "Inicia la sessió a OpenWork Cloud a Configuració per compartir-lo amb el vostre equip.", + "session.share_skills_set_desc": "Conjunt complet d'Skills des d'un workspace OpenWork.", + "session.share_starting_server": "S'està iniciant el servidor...", + "session.share_team_fallback_name": "les plantilles del teu equip", + "session.share_url_resolving_hint": "S'està resolent l'URL del worker; mentrestant es mostra l'URL del host com a alternativa.", + "session.share_url_worker_hint": "Fes servir-lo en telèfons o ordinadors portàtils connectats a aquest worker.", + "session.share_worker_url": "URL del worker", + "session.share_worker_url_phones_hint": "Fes servir-lo en telèfons o ordinadors portàtils connectats a aquest worker.", + "session.share_worker_url_resolving_hint": "S'està resolent l'URL del worker; mentrestant es mostra l'URL del host com a alternativa.", + "session.shared_folder_upload_failed": "La càrrega de la carpeta compartida ha fallat", + "session.show_earlier": "Mostra {count} missatge{plural} anteriors", + "session.status_active": "Sessió activa", + "session.status_compacting": "Context compactant", + "session.status_delegating": "Delegant", + "session.status_gathering_context": "Recollida de context", + "session.status_planning": "Planificació", + "session.status_ready": "A punt", + "session.status_ready_session": "Sessió llesta", + "session.status_running_shell": "Executant la shell", + "session.status_searching_codebase": "Buscant la base de codi", + "session.status_searching_web": "Buscant a la web", + "session.status_thinking": "Pensant", + "session.status_working": "Treballant", + "session.status_writing_file": "Escrivint fitxer", + "session.stopped": "Parat.", + "session.stopping_run": "Aturant la carrera...", + "session.todo_progress": "{completed} de les tasques {total} completades", + "session.trying_again": "Tornant a provar...", + "session.unable_to_open_file": "No es pot obrir el fitxer", + "session.unable_to_open_obsidian": "No es pot obrir el fitxer a Obsidian", + "session.unable_to_reveal": "No es pot revelar el workspace", + "session.undo_label": "Revertir", + "session.undo_title": "Desfer l'últim missatge", + "session.update_available": "Actualització disponible", + "session.update_available_title": "Actualització disponible {version}", + "session.update_ready": "Actualització a punt", + "session.update_ready_stop_runs_title": "Actualitzar {version} a punt. Atura les execucions actives per reiniciar-se.", + "session.upload_connect_server": "Connecta't al servidor OpenWork per carregar fitxers a la carpeta compartida.", + "session.uploaded_to_shared_folder": "S'ha penjat a la carpeta compartida.", + "session.uploaded_with_summary": "Penjat a la carpeta compartida: {summary}", + "session.uploading_to_shared_folder": "S'està penjant {label} a la carpeta compartida...", + "session.workspace_fallback": "Workspace", + "session.workspace_label": "Workspace", + "session.workspace_path_unavailable": "El camí del workspace no està disponible.", + "session.workspace_setup_desc": "Comença amb un workspace guiada d'OpenWork o tria una carpeta existent on vulguis treballar.", + "session.workspace_setup_label": "Configuració del workspace", + "session.workspace_setup_title": "Configura el teu primer workspace", + "settings.action_download": "Descarregar", + "settings.action_install": "Instal·lar", + "settings.actor_host": "host", + "settings.actor_remote": "remot", + "settings.actor_unknown": "desconegut", + "settings.advanced": "Avançat", + "settings.advanced_title": "Avançat", + "settings.api_keys_info": "Les claus API es desen localment a OpenCode. Els proveïdors basats en variables d'entorn s'han de canviar a l'entorn del worker i després recarregar.", + "settings.appearance_hint": "Segueix el sistema o força el mode clar/fosc.", + "settings.appearance_title": "Aparença", + "settings.audit_error": "Error", + "settings.audit_loading": "Carregant", + "settings.audit_log_title": "Registre d'auditoria", + "settings.audit_ready": "A punt", + "settings.auto_compact": "Compactació automàtica del context", + "settings.auto_compact_desc": "Controla OpenCode compaction.auto per a aquest workspace. Torna a carregar el motor després de canviar-lo.", + "settings.auto_update_desc": "Descarrega les actualitzacions automàticament (et demana", + "settings.auto_update_title": "Actualització automàtica", + "settings.available_count": "{count} disponibles", + "settings.background_checks_desc": "OpenWork sempre comprova si hi ha actualitzacions en arrencar. També ho comprova un cop", + "settings.background_checks_title": "Comprovacions en segon pla", + "settings.base_url_unavailable": "URL base no disponible", + "settings.binary_unavailable": "Binari no disponible", + "settings.cache_nothing_to_repair": "No s'ha trobat la memòria cache OpenCode. Res a reparar.", + "settings.cache_repair_requires_desktop": "La reparació de la memòria cache requereix l'app d'escriptori", + "settings.cache_repaired": "S'ha reparat la memòria cache OpenCode. Reinicia el motor si estava en marxa.", + "settings.cap_browser_tools": "Eines del navegador: {value}", + "settings.cap_commands": "Commands: {value}", + "settings.cap_config": "Configuració: {value}", + "settings.cap_file_tools": "Eines de fitxer: {value}", + "settings.cap_inbox_off": "safata d'entrada desactivada", + "settings.cap_inbox_on": "safata d'entrada activada", + "settings.cap_mcp": "MCP: {value}", + "settings.cap_outbox_off": "bústia de sortida desactivada", + "settings.cap_outbox_on": "bústia de sortida activada", + "settings.cap_plugins": "Plugins: {value}", + "settings.cap_read": "llegir", + "settings.cap_sandbox": "Sandbox: {value}", + "settings.cap_skills": "Skills: {value}", + "settings.cap_write": "escriure", + "settings.capabilities_title": "Capacitats del servidor OpenWork", + "settings.capabilities_unavailable": "Capacitats no disponibles. Connecta't amb un token de client.", + "settings.change": "Canviar", + "settings.check_update": "Comprova", + "settings.checking_for_updates": "S'estan buscant actualitzacions", + "settings.choose": "Tria", + "settings.clear": "Esborra", + "settings.clipboard_unavailable": "El porta-retalls no està disponible en aquest entorn.", + "settings.configure": "Configura", + "settings.connect_opencode_hint": "Connecta't a OpenCode per carregar proveïdors.", + "settings.connect_provider": "Connecta un proveïdor", + "settings.connected_count": "{count} connectat", + "settings.connection": "Connexió", + "settings.connection_failed": "La connexió ha fallat", + "settings.connection_title": "Connexió", + "settings.copied_debug_report": "S'ha copiat l'informe JSON del runtime.", + "settings.copy_failed": "No s'ha pogut copiar l'informe del runtime.", + "settings.copy_json": "Copia JSON", + "settings.custom_binary_hint": "Fes servir-ho per apuntar OpenWork a una compilació OpenCode local", + "settings.custom_binary_label": "Binari personalitzat OpenCode", + "settings.data_dir_unavailable": "Directori de dades no disponible", + "settings.debug_commit": "Commit: {sha}", + "settings.debug_desktop_app": "Aplicació d'escriptori: {version}", + "settings.debug_opencode_version": "OpenCode: {version}", + "settings.debug_openwork_server_version": "Servidor OpenWork: {version}", + "settings.debug_section_title": "Desenvolupador", + "settings.deeplink_failed": "No s'ha pogut obrir el deep link.", + "settings.deeplink_hint": "Accepta openwork://, openwork-dev:// o una URL compatible en brut de https://share.openworklabs.com/b/...", + "settings.default_model": "Model per defecte", + "settings.delete_containers": "S'estan retirant els contenidors...", + "settings.delete_local_config": "S'està eliminant l'estat local...", + "settings.desktop_only_hint": "Disponible a l'app d'escriptori.", + "settings.dev_mode_badge": "Mode de desenvolupament", + "settings.developer": "Desenvolupador", + "settings.developer_mode_desc": "Activa les eines de depuració, els diagnòstics i la pestanya Desenvolupador.", + "settings.developer_mode_title": "Mode de desenvolupador", + "settings.developer_panel_disabled": "Panell de desenvolupador desactivat.", + "settings.developer_panel_enabled": "Panell de desenvolupador activat.", + "settings.devlog_cleared": "S'ha esborrat la sortida del registre del desenvolupador.", + "settings.devlog_clipboard_unavailable": "El porta-retalls no està disponible en aquest entorn.", + "settings.devlog_copied": "S'ha copiat la sortida del registre del desenvolupador.", + "settings.devlog_copy_failed": "No s'ha pogut copiar la sortida del registre del desenvolupador.", + "settings.devlog_export_failed": "No s'ha pogut exportar la sortida del registre del desenvolupador.", + "settings.devlog_export_unavailable": "L'exportació no està disponible en aquest entorn.", + "settings.devlog_exported": "S'ha exportat la sortida del registre del desenvolupador.", + "settings.devtools_desc": "Salut, capacitats i pista d'auditoria del sidecar.", + "settings.devtools_title": "Eines de desenvolupament", + "settings.diag_approval": "Aprovació: {mode} ({ms}ms)", + "settings.diag_config_path": "Ruta de configuració: {path}", + "settings.diag_daemon_url": "Dimoni: {url}", + "settings.diag_default": "per defecte", + "settings.diag_health_port": "Port de salut: {port}", + "settings.diag_healthy_ms": "Saludable: {ms}ms", + "settings.diag_host_token_source": "Origen del token de host: {source}", + "settings.diag_last_attempt": "Últim intent: {time}", + "settings.diag_load_sessions_ms": "Sessions de càrrega: {ms}ms", + "settings.diag_opencode_binary": "OpenCode binari: {binary}", + "settings.diag_opencode_url": "OpenCode: {url}", + "settings.diag_pending_permissions_ms": "Permisos pendents: {ms}ms", + "settings.diag_pid": "PID: {pid}", + "settings.diag_providers_ms": "Proveïdors: {ms}ms", + "settings.diag_read_only": "Només de lectura: {value}", + "settings.diag_reason": "Motiu: {reason}", + "settings.diag_runtime_workspace": "Workspace del runtime: {id}", + "settings.diag_selected_workspace": "Workspace seleccionat: {id}", + "settings.diag_sidecar": "Sidecar: {info}", + "settings.diag_started": "Inici: {time}", + "settings.diag_token_source": "Font del token: {source}", + "settings.diag_total_ms": "Total: {ms}ms", + "settings.diag_version": "Versió: {version}", + "settings.diag_workspaces": "Workspaces: {count}", + "settings.diagnostics_unavailable": "Diagnòstic no disponible.", + "settings.disable_developer_mode": "Desactiva el mode de desenvolupador", + "settings.disabled": "Desactivat", + "settings.disconnect": "Desconnecta", + "settings.disconnect_confirm_suffix": "Desconnectar {resolved}? Això elimina les claus API emmagatzemades o les credencials OAuth per a aquest proveïdor.", + "settings.disconnect_server": "Desconnecta el servidor", + "settings.disconnected_prefix": "{resolved} desconnectat.", + "settings.disconnecting": "S'està desconnectant...", + "settings.docker_containers_desc": "Forçar els contenidors Docker llançats per OpenWork", + "settings.docker_containers_title": "Contenidors Docker d'OpenWork", + "settings.docker_requires_desktop": "La neteja de Docker requereix l'app d'escriptori", + "settings.done": "Fet", + "settings.downloading_bytes": "S'està descarregant {downloaded}", + "settings.downloading_progress": "S'està baixant {downloaded} / {total} ({percent}%)", + "settings.enable_developer_mode": "Activa el mode de desenvolupador", + "settings.enable_exa": "Activa la cerca web Exa", + "settings.enable_exa_desc": "S'aplica quan OpenWork Orchestrator arrenca OpenCode. Per defecte està desactivat", + "settings.enabled": "Habilitat", + "settings.engine_bundled": "En paquet (recomanat)", + "settings.engine_bundled_hint": "El motor en paquet és l'opció més fiable. Fes servir System", + "settings.engine_custom_binary": "Binari personalitzat", + "settings.engine_desc": "Tria com s'executa OpenCode localment.", + "settings.engine_runtime_label": "Runtime del motor", + "settings.engine_source": "Font del motor", + "settings.engine_source_debug": "Font del motor", + "settings.engine_system_path": "Instal·lació del sistema (PATH)", + "settings.engine_title": "Motor", + "settings.environment.add_button": "Add variable", + "settings.environment.add_title": "Add environment variable", + "settings.environment.apply_button": "Apply changes", + "settings.environment.apply_blocked_active_tasks": "Stop running tasks before applying environment changes.", + "settings.environment.apply_confirm_body": "OpenWork will restart local agents so they can use the latest environment. Running local tasks may stop.", + "settings.environment.apply_no_local_workspace": "OpenWork is not connected to a local workspace.", + "settings.environment.apply_pending_body": "Apply changes to restart local agents and make the latest values available.", + "settings.environment.apply_pending_body_manual": "Restart local agents to make the latest values available.", + "settings.environment.apply_pending_title": "Changes are saved, not active yet", + "settings.environment.apply_refresh_failed": "Changes are active, but OpenWork status did not refresh. Reopen the app if it looks stale.", + "settings.environment.apply_success": "Environment changes are active.", + "settings.environment.apply_title": "Apply environment changes?", + "settings.environment.apply_unavailable": "Apply changes is only available in the desktop app.", + "settings.environment.applying": "Applying…", + "settings.environment.cancel": "Cancel", + "settings.environment.click_to_edit": "Click to edit", + "settings.environment.close_editor": "Close editor", + "settings.environment.confirm_delete": "Delete {key}? Agents stop seeing this key after you apply changes.", + "settings.environment.delete": "Delete", + "settings.environment.delete_title": "Delete environment variable", + "settings.environment.delete_variable": "Delete {key}", + "settings.environment.deleting": "Deleting…", + "settings.environment.description": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device; changes become available after you apply them.", + "settings.environment.edit_title": "Edit environment variable", + "settings.environment.empty_body": "Add keys like ANTHROPIC_API_KEY, GOOGLE_API_KEY, ELEVENLABS_API_KEY, or GITHUB_TOKEN for services your agents and MCP servers need.", + "settings.environment.empty_title": "No environment variables yet", + "settings.environment.empty_value": "(empty)", + "settings.environment.footer_hint": "OPENWORK_ and OPENCODE_ keys are reserved for app/runtime wiring. Configure OpenCode runtime settings from your shell.", + "settings.environment.hide": "Hide", + "settings.environment.hide_value": "Hide value for {key}", + "settings.environment.key_hint": "Letters, digits, and underscores. Cannot start with a digit.", + "settings.environment.key_label": "Key", + "settings.environment.loading": "Loading…", + "settings.environment.override_hint": "Environment variables set before OpenWork starts take precedence over values saved here.", + "settings.environment.remote_workspace_hint": "This workspace is remote. Local environment variables are hidden here; use cloud LLM Providers or configure the worker host directly.", + "settings.environment.restart_required": "Saved. Apply changes to make the update available.", + "settings.environment.reveal": "Reveal", + "settings.environment.reveal_value": "Reveal value for {key}", + "settings.environment.save": "Save", + "settings.environment.saving": "Saving…", + "settings.environment.title": "Environment variables", + "settings.environment.validation_duplicate": "A variable with this name already exists.", + "settings.environment.validation_empty": "Name is required.", + "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", + "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", + "settings.environment.value_label": "Value", + "settings.exa_restart_hint": "Reinicia OpenCode o l'orquestrador després de canviar aquesta configuració.", + "settings.export": "Exporta", + "settings.export_failed": "No s'ha pogut exportar l'informe del runtime.", + "settings.export_unavailable": "L'exportació no està disponible en aquest entorn.", + "settings.exported_debug_report": "S'ha exportat l'informe JSON del runtime.", + "settings.failed": "Error", + "settings.failed_open_providers": "No s'han pogut obrir els proveïdors", + "settings.feedback_badge": "Llegim tots els missatges", + "settings.feedback_desc": "Digueu-nos què us sembla bé i què us sembla dur. Els comentaris van directament a l'equip i ens ajuden a prioritzar el que s'enviarà a continuació.", + "settings.feedback_title": "Ajuda a donar forma a OpenWork", + "settings.group_global": "Global", + "settings.group_workspace": "Workspace", + "settings.hide_titlebar": "Amaga la barra de títol", + "settings.hide_titlebar_desc": "Amaga la barra de títol de la finestra. Útil en gestors de finestres en mosaic", + "settings.join_discord": "Uneix-te a Discord", + "settings.language": "Llengua", + "settings.language.description": "Tria l'idioma que prefereixes", + "settings.last_error": "Últim error", + "settings.last_stderr": "Últim stderr", + "settings.last_stdout": "Últim stdout", + "settings.loading_providers": "S'estan carregant els proveïdors...", + "settings.logs_on_host": "Els registres estan disponibles al host.", + "settings.managed_by_env": "Gestionat per l'entorn", + "settings.messaging_bridge_service": "Servei pont de missatgeria.", + "settings.messaging_section_desc": "Gestiona les identitats i els enllaços Telegram/Slack a la pestanya Identitats.", + "settings.messaging_section_title": "Missatgeria", + "settings.model": "Model", + "settings.model_behavior": "Comportament del model", + "settings.model_behavior_desc": "Obre el selector de models predeterminat per triar perfils de raonament quan estiguin disponibles.", + "settings.model_default": "Per defecte", + "settings.model_description": "Valors predeterminats + controls de pensament per a execucions.", + "settings.model_description_default": "Tria entre els proveïdors que tens configurats. Aquesta selecció es farà servir per a les sessions noves.", + "settings.model_description_session": "Tria entre els proveïdors que tens configurats. Aquesta selecció s'aplicarà al missatge següent.", + "settings.model_fallback": "Alternativa", + "settings.model_reasoning": "Raonament", + "settings.model_section_desc": "Tria el model de xat predeterminat i revisa com raona.", + "settings.model_title": "Model", + "settings.no_access": "sense accés", + "settings.no_active_workspace": "No hi ha workspace local actiu.", + "settings.no_audit_entries": "Encara no hi ha entrades d'auditoria.", + "settings.no_binary_selected": "No s'ha seleccionat cap binari.", + "settings.no_custom_path_set": "No s'ha definit cap camí personalitzat", + "settings.no_project_directory": "No hi ha cap directori de projectes", + "settings.no_stderr": "Encara no s'ha capturat cap stderr.", + "settings.no_stdout": "Encara no s'ha capturat cap stdout.", + "settings.no_worker_directory": "No hi ha cap directori de projectes", + "settings.no_worker_path": "No hi ha cap ruta de worker disponible", + "settings.nuke_confirm_dev": "Això és irreversible. S'esborraran totes les dades d'OpenWork d'aquesta build de desenvolupament i també tota la configuració, autenticació, memòria cache, dades i estat aïllats d'OpenCode dins de l'entorn de desenvolupament. Després OpenWork es tancarà. Vols continuar?", + "settings.nuke_confirm_prod": "Això és irreversible. S'esborraran totes les dades d'OpenWork d'aquesta build de desenvolupament i també tota la configuració, autenticació, memòria cache, dades i estat aïllats d'OpenCode dins de l'entorn de desenvolupament. Després OpenWork es tancarà. Vols continuar?", + "settings.nuke_failed": "No s'ha pogut eliminar l'estat OpenWork i OpenCode.", + "settings.nuke_hint": "Fes servir això només si vols reiniciar completament l'app d'escriptori i l'estat del runtime d'OpenCode.", + "settings.nuke_success": "S'han eliminat els estats OpenWork i OpenCode. OpenWork està tancant...", + "settings.off": "Apagat", + "settings.offline": "Fora de línia", + "settings.on": "Encès", + "settings.open_deeplink_action": "S'està obrint...", + "settings.open_deeplink_button": "Amaga", + "settings.open_deeplink_desc": "Enganxa un deep link d'OpenWork o una URL compartida per obrir-lo.", + "settings.open_deeplink_title": "Obre deep link", + "settings.opencode_cache": "memòria cache d'OpenCode", + "settings.opencode_cache_description": "Repara les dades de la memòria cache utilitzades per engegar el motor. Segur de córrer.", + "settings.opencode_engine_desc": "Entorn d'execució local per a agents, eines i proveïdors de models.", + "settings.opencode_engine_label": "Motor OpenCode", + "settings.opencode_engine_sidecar_desc": "Sidecar del runtime local.", + "settings.opencode_sdk_desc": "Diagnòstic de connexió de la UI.", + "settings.opencode_sdk_title": "Motor OpenCode", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "URL base no disponible", + "settings.opening": "Obre deep link", + "settings.openwork_config_sidecar_desc": "Sidecar de configuració i aprovacions.", + "settings.openwork_diagnostics_title": "Diagnòstic del servidor OpenWork", + "settings.openwork_server_desc": "Pla de control de sessió per a la sincronització de l'app, els workers i l'accés remot", + "settings.openwork_server_label": "Servidor OpenWork", + "settings.pending_permissions": "Permisos pendents", + "settings.production_mode_badge": "Producció", + "settings.provider_default_desc": "Fes servir el comportament de raonament predeterminat integrat del model.", + "settings.provider_default_label": "Per defecte del proveïdor", + "settings.provider_source_config": "Config", + "settings.provider_source_custom": "Personalitzat", + "settings.provider_source_env": "Medi ambient", + "settings.providers_desc": "Connecta serveis per als models i les eines.", + "settings.providers_title": "Proveïdors", + "settings.quit_hint": "OpenWork es tanca just després de la neteja perquè la pròxima obertura arrenqui des d'un estat local net per a aquest mode.", + "settings.recent_events": "Esdeveniments recents", + "settings.reconnect_failed": "No s'ha pogut reconnectar. Comprova l'URL i el token del servidor i torna-ho a provar.", + "settings.reconnect_server": "S'està tornant a connectar...", + "settings.reconnect_server_failed": "No s'ha pogut reconnectar al servidor OpenWork.", + "settings.reconnected": "Tornes a estar connectat al servidor OpenWork.", + "settings.reconnecting": "S'està tornant a connectar...", + "settings.removing_containers": "S'estan retirant els contenidors...", + "settings.removing_local_state": "S'està eliminant l'estat local...", + "settings.repair_cache": "Repara la memòria cache", + "settings.repairing_cache": "Reparació de la memòria cache", + "settings.report_issue": "Informa d'un problema", + "settings.reset": "Restableix", + "settings.reset_app_data": "Restableix les dades de l'aplicació", + "settings.reset_app_data_description": "Més agressiu. Esborra la memòria cache OpenWork + les dades de l'aplicació.", + "settings.reset_app_data_title": "Restableix les dades de l'aplicació", + "settings.reset_app_data_warning": "Esborra la memòria cache OpenWork i les dades de l'aplicació en aquest dispositiu.", + "settings.reset_button": "Restableix", + "settings.reset_cancel": "Cancel·la", + "settings.reset_config_defaults": "S'està reiniciant...", + "settings.reset_config_failed": "No s'ha pogut restablir la configuració de l'aplicació.", + "settings.reset_confirm_button": "Restablir i reiniciar", + "settings.reset_confirmation_hint": "Escriu {resetWord} per confirmar-ho. OpenWork es reiniciarà.", + "settings.reset_confirmation_label": "Confirmació", + "settings.reset_confirmation_placeholder": "Escriu RESET", + "settings.reset_onboarding": "Restableix la incorporació", + "settings.reset_onboarding_description": "Esborra les preferències de OpenWork i reinicia l'aplicació.", + "settings.reset_onboarding_title": "Restableix la incorporació", + "settings.reset_onboarding_warning": "Esborra les preferències locals de OpenWork i els marcadors d'incorporació del workspace.", + "settings.reset_openwork_desc_dev": "Amb el mode de desenvolupament actiu, només esborra l'estat de desenvolupament OpenCode aïllat dins de openwork-dev-data.", + "settings.reset_openwork_desc_prod": "Amb el mode de desenvolupament actiu, només esborra l'estat de desenvolupament OpenCode aïllat dins de openwork-dev-data.", + "settings.reset_openwork_title": "Restableix l'estat OpenWork + OpenCode", + "settings.reset_recovery_desc": "Esborra les dades o reinicia el flux de configuració.", + "settings.reset_recovery_title": "Restabliment i recuperació", + "settings.reset_requires_confirm": "Cal escriure RESET i reiniciarà l'aplicació.", + "settings.reset_startup": "Restableix el mode d'inici predeterminat", + "settings.reset_startup_pref": "Restableix la preferència d'inici", + "settings.reset_stop_active_runs": "Atura les execucions actives abans de restablir.", + "settings.resetting": "S'està reiniciant...", + "settings.restart_blocked_message": "OpenWork s'ha de reiniciar per acabar aquesta actualització. Per evitar interrompre la feina que tens en marxa, la instal·lació queda en pausa fins que acabin les execucions actives o les aturis.", + "settings.restart_failed": "El reinici ha fallat. Revisa els registres i torna-ho a provar.", + "settings.restart_opencode": "S'està reiniciant...", + "settings.restart_openwork_server": "S'està reiniciant...", + "settings.restart_server_failed": "No s'ha pogut reiniciar el servidor local.", + "settings.restarted": "S'ha reiniciat el servidor local.", + "settings.restarting": "S'està reiniciant...", + "settings.reveal_config": "Mostra la configuració", + "settings.reveal_config_failed": "No s'ha pogut revelar la configuració del workspace.", + "settings.reveal_config_requires_desktop": "Mostrar la configuració requereix l'app d'escriptori", + "settings.revealed_workspace_config": "S'ha revelat la configuració del workspace.", + "settings.run_sandbox_probe": "Sonda en execució...", + "settings.running_probe": "Sonda en execució...", + "settings.runtime_applies_hint": "S'aplica la propera vegada que el motor arrenqui o es recarregui.", + "settings.runtime_debug_desc": "Instantània de diagnòstic llegible amb exportació amb un sol clic.", + "settings.runtime_debug_title": "Informe de depuració del runtime", + "settings.runtime_desc": "Estat del motor local i del servidor OpenWork.", + "settings.runtime_direct": "Directe (OpenCode)", + "settings.runtime_title": "Runtime", + "settings.sandbox_error": "Error", + "settings.sandbox_export_hint": "Fes servir Exporta a l'informe de depuració del runtime de més amunt per", + "settings.sandbox_probe_desc": "Executa una comprovació temporal d'arrencada d'un sandbox de Docker i", + "settings.sandbox_probe_errors": "Sonda Sandbox completada amb errors.", + "settings.sandbox_probe_failed": "La sonda Sandbox ha fallat.", + "settings.sandbox_probe_success": "La prova de Sandbox ha anat bé. Exporta l'informe de depuració per al suport.", + "settings.sandbox_probe_title": "Sonda Sandbox", + "settings.sandbox_ready": "A punt", + "settings.sandbox_requires_desktop": "La prova de Sandbox requereix l'app d'escriptori", + "settings.sandbox_result": "Resultat: {status}", + "settings.sandbox_run_id": "ID d'execució: {id}", + "settings.sandbox_stop_runs_hint": "Atura les execucions actives abans de llançar la sonda", + "settings.search_models": "Cerca models...", + "settings.select_binary": "Selecciona el binari OpenCode", + "settings.select_workspace_first": "Selecciona un workspace local abans de revelar la configuració.", + "settings.send_feedback": "Envia comentaris", + "settings.service_restarts_desc": "Reinicia serveis concrets del host sense sortir d'aquí.", + "settings.service_restarts_title": "Reinicis de serveis", + "settings.session_model": "Model", + "settings.show_model_reasoning": "Mostra el raonament del model", + "settings.show_model_reasoning_desc": "Desplega les traces de raonament a la UI quan el model les exposi.", + "settings.showing_models": "Mostrant {count} de {total}", + "settings.sidecar_config_unavailable": "La configuració del sidecar no està disponible", + "settings.startup": "Inici", + "settings.startup_local": "Inicia el servidor local", + "settings.startup_not_set": "Connecta't al servidor", + "settings.startup_remote_warning": "Ara mateix la preferència d'inici és remota. La configuració del motor", + "settings.startup_reset_hint": "Això esborra la teva preferència desada i mostra la pantalla de connexió", + "settings.startup_server": "Connecta't al servidor", + "settings.startup_title": "Posada en marxa", + "settings.stop_local_server": "Atura el servidor local", + "settings.stop_runs_before_cleanup": "Atura les execucions actives abans de fer neteja", + "settings.stop_runs_before_reset_config": "Atura les execucions actives abans de restablir la configuració", + "settings.stop_runs_to_reset": "Atura les execucions actives per restablir", + "settings.switch": "Canvia", + "settings.tab_advanced": "Avançat", + "settings.tab_appearance": "Aparença", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "Depuració", + "settings.tab_description_advanced": "Inspecciona l'estat del runtime, l'estat de la connexió i els controls orientats a desenvolupador.", + "settings.tab_description_appearance": "Ajusta l'aspecte d'OpenWork al desktop, al tema del sistema i al marc de l'app.", + "settings.tab_description_debug": "Revisa els diagnòstics, els registres i les utilitats avançades de depuració del runtime.", + "settings.tab_description_den": "Gestiona la connexió a OpenWork Cloud, els workers allotjats i l'accés al workspace.", + "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", + "settings.tab_description_extensions": "Gestiona apps MCP i Plugins d'OpenCode per a aquest workspace.", + "settings.tab_description_general": "Connecta proveïdors, tria el model predeterminat, autoritza carpetes i controla el workspace OpenWork seleccionat juntament amb la seva connexió de runtime.", + "settings.tab_description_messaging": "Configura les identitats del router i el comportament de la safata d'entrada des de la configuració del workspace.", + "settings.tab_description_model": "Ajusta el model predeterminat, el comportament del runtime i la configuració de sortida de l'assistent.", + "settings.tab_description_recovery": "Repara l'estat de migració, restableix els valors predeterminats del workspace i recupera la configuració local.", + "settings.tab_description_skills": "Explora, edita i instal·la Skills sense sortir de la configuració.", + "settings.tab_description_updates": "Mantén l'app actualitzada amb comprovacions silencioses en segon pla i controls d'instal·lació.", + "settings.tab_environment": "Environment", + "settings.tab_extensions": "Extensions", + "settings.tab_general": "Configuració", + "settings.tab_messaging": "Missatgeria", + "settings.tab_model": "Model", + "settings.tab_recovery": "Recuperació", + "settings.tab_skills": "Skills", + "settings.tab_updates": "Actualitzacions", + "settings.theme_dark": "Fosc", + "settings.theme_light": "Clar", + "settings.theme_system": "Sistema", + "settings.theme_system_hint": "El mode Sistema segueix automàticament la preferència del sistema operatiu.", + "settings.toolbar_ready_to_install": "A punt per instal·lar", + "settings.update": "Actualització", + "settings.update_available": "Actualització disponible: v", + "settings.update_available_version": "Actualització disponible: v{version}", + "settings.update_check_button": "Comprova", + "settings.update_check_failed": "La comprovació d'actualització ha fallat", + "settings.update_checking": "S'està comprovant...", + "settings.update_download_button": "Descarregar", + "settings.update_downloading": "S'està baixant...", + "settings.update_error": "La comprovació d'actualització ha fallat", + "settings.update_install_button": "Instal·la i reinicia", + "settings.update_last_checked": "Darrera comprovació {time}", + "settings.update_published": "Publicat {date}", + "settings.update_ready": "A punt per instal·lar: v", + "settings.update_ready_version": "A punt per instal·lar: v{version}", + "settings.update_uptodate": "Al dia", + "settings.updates": "Actualitzacions", + "settings.updates_desc": "Mantén OpenWork al dia.", + "settings.updates_desktop_only": "Les actualitzacions només estan disponibles a l'app d'escriptori.", + "settings.updates_not_supported": "Les actualitzacions no són compatibles amb aquest entorn.", + "settings.updates_title": "Actualitzacions", + "settings.version": "Versió", + "settings.versions_desc": "Informació del sidecar i de la build d'escriptori.", + "settings.versions_title": "Versions", + "settings.window_appearance_desc": "Personalitza l'aspecte de la finestra.", + "settings.worker_id_label": "worker {id}", + "settings.worker_unresolved": "worker {runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "Configuració del workspace", + "settings.workspace_debug_events_label": "Esdeveniments de depuració del workspace", + "settings.workspace_fallback_name": "Workspace", + "share.active_cloud_org": "Organització activa de Cloud", + "share.back_hint": "Torna a les opcions per compartir", + "share.chooser_subtitle": "Tria com vols compartir aquest workspace.", + "share.close_hint": "Tancar", + "share.cloud_signin_note": "OpenWork Cloud s'obre al navegador i torna aquí quan hagis iniciat la sessió.", + "share.collaborator_hint": "Accés rutinari sense aprovacions de permís.", + "share.connect_messaging_desc": "Fes servir aquest workspace de Slack, Telegram i d'altres.", + "share.connect_messaging_title": "Connecta la missatgeria", + "share.connection_details_label": "Detalls de connexió", + "share.copy_hint": "Copia", + "share.copy_link_hint": "Copia l'enllaç", + "share.create_template_link": "Crea un enllaç de plantilla", + "share.credentials_disabled_hint": "Activa l'accés remot i fes clic a Desa per reiniciar el worker i mostrar les dades reals de connexió d'aquest workspace.", + "share.field_password": "Contrasenya", + "share.field_worker_url": "URL del worker", + "share.hide_password": "Amaga la contrasenya", + "share.included_in_template": "Inclòs en aquesta plantilla", + "share.option_access_desc": "Mostra les dades de connexió en directe necessàries per accedir a aquest workspace en execució des d'una altra màquina.", + "share.option_access_title": "Accedeix al workspace remotament", + "share.option_public_desc": "Crea un enllaç públic perquè qualsevol pugui començar a partir d'aquesta plantilla.", + "share.option_public_title": "Plantilla pública", + "share.option_team_title": "Comparteix amb l'equip", + "share.option_template_desc": "Empaqueta aquesta configuració perquè una altra persona pugui començar des del mateix entorn.", + "share.optional_collaborator": "Accés opcional de col·laborador", + "share.public_intro": "Comparteix aquest workspace com un enllaç públic de plantilla.", + "share.publishing": "S'està publicant...", + "share.regenerate_link": "Regenera l'enllaç", + "share.remote_access_desc": "Desactivat per defecte. Activa-ho només quan vulguis que aquest worker sigui accessible des d'una altra màquina.", + "share.remote_access_disabled": "Actualment, l'accés remot està desactivat.", + "share.remote_access_enabled": "L'accés remot està activat actualment.", + "share.remote_access_title": "Accés remot", + "share.remote_save": "Desa", + "share.remote_save_busy": "S'està desant...", + "share_skill_destination.add_to_workspace": "Afegeix-la al workspace", + "share_skill_destination.adding": "Afegint-la", + "share_skill_destination.connect_remote_hint": "Connecta primer un workspace remot", + "share_skill_destination.create_worker_hint": "Crea primer un workspace", + "share_skill_destination.more_options": "Més opcions", + "share.reveal_password": "Revela la contrasenya", + "share.save_to_team": "Desa-ho a l'equip", + "share.saving": "S'està desant...", + "share.setup": "Configuració", + "share.sign_in_to_share": "Inicia la sessió per compartir amb l'equip", + "share.subtitle_access": "Mostra les dades de connexió en directe necessàries per accedir a aquest workspace des d'una altra màquina.", + "share.team_intro": "Desa aquesta plantilla a l'organització activa d'OpenWork Cloud perquè els companys la puguin obrir més tard des de la configuració de Cloud.", + "share.template_intro": "Comparteix una configuració reutilitzable sense concedir accés en directe a aquest workspace en execució.", + "share.template_item_config": "Commands i configuració", + "share.template_item_config_desc": "Commands reutilitzables i configuració d'OpenWork/OpenCode.", + "share.template_item_settings": "Configuració del workspace", + "share.template_item_settings_desc": "El perfil del workspace compartit i el comportament predeterminat.", + "share.template_item_skills": "Skills incloses", + "share.template_item_skills_desc": "Skills personalitzades desades en aquest workspace.", + "share.template_name_label": "Nom de la plantilla", + "share.title": "Comparteix el workspace", + "share.view_access": "Accedeix al workspace remotament", + "share.warning_basic": "Comparteix només amb persones de confiança. Aquestes credencials atorguen accés directe a aquest workspace.", + "share.warning_full": "Aquestes credencials atorguen accés directe a aquest workspace. Compartir aquest workspace de forma remota pot permetre que qualsevol persona amb accés a la vostra xarxa controli el vostre worker.", + "share.workspace_fallback": "Workspace", + "share.workspace_template_desc": "Comparteix la configuració bàsica i els valors predeterminats del workspace.", + "share.workspace_template_title": "Plantilla de workspace", + "share_skill_destination.confirm_busy": "S'està afegint la Skill...", + "share_skill_destination.confirm_button": "Afegeix la Skill al workspace", + "share_skill_destination.connect_remote": "Connecta un workspace remot", + "share_skill_destination.connect_remote_desc": "Connecta un host OpenWork i després tria'l de la llista per importar-hi aquesta Skill.", + "share_skill_destination.create_worker": "Crea un nou workspace", + "share_skill_destination.create_worker_desc": "Obre el flux de configuració del workspace i, a continuació, afegeix aquesta Skill quan el nou workspace estigui llest.", + "share_skill_destination.current_badge": "Actual", + "share_skill_destination.existing_workers": "Workspaces existents", + "share_skill_destination.fallback_skill_name": "Skill compartida", + "share_skill_destination.footer_idle": "Tria un workspace per continuar.", + "share_skill_destination.footer_selected": "Workspace seleccionat:", + "share_skill_destination.local_badge": "Local", + "share_skill_destination.new_destination": "Nova destinació", + "share_skill_destination.no_workers": "Encara no hi ha cap workspace a punt. Crea'n un o connecta un workspace remot per instal·lar-hi aquesta Skill.", + "share_skill_destination.remote_badge": "Remot", + "share_skill_destination.sandbox_badge": "Sandbox", + "share_skill_destination.selected_badge": "Seleccionat", + "share_skill_destination.selected_hint": "Seleccionat. Revisa la destinació aquí sota i confirma.", + "share_skill_destination.skill_label": "Skill compartida", + "share_skill_destination.subtitle": "Tria un workspace existent o crea'n un de nou abans d'importar aquesta Skill compartida.", + "share_skill_destination.title": "On vols posar aquesta Skill?", + "share_skill_destination.trigger_label": "Activador", + "sidebar.active": "Actiu", + "sidebar.add_workspace": "Afegeix un workspace nou", + "sidebar.collapse": "Replega", + "sidebar.connect_remote": "Connecta un worker remot", + "sidebar.delete_session": "Suprimeix la sessió", + "sidebar.drag_reorder": "Arrossega per reordenar", + "sidebar.edit_connection": "Edita la connexió", + "sidebar.expand": "Expandir", + "sidebar.import_config": "Importa la configuració", + "sidebar.needs_attention": "Necessita atenció", + "sidebar.new_worker": "Nou worker", + "sidebar.no_workspaces": "Encara no hi ha workspaces en aquesta sessió. Afegeix-ne un per començar.", + "sidebar.progress": "Progrés", + "sidebar.show_fewer": "Mostra menys", + "sidebar.show_more": "Mostra més {count}", + "sidebar.stop_sandbox": "Atura el sandbox", + "sidebar.switch": "Canvia", + "sidebar.test_connection": "Prova de connexió", + "skills.add_custom_repo": "Afegeix un dipòsit GitHub personalitzat", + "skills.add_git_repo": "Afegeix git repo", + "skills.add_openwork_hub": "Afegeix OpenWork Hub", + "skills.available_from_hub": "Disponible a Hub", + "skills.catalog_search_placeholder": "Cerca Skills instal·lades, d'equip i de concentrador", + "skills.cloud_add_skill": "Afegeix Skill", + "skills.cloud_choose_org_detail": "Fes servir el tauler Cloud per triar la vostra organització activa i, a continuació, actualitza aquesta llista.", + "skills.cloud_choose_org_hint": "Tria una organització a Configuració → Cloud per carregar les Skills de l'equip.", + "skills.cloud_footer_label": "Equip", + "skills.cloud_hub_label": "Hub: {name}", + "skills.cloud_install_need_server": "Connecta't a un servidor OpenWork amb accés d'escriptura d'Skills per instal·lar les Skills d'equip en aquest worker.", + "skills.cloud_installed": "S'ha instal·lat {name} en aquest worker.", + "skills.cloud_installed_as": "S'ha instal·lat com a {name}", + "skills.cloud_installing": "S'està instal·lant {title}...", + "skills.cloud_installing_short": "Instal·lació", + "skills.cloud_no_search_matches": "Cap Skill no coincideix amb aquesta cerca.", + "skills.cloud_org_empty": "Encara no hi ha Skills organitzatives disponibles.", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "No s'han pogut carregar les Skills d'organització.", + "skills.cloud_refresh": "Actualitzar les Skills de l'equip", + "skills.cloud_section_subtitle": "Skills que se t'han compartit a través d'OpenWork Cloud, inclosos els hubs d'Skills de l'equip als quals tens accés.", + "skills.cloud_section_title": "De la vostra organització", + "skills.cloud_shared_org": "Org", + "skills.cloud_shared_private": "Privat", + "skills.cloud_shared_public": "Públic", + "skills.cloud_sign_in": "Inicia la sessió a Cloud", + "skills.cloud_sign_in_hint": "Inicia la sessió a OpenWork Cloud per explorar les Skills de l'equip i de l'organització.", + "skills.cloud_status_installed": "Instal·lat", + "skills.cloud_status_update": "Actualització disponible", + "skills.cloud_update_skill": "Actualització", + "skills.cloud_updated": "S'ha actualitzat {name} en aquest worker.", + "skills.cloud_updating": "S'està actualitzant {title}…", + "skills.cloud_removed": "S'ha eliminat l'Skill del núvol local {name}.", + "skills.copy_link_failed": "No s'ha pogut copiar l'enllaç", + "skills.create_in_chat": "Crea Skills al xat", + "skills.desktop_required": "La gestió d'Skills requereix el app d'escriptori.", + "skills.enter_plugin_name": "Introdueix el nom d'un paquet de Plugin.", + "skills.failed_load_active": "No s'han pogut carregar els Plugins actius.", + "skills.failed_load_opencode": "No s'ha pogut carregar opencode.json", + "skills.failed_parse_opencode": "No s'ha pogut analitzar opencode.json", + "skills.failed_to_load": "No s'han pogut carregar les Skills", + "skills.failed_update_opencode": "No s'ha pogut actualitzar opencode.json", + "skills.filter_all": "Tots", + "skills.filter_cloud": "Equip", + "skills.filter_hub": "Hub", + "skills.filter_installed": "Instal·lat", + "skills.from_repo": "Des de {owner}/{repo}", + "skills.github_repo_hint": "Introdueix un repo GitHub en format owner/repo.", + "skills.host_mode_only": "Només workspace local", + "skills.host_only_error": "La gestió d'Skills requereix un workspace local o servidor OpenWork connectat.", + "skills.hub_desc": "Explora les Skills compartides dels concentradors recolzats per GitHub i afegeix-les a aquest worker.", + "skills.hub_label": "Hub", + "skills.import": "Importar", + "skills.import_failed": "La importació ha fallat ({status})", + "skills.import_local": "Importa les Skills locals", + "skills.import_local_hint": "Copia una carpeta d'Skills existent a aquest workspace.", + "skills.import_local_skill": "Importa les Skills locals", + "skills.imported": "Importat.", + "skills.install": "Instal·lar", + "skills.install_failed": "La instal·lació de l'Skill ha fallat.", + "skills.install_name_title": "Instal·la {name}", + "skills.install_skill_creator": "Instal·la el creador d'Skills", + "skills.install_skill_creator_hint": "Aquesta Skill us permet crear altres Skills des del xat.", + "skills.installed": "Skills instal·lades", + "skills.installed_desc": "Les Skills instal·lades en directe en aquest worker i es poden editar o compartir.", + "skills.installed_label": "Instal·lat", + "skills.installed_status": "Instal·lat", + "skills.installing": "Afegeix Skill", + "skills.installing_prefix": "S'està instal·lant {name}...", + "skills.installing_skill_creator": "S'està instal·lant el creador d'Skills...", + "skills.link_copied": "S'ha copiat l'enllaç", + "skills.loading": "S'està carregant…", + "skills.no_description": "Encara no hi ha descripció.", + "skills.no_hub_repo_label": "No s'ha seleccionat cap dipòsit de concentrador", + "skills.no_hub_repo_selected": "No hi ha Skills de concentració disponibles.", + "skills.no_hub_skills": "No s'ha seleccionat cap dipòsit de concentrador. Afegeix un repo GitHub per navegar per Skills.", + "skills.no_opencode_found": "Encara no s'ha trobat cap opencode.json. Afegeix un Plugin per crear-ne un.", + "skills.no_opencode_workspace": "Encara no hi ha opencode.json en aquest workspace.", + "skills.no_skills": "No s'ha detectat cap Skill a `.opencode/skills`, `.claude/skills` o `~/.agents/skills`.", + "skills.no_skills_found": "Encara no s'han trobat Skills.", + "skills.owner_label": "Propietari", + "skills.owner_repo_required": "El propietari i el dipòsit són necessaris.", + "skills.pick_project_first": "Tria primer una carpeta de projecte.", + "skills.pick_project_for_active": "Tria una carpeta de projecte per carregar Plugins actius.", + "skills.pick_project_for_plugins": "Tria una carpeta de projecte per gestionar els Plugins del projecte.", + "skills.pick_workspace_first": "Tria primer una carpeta de workspace.", + "skills.plugin_already_listed": "El Plugin ja apareix a opencode.json.", + "skills.plugin_management_host_only": "La gestió de Plugins requereix el app d'escriptori.", + "skills.plugins_host_only": "Plugins només estan disponibles a app d'escriptori.", + "skills.ref_label": "Ref (branch/tag/commit)", + "skills.refresh": "Actualitza", + "skills.refresh_hub": "Actualitzar el centre", + "skills.refresh_hub_title": "Actualitzar el catàleg de concentradors", + "skills.remove_saved_repo": "Elimina el repositori desat", + "skills.repo_label": "Repo", + "skills.reveal_failed": "No s'ha pogut obrir la carpeta d'Skills.", + "skills.reveal_folder": "Obre la carpeta d'Skills", + "skills.reveal_folder_hint": "Obre el directori d'Skills a Finder.", + "skills.save_and_load": "Guarda i carrega", + "skills.save_failed": "No s'ha pogut desar l'Skill.", + "skills.select_skill_folder": "Selecciona la carpeta d'Skills", + "skills.share_back": "Enrere", + "skills.share_chooser_subtitle": "Desa a la vostra organització OpenWork Cloud o publiqueu un enllaç d'instal·lació públic.", + "skills.share_close": "Tancar", + "skills.share_copy_link": "Còpia", + "skills.share_done": "Fet", + "skills.share_option_public_desc": "Crea un enllaç que qualsevol pugui utilitzar per instal·lar aquesta Skill.", + "skills.share_option_public_title": "Enllaç públic", + "skills.share_option_team_desc": "Afegeix aquesta Skill a la vostra organització OpenWork Cloud activa.", + "skills.share_option_team_title": "Comparteix amb l'equip", + "skills.share_public_create": "Crea un enllaç", + "skills.share_public_creating": "S'està publicant…", + "skills.share_public_intro": "Publicar un enllaç públic. Qualsevol persona amb l'URL pot instal·lar aquesta Skill.", + "skills.share_public_regenerate": "Regenera l'enllaç", + "skills.share_publisher_label": "Editor", + "skills.share_subtitle_public": "Qualsevol persona amb l'enllaç pot instal·lar aquesta Skill.", + "skills.share_subtitle_team": "Emmagatzemat a la vostra organització per als companys d'equip.", + "skills.share_team_choose_org": "Tria una organització a Configuració → Cloud abans de compartir-la amb el vostre equip.", + "skills.share_team_permissions_intro": "Carrega aquesta Skill a la vostra organització OpenWork Cloud activa i decidiu qui la pot veure.", + "skills.share_team_permissions_label": "Permisos per compartir", + "skills.share_team_permission_org": "Només organització: no al centre", + "skills.share_team_permission_private": "Privat només per a mi", + "skills.share_team_hub_label": "Afegeix al centre d'Skills (opcional)", + "skills.share_team_hub_none": "Només organització, no en un centre", + "skills.share_team_hubs_loading": "S'estan carregant els concentradors…", + "skills.share_team_intro": "Desa aquesta Skill a la teva organització activa perquè els companys d'equip la puguin instal·lar des de Cloud.", + "skills.share_team_org_fallback": "Organització activa de Cloud", + "skills.share_team_save": "Desa a l'equip", + "skills.share_team_saving": "S'està desant…", + "skills.share_team_upload_and_save": "Carrega i desa", + "skills.share_team_uploading": "S'està carregant…", + "skills.share_team_sign_in": "Inicia la sessió per compartir amb l'equip", + "skills.share_team_sign_in_hint": "OpenWork Cloud s'obre al vostre navegador. Torna aquí després d'iniciar la sessió.", + "skills.share_team_success": "Desat a {org}. Els companys d'equip poden instal·lar-lo des de les vostres Skills organitzatives.", + "skills.share_team_uploaded_success": "Penjat a {org}. Les Skills Cloud s'actualitzaran per al vostre compte.", + "skills.share_title": "Comparteix Skill", + "skills.shown_count": "Es mostra {count}", + "skills.skill_creator_already_installed": "El creador d'Skills ja està instal·lat.", + "skills.skill_creator_installed": "Skill creator instal·lat.", + "skills.skill_load_failed": "No s'ha pogut carregar l'Skill.", + "skills.source_label": "Font", + "skills.subtitle": "Gestiona les Skills per a aquest workspace.", + "skills.title": "Skills", + "skills.trigger_label": "Activador: {trigger}", + "skills.uninstall": "Desinstal·la", + "skills.uninstall_failed": "No s'ha pogut desinstal·lar l'Skill.", + "skills.uninstall_title": "Vols desinstal·lar l'Skill?", + "skills.uninstall_warning": "Això suprimirà permanentment l'Skill `{name}` del vostre workspace.", + "skills.uninstalled": "S'ha eliminat l'Skill.", + "skills.unknown_error": "Error desconegut", + "skills.worker_profile_desc": "Skills són les Skills bàsiques d'aquest worker. Descobreix-los des de Hub, gestiona el que està instal·lat i crea-ne de nous directament al xat.", + "status.back": "Torna a la pantalla anterior", + "status.connected": "Connectat", + "status.connecting": "Connectant", + "status.creating_task": "Creació de nova tasca", + "status.creating_workspace": "Creació de workspace", + "status.developer_mode": "Mode de desenvolupador", + "status.disconnected": "Desconnectat", + "status.disconnected_hint": "Obre la configuració per tornar a connectar-te", + "status.disconnected_label": "Desconnectat", + "status.disconnecting": "Desconnectant", + "status.docs": "Docs", + "status.feedback": "Feedback", + "status.idle": "Inactiu", + "status.installing_opencode": "S'està instal·lant OpenCode", + "status.limited_hint": "Torna a connectar-vos per restaurar les funcions completes de OpenWork", + "status.limited_mcp_hint": "{count} MCP connectat · Torna a connectar per obtenir funcions completes", + "status.limited_mode": "Mode limitat", + "status.live": "Viu", + "status.loading_session": "Sessió de càrrega", + "status.mcp_connected": "{count} MCP connectat", + "status.open_docs": "Obre la documentació", + "status.openwork_ready": "OpenWork llest", + "status.providers_connected": "Proveïdor {count}{plural} connectat", + "status.ready_for_tasks": "Preparat per a noves tasques", + "status.reloading_engine": "Recàrrega del motor", + "status.restarting_engine": "Reiniciant el motor", + "status.running": "Córrer", + "status.send_feedback": "Envieu comentaris", + "status.settings": "Configuració", + "status.starting_engine": "Arrancada del motor", + "system.cache_repair_requires_desktop": "La reparació de la memòria cache requereix el app d'escriptori.", + "system.docker_cleanup_requires_desktop": "La neteja de Docker requereix el app d'escriptori.", + "system.reload_body_agents": "OpenCode carrega agents a l'inici. Torna a carregar el motor per posar agents actualitzats disponibles.", + "system.reload_body_commands": "OpenCode carrega ordres a l'inici. Torna a carregar el motor per fer que les ordres actualitzades estiguin disponibles.", + "system.reload_body_config": "OpenCode llegeix opencode.json a l'inici. Torna a carregar el motor per aplicar els canvis de configuració.", + "system.reload_body_default": "OpenWork ha detectat canvis que requereixen tornar a carregar la instància OpenCode.", + "system.reload_body_mcp": "OpenCode carrega els servidors MCP a l'inici. Torna a carregar el motor per activar la nova connexió.", + "system.reload_body_mixed": "OpenWork ha detectat canvis en la configuració de OpenCode. Torna a carregar el motor per aplicar-los.", + "system.reload_body_plugins": "OpenCode carrega Plugins npm a l'inici. Torna a carregar el motor per aplicar els canvis opencode.json.", + "system.reload_body_skills": "OpenCode pot emmagatzemar a la memòria cache el descobriment d'Skills/state. Torna a carregar el motor per posar a disposició les Skills instal·lades recentment.", + "system.reload_failed": "No s'ha pogut tornar a carregar el motor.", + "system.reload_required": "Cal tornar a carregar", + "system.reload_unavailable": "La recàrrega no està disponible per a aquest worker.", + "system.stop_active_runs_before_reset": "Atura les execucions actives abans de restablir.", + "system.stop_runs_before_update": "Atura les execucions actives abans d'instal·lar una actualització.", + "system.updates_not_supported": "Les actualitzacions no són compatibles amb aquest entorn.", + "time.hours_ago": "fa {count}h", + "time.just_now": "just ara", + "time.minutes_ago": "fa {count}m", + "time.seconds_ago": "fa {count}s", + "workspace.loading_tasks": "S'estan carregant tasques...", + "workspace.local_badge": "Local", + "workspace.new_task_inline": "+ Tasca nova", + "workspace.no_tasks": "Encara no hi ha tasques.", + "workspace.remote_badge": "Remot", + "workspace.rename_description": "Actualitza el nom que es mostra a la barra lateral.", + "workspace.rename_label": "Nom del workspace", + "workspace.rename_placeholder": "Disseny del workspace en equip", + "workspace.rename_title": "Edita el nom del workspace", + "workspace.sandbox_badge": "Sandbox", + "workspace.selected": "Seleccionat", + "workspace.switch": "Canvia", + "workspace.switching_status_connecting": "Comprovant la teva connexió", + "workspace.switching_status_loading": "S'estan carregant tasques recents", + "workspace.switching_status_preparing": "Preparant les coses", + "workspace.switching_subtitle": "Tornarem a portar el teu treball recent.", + "workspace.switching_title": "Obertura de {name}", + "workspace.switching_title_unknown": "Obertura del workspace", + "workspace_list.add_workspace": "Afegeix un workspace", + "workspace_list.connect_remote": "Connecta un workspace remot", + "workspace_list.connecting": "S'està connectant...", + "workspace_list.delete_session": "Suprimeix la sessió", + "workspace_list.desktop_only_hint": "Crea workspaces locals des de l'app d'escriptori.", + "workspace_list.edit_connection": "Edita la connexió", + "workspace_list.edit_name": "Edita el nom", + "workspace_list.hide_child_sessions": "Amaga les sessions infantils", + "workspace_list.import_config": "Importa la configuració", + "workspace_list.new_workspace": "Nou workspace", + "workspace_list.recover": "Recupera", + "workspace_list.remove_workspace": "Elimina el workspace", + "workspace_list.rename_session": "Canvia el nom de la sessió", + "workspace_list.reveal_explorer": "Mostra a l'Explorer", + "workspace_list.reveal_finder": "Mostra al Finder", + "workspace_list.session_actions": "Accions de sessió", + "workspace_list.share": "Comparteix...", + "workspace_list.show_child_sessions": "Mostra les sessions infantils", + "workspace_list.show_more": "Mostra'n {count} més", + "workspace_list.show_more_fallback": "Mostra'n més", + "workspace_list.test_connection": "Prova de connexió", + "workspace_list.workspace_fallback": "Workspace", + "workspace_list.workspace_options": "Opcions de workspace", + "workspace_sidebar.close_sidebar": "Tanca la barra lateral", + "workspace_sidebar.collapse_sidebar": "Replega la barra lateral", + "workspace_sidebar.configuration": "configuració", + "workspace_sidebar.expand_sidebar": "Desplega la barra lateral", + "workspace_sidebar.extensions": "Extensions", + "workspace_sidebar.messaging": "Missatgeria", +} as const; diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts new file mode 100644 index 0000000000..166391e77b --- /dev/null +++ b/apps/app/src/i18n/locales/en.ts @@ -0,0 +1,2118 @@ +/** + * English translations + * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) are NOT translated + */ + +export default { + "app.compact_command_desc": "Summarize this session to reduce context size.", + "app.connection_lost": "Server connection lost. Please reload.", + "app.deep_link_auth_queued": "Queued the Cloud auth deep link for OpenWork.", + "app.deep_link_remote_queued": "Queued remote worker link. OpenWork should move into the connect flow.", + "app.error.choose_folder": "Choose a folder to continue.", + "app.error.host_requires_local": "Select a local workspace to start the engine.", + "app.error.install_failed": "OpenCode install failed. See logs above.", + "app.error.pick_workspace_folder": "Pick a workspace folder first.", + "app.error.remote_base_url_required": "Add a server URL to continue.", + "app.error.tauri_required": "This action requires the OpenWork desktop app runtime.", + "app.error_audit_load": "Failed to load audit log.", + "app.error_auth_failed": "Authentication failed", + "app.error_auto_compact_scope": "Auto context compaction can only be changed for a local workspace or a writable OpenWork server workspace.", + "app.error_cloud_signin": "Failed to complete OpenWork Cloud sign-in.", + "app.error_command_not_resolved": "Command was not resolved.", + "app.error_compact_empty": "Nothing to compact yet.", + "app.error_compact_no_session": "Select a session with messages before running /compact.", + "app.error_compact_no_session_id": "Select a session before compacting.", + "app.error_connect_first": "Connect to this worker before applying runtime changes.", + "app.error_connection_failed": "Connection failed", + "app.error_connection_failed_url": "Connection failed. Check the URL and token.", + "app.error_deep_link_unrecognized": "That link is not a recognized OpenWork deep link or share URL.", + "app.error_desktop_signin": "Desktop sign-in completed, but OpenWork Cloud did not return a session token.", + "app.error_not_connected": "Not connected to a server", + "app.error_pick_local_folder": "Pick a local worker folder before restarting the local server.", + "app.error_rate_limit": "Rate limit exceeded", + "app.error_remote_access": "Failed to update remote access.", + "app.error_request_failed": "Request failed", + "app.error_reset_config": "Failed to reset app config defaults.", + "app.error_restart_local_worker": "Failed to restart the local worker with the updated sharing setting.", + "app.error_runtime_changes": "Failed to apply runtime changes.", + "app.error_session_name_required": "Session name is required", + "app.error_update_opencode_json": "Failed to update opencode.json", + "app.import_bundle_desc": "Choose how to import this bundle.", + "app.import_shared_bundle": "Import shared bundle", + "app.local_disabled_reason": "Create local workspaces in the desktop app. Remote and shared workspaces still work here.", + "app.local_worker_detail": "Local worker", + "app.model_behavior_desc": "Choose the model first to see provider-specific behavior controls.", + "app.model_behavior_title": "Model behavior", + "app.plugins_hint_disconnected": "OpenWork server unavailable. Plugins are read-only.", + "app.plugins_hint_limited": "OpenWork server needs a token to edit plugins.", + "app.plugins_hint_readonly": "OpenWork server is read-only for plugins.", + "app.reload_later": "Later", + "app.reload_now": "Reload now", + "app.reload_stop_tasks": "Reload & Stop Tasks", + "app.remote_worker_detail": "Remote worker", + "app.reset_config_ok": "Reset app config defaults. Restart OpenWork if any stale settings remain.", + "app.shared_setup": "Shared setup", + "app.skill_added": "Skill added", + "app.skills_hint_disconnected": "OpenWork server unavailable. Add the server URL/token in Advanced to manage skills.", + "app.skills_hint_limited": "OpenWork server needs a host token to install/update skills. Add it in Advanced and reconnect.", + "app.skills_hint_readonly": "OpenWork server is read-only for skills. Add a host token in Advanced to enable installs.", + "action.remove": "Remove", + "app.unknown_error": "Unknown error", + "app.worker_fallback": "Worker", + "blueprint.automation_body": "Start from a reusable workflow or type your own task below.", + "blueprint.automation_title": "What do you want to automate?", + "blueprint.csv_session_assistant": "I can help you generate, clean, merge, and summarize CSV files. What kind of CSV work do you want to automate?", + "blueprint.csv_session_title": "CSV workflow ideas", + "blueprint.csv_session_user": "I want to combine exports from multiple tools into one clean CSV.", + "blueprint.empty_body": "Pick a starting point or just type below.", + "blueprint.empty_title": "What do you want to do?", + "blueprint.minimal_body": "Ask a question about this workspace or use a starter prompt.", + "blueprint.minimal_title": "Start with a task", + "blueprint.starter_blueprint_desc": "Design a repeatable workflow with skills, commands, and handoff steps.", + "blueprint.starter_blueprint_prompt": "Help me design a reusable automation blueprint for this workspace. Ask what should be standardized, then propose the workflow.", + "blueprint.starter_blueprint_title": "Plan an automation blueprint", + "blueprint.starter_chrome_desc": "Start a browser automation conversation right away.", + "blueprint.starter_chrome_prompt": "Help me connect to Chrome and automate a repetitive task.", + "blueprint.starter_chrome_title": "Automate Chrome", + "blueprint.starter_command_desc": "Turn a repeated workflow into a slash command for this workspace.", + "blueprint.starter_command_prompt": "Help me create a reusable /command for this workspace. Ask what workflow I want to automate, then draft the command.", + "blueprint.starter_command_title": "Create a reusable command", + "blueprint.starter_connect_openai_desc": "Add your OpenAI provider so ChatGPT models are ready in new sessions.", + "blueprint.starter_connect_openai_title": "Connect ChatGPT", + "blueprint.starter_csv_desc": "Clean up or generate spreadsheet data.", + "blueprint.starter_csv_prompt": "Help me create or edit CSV files on this computer.", + "blueprint.starter_csv_title": "Work on a CSV", + "blueprint.starter_explore_desc": "Summarize the files and suggest the best first task to tackle.", + "blueprint.starter_explore_prompt": "Summarize this workspace, point out the most important files, and suggest the best first task.", + "blueprint.starter_explore_title": "Explore this workspace", + "blueprint.welcome_message": "Hi welcome to OpenWork!\n\nPeople use us to write .csv files on their computer, connect to Chrome and automate repetitive tasks, and sync contacts to Notion.\n\nBut the only limit is your imagination.\n\nWhat would you want to do?", + "blueprint.welcome_title": "Welcome to OpenWork", + "common.add": "Add", + "common.cancel": "Cancel", + "common.choose": "Choose", + "common.back": "Back", + "common.close": "Close", + "common.default_parens": "(default)", + "common.done": "Done", + "common.edit": "Edit", + "common.hide": "Hide", + "common.install": "Install", + "common.navigate": "navigate", + "common.next": "Next", + "common.off": "Off", + "common.on": "On", + "common.path": "Path", + "common.question": "Question", + "common.refresh": "Refresh", + "common.remove": "Remove", + "common.reset": "Reset", + "common.retry": "Retry", + "common.save": "Save", + "common.select": "select", + "common.show": "Show", + "common.something_went_wrong": "Something went wrong", + "common.submit": "Submit", + "common.unknown": "Unknown", + "composer.agent_label": "Agent", + "composer.attach_files": "Attach files", + "composer.attachments_unavailable": "Attachments are unavailable.", + "composer.behavior_label": "Behavior", + "composer.configure": "Configure", + "composer.default_agent": "Default agent", + "composer.expand_pasted": "Click to expand pasted text", + "composer.failed_read_attachment": "Failed to read attachment", + "composer.file_exceeds_limit": "{name} exceeds the 8MB limit.", + "composer.file_kind": "File", + "composer.file_too_large_encoding": "{name} is too large after encoding. Try a smaller image.", + "composer.image_kind": "Image", + "composer.inserted_links_unsupported": "Inserted links for unsupported files.", + "composer.loading_agents": "Loading agents...", + "composer.loading_commands": "Loading commands...", + "composer.mcps_label": "MCPs", + "composer.no_commands": "No commands found.", + "composer.no_matches": "No matches found.", + "composer.placeholder": "Describe your task...", + "composer.remote_worker_paste_warning": "This is a remote worker. Sandboxes are remote too. To share files with it, upload them to the Shared folder in the sidebar.", + "composer.run_task": "Run task", + "composer.skill_source": "Skill", + "composer.stop": "Stop", + "composer.tools_label": "Commands, skills, and MCPs", + "composer.unsupported_attachment_type": "Unsupported attachment type.", + "composer.upload_failed_local_links": "Couldn't upload to the shared folder. Inserted local links instead.", + "composer.upload_to_shared_folder": "Upload to shared folder", + "composer.uploaded_multiple_files": "Uploaded {count} files to the shared folder and inserted links.", + "composer.uploaded_single_file": "Uploaded {name} to the shared folder and inserted a link.", + "config.auto_reload_desc": "Reload automatically after agents/skills/commands/config change (only when idle).", + "config.auto_reload_title": "Auto reload (local)", + "config.auto_reload_unavailable": "Available for local workspaces in the desktop app.", + "config.collaborator_token_disabled_hint": "Stored in advance for remote sharing, but remote access is currently disabled.", + "config.collaborator_token_label": "Collaborator token", + "config.collaborator_token_remote_hint": "Routine remote access for phones or laptops connecting to this server.", + "config.connection_failed": "Connection failed.", + "config.connection_failed_check": "Connection failed. Check the host URL and token.", + "config.connection_status_updated": "Connection status updated.", + "config.connection_successful": "Connection successful.", + "config.copied": "Copied", + "config.copy": "Copy", + "config.desktop_only_hint": "Some config features (local server sharing + messaging bridge) require the desktop app.", + "config.diagnostics_desc": "Copy sanitized runtime state for debugging.", + "config.diagnostics_title": "Diagnostics bundle", + "config.enable_auto_reload_first": "Enable auto reload first", + "config.engine_reload_desc": "Restart the OpenCode server for this workspace.", + "config.engine_reload_title": "Engine reload", + "config.host_admin_token_hint": "Internal host-only token for approvals CLI and admin APIs. Do not use this in the remote app connect flow.", + "config.host_admin_token_label": "Host admin token", + "config.host_local_only": "Local only", + "config.host_offline": "Offline", + "config.host_remote_enabled": "Remote enabled", + "config.local_ip_hint": "Use your local IP on the same Wi-Fi for the fastest connection.", + "config.mdns_hint": ".local names are easier to remember but may not resolve on all networks.", + "config.messaging_identities_desc": "Manage Telegram/Slack identities and routing in the Identities tab.", + "config.messaging_identities_title": "Messaging identities", + "config.not_set": "Not set", + "config.owner_token_disabled_hint": "Only relevant after you enable remote access for this worker.", + "config.owner_token_label": "Owner token", + "config.owner_token_remote_hint": "Use this when a remote client needs to answer permission prompts or take owner-only actions.", + "config.reload_active_tasks_warning": "Reloading will stop active tasks.", + "config.reload_availability_hint": "Reloading is only available for local workers or connected OpenWork servers.", + "config.reload_connect_hint": "Connect to this worker to reload.", + "config.reload_engine": "Reload engine", + "config.reload_now_desc": "Applies config updates and reconnects your session.", + "config.reload_now_title": "Reload now", + "config.reloading": "Reloading...", + "config.remote_access_off_hint": "Remote access is off. Use Share workspace to enable it before connecting from another machine.", + "config.resolved_worker_url": "Resolved worker URL:", + "config.resume_sessions_desc": "If a reload was queued while tasks were running, send a resume message afterward.", + "config.resume_sessions_title": "Resume sessions after auto reload", + "config.server_needed_hint": "OpenWork server connection needed to sync skills, plugins, and commands.", + "config.server_section_desc": "Connect to an OpenWork server. Use the URL plus a collaborator or owner token from your server admin.", + "config.server_section_title": "OpenWork server", + "config.server_sharing_desc": "Share these details with a trusted device. Keep the server on the same network for the fastest setup.", + "config.server_sharing_menu_hint": "For per-workspace sharing links, use Share... in the workspace menu.", + "config.server_sharing_title": "OpenWork server sharing", + "config.server_url_hint": "Use the URL shared by your OpenWork server. Local desktop workers reuse a persistent high port in the 48000-51000 range.", + "config.server_url_input_label": "OpenWork server URL", + "config.server_url_label": "OpenWork Server URL", + "config.starting_server": "Starting server…", + "config.status_connected": "Connected", + "config.status_limited": "Limited", + "config.status_not_connected": "Not connected", + "config.test_connection": "Test connection", + "config.testing": "Testing...", + "config.testing_connection": "Testing connection...", + "config.token_hint": "Optional. Paste a collaborator token for routine access or an owner token when this client must answer permission prompts.", + "config.token_label": "Collaborator or owner token", + "config.token_placeholder": "Paste your token", + "config.unavailable": "Unavailable", + "config.worker_id": "Worker ID:", + "config.workspace_config_desc": "These settings affect the selected workspace. Runtime-only actions apply to whichever workspace is currently connected.", + "config.workspace_config_title": "Workspace config", + "config.workspace_id_prefix": "Workspace:", + "context_panel.add_button": "Add", + "context_panel.add_folder_hint": "Add a folder to let this workspace read and edit files outside its root directory.", + "context_panel.adding_button": "Adding...", + "context_panel.always_available": "Always available", + "context_panel.authorized_folders": "Authorized folders", + "context_panel.authorized_folders_desc": "Grant this workspace access to read and edit files in directories outside of its root.", + "context_panel.authorized_folders_no_access": "Connect to a writable OpenWork server workspace to edit authorized folders.", + "context_panel.browse_button": "Browse", + "context_panel.config_access_unavailable": "OpenWork server config access is unavailable for this workspace.", + "context_panel.config_read_only": "OpenWork server is connected read-only for workspace config.", + "context_panel.context": "Context", + "context_panel.folder_already_authorized": "Folder is already authorized.", + "context_panel.folders_updated": "Authorized folders updated.", + "context_panel.input_placeholder": "Type a folder path to authorize...", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "Connected", + "context_panel.mcp_disabled": "Disabled", + "context_panel.mcp_disconnected": "Disconnected", + "context_panel.mcp_failed": "Failed", + "context_panel.mcp_needs_auth": "Needs auth", + "context_panel.mcp_register_client": "Register client", + "context_panel.no_external_folders": "No external folders authorized", + "context_panel.no_mcp": "No MCP servers loaded.", + "context_panel.no_plugins": "No plugins loaded.", + "context_panel.no_server_workspace": "No active server workspace is selected.", + "context_panel.no_skills": "No skills loaded.", + "context_panel.none_yet": "None yet.", + "context_panel.plugins": "Plugins", + "context_panel.preserving_entries": "Preserving {count} non-folder permission entries.", + "context_panel.preserving_entry": "Preserving 1 non-folder permission entry.", + "context_panel.remove_folder": "Remove {name}", + "context_panel.saving_folders": "Saving authorized folders...", + "context_panel.server_disconnected": "OpenWork server is disconnected.", + "context_panel.skills": "Skills", + "context_panel.working_files": "Working files", + "context_panel.workspace_root_available": "Workspace root is already available.", + "context_panel.workspace_root_badge": "Workspace root", + "context_panel.writable_workspace_required": "A writable OpenWork server workspace is required to update authorized folders.", + "dashboard.access_token": "Access token", + "dashboard.access_token_optional_hint": "Add a token only if the worker requires one.", + "dashboard.blueprints_workspace": "Blueprints", + "dashboard.blueprints_workspace_desc": "Start with an automation-ready workspace for reusable skills, commands, and shared flows.", + "dashboard.change": "Change", + "dashboard.choose_folder": "Choose a folder", + "dashboard.choose_folder_continue": "Choose a folder to continue.", + "dashboard.choose_folder_next": "Share files with your workspace.", + "dashboard.choose_preset": "Choose Preset", + "dashboard.chooser_local_desc": "Create a workspace on this device.", + "dashboard.chooser_remote_desc": "Attach to a self-hosted OpenWork worker using a URL and access token.", + "dashboard.chooser_shared_desc": "Browse cloud workers shared with your organization and connect in one step.", + "dashboard.close_settings": "Close settings", + "dashboard.cloud_signin_button": "Continue with Cloud", + "dashboard.cloud_signin_hint": "Access remote workers shared with your organization.", + "dashboard.cloud_signin_next": "You'll pick a team and connect to an existing workspace next.", + "dashboard.cloud_signin_title": "Sign in to OpenWork Cloud", + "dashboard.cloud_worker": "Cloud worker", + "dashboard.commands": "Commands", + "dashboard.connect_remote_button": "Connect remote", + "dashboard.connected": "Connected", + "dashboard.connecting": "Connecting...", + "dashboard.create_local_workspace_subtitle": "Create a workspace on this device.", + "dashboard.create_local_workspace_title": "Local workspace", + "dashboard.create_remote_custom_subtitle": "Attach to a self-hosted OpenWork worker.", + "dashboard.create_remote_custom_title": "Connect custom remote", + "dashboard.create_remote_workspace_confirm": "Add Workspace", + "dashboard.create_remote_workspace_subtitle": "Save an OpenWork server as a workspace.", + "dashboard.create_remote_workspace_title": "Add Remote Workspace", + "dashboard.create_sandbox_confirm": "Create as sandbox", + "dashboard.create_shared_subtitle_signed_in": "Browse cloud workers shared with your organization and connect in one step.", + "dashboard.create_shared_subtitle_signed_out": "Sign in to OpenWork Cloud to access workers shared with your organization.", + "dashboard.create_shared_title": "Shared workspaces", + "dashboard.create_workspace_confirm": "Create Workspace", + "dashboard.create_workspace_subtitle": "Initialize a new folder-based workspace.", + "dashboard.create_workspace_title": "Create Workspace", + "dashboard.creating": "Creating...", + "dashboard.desktop_badge": "Desktop", + "dashboard.display_name_label": "Display name", + "dashboard.display_name_optional": "(optional)", + "dashboard.docker_debug_details": "Docker debug details", + "dashboard.edit_remote_workspace_confirm": "Save connection", + "dashboard.edit_remote_workspace_subtitle": "Update the OpenWork server details for this workspace.", + "dashboard.edit_remote_workspace_title": "Edit Remote Connection", + "dashboard.empty_workspace": "Empty workspace", + "dashboard.empty_workspace_desc": "Start with a blank folder and add what you need.", + "dashboard.error_choose_org": "Choose an organization before opening a workspace.", + "dashboard.error_connect_worker": "Failed to connect to {name}.", + "dashboard.error_create_template": "Failed to create {name}.", + "dashboard.error_load_orgs": "Failed to load organizations.", + "dashboard.error_load_shared_workspaces": "Failed to load shared workspaces.", + "dashboard.error_workspace_not_ready": "Workspace is not ready to connect yet. Try again in a moment.", + "dashboard.import_config": "Import config", + "dashboard.importing": "Importing…", + "dashboard.modal_back": "Back", + "dashboard.modal_close": "Close add workspace modal", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "No folder selected yet.", + "dashboard.open_cloud_dashboard": "Open cloud dashboard", + "dashboard.opening": "Opening...", + "dashboard.openwork_host_hint": "Use the URL shared by your OpenWork server.", + "dashboard.openwork_host_label": "OpenWork server URL", + "dashboard.openwork_host_placeholder": "https://your-server.openwork.app", + "dashboard.openwork_host_token_hint": "Optional. Paste a collaborator token for routine access or an owner token when this client must answer permission prompts.", + "dashboard.openwork_host_token_label": "Collaborator or owner token", + "dashboard.openwork_host_token_placeholder": "Paste your token", + "dashboard.recently_updated": "Recently updated", + "dashboard.remote": "Remote", + "dashboard.remote_base_url_required": "Add a server URL to continue.", + "dashboard.remote_connection_direct": "Direct", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "Leave blank to use the server default.", + "dashboard.remote_directory_label": "Workspace directory (optional)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "Display name (optional)", + "dashboard.remote_display_name_placeholder": "Design team workspace", + "dashboard.remote_server_details_hint": "Attach to a self-hosted OpenWork worker.", + "dashboard.remote_server_details_title": "Remote server details", + "dashboard.remote_workspace_hint": "Track an OpenWork server and reconnect anytime.", + "dashboard.remote_workspace_title": "Remote workspace", + "dashboard.repair_cache": "Repair cache", + "dashboard.repairing_cache": "Repairing cache", + "dashboard.sandbox_checking_docker": "Checking Docker...", + "dashboard.sandbox_get_ready_action": "Get your system ready", + "dashboard.sandbox_get_ready_desc": "Run this workspace in an isolated Docker container for safer, more reproducible runs.", + "dashboard.sandbox_get_ready_title": "Sandboxes need Docker", + "dashboard.sandbox_hide_logs": "Hide logs", + "dashboard.sandbox_live_logs": "Live Logs", + "dashboard.sandbox_setup": "Sandbox setup", + "dashboard.sandbox_show_logs": "Show logs", + "dashboard.search_shared_workspaces": "Search shared workspaces", + "dashboard.select_folder": "Select Folder", + "dashboard.settings": "Settings", + "dashboard.shared_workspaces_loading": "Loading shared workspaces…", + "dashboard.shared_workspaces_no_match": "No shared workspaces match that search.", + "dashboard.shared_workspaces_none": "No shared workspaces available yet.", + "dashboard.shared_workspaces_refreshing": "Refreshing workspaces…", + "dashboard.skills": "Skills", + "dashboard.starter_workspace": "Starter workspace", + "dashboard.starter_workspace_desc": "Preconfigured to show you how to use plugins, commands, and skills.", + "dashboard.unknown_creator": "Unknown creator", + "dashboard.worker_status_attention": "Attention", + "dashboard.worker_status_ready": "Ready", + "dashboard.worker_status_starting": "Starting", + "dashboard.worker_status_stopped": "Stopped", + "dashboard.worker_status_unknown": "Unknown", + "dashboard.worker_url_hint": "Paste the URL for the OpenWork worker you want to connect to.", + "dashboard.worker_url_label": "Worker URL", + "dashboard.workspace_connect": "Connect", + "dashboard.workspace_connect_unavailable": "Connecting shared workspaces is unavailable here.", + "dashboard.workspace_connecting": "Connecting", + "dashboard.workspace_folder_hint": "Choose where this workspace should live on your device.", + "dashboard.workspace_folder_title": "Workspace folder", + "dashboard.workspace_not_ready_title": "This workspace is not ready to connect yet.", + "dashboard.workspaces": "Workspaces", + "den.active_org_hint": "Cloud workers are scoped to the selected org.", + "den.active_org_title": "Active org", + "den.auto_reconnect_hint": "Finish auth in your browser and OpenWork will reconnect here automatically.", + "den.checking_session": "Checking session", + "den.choose_org_for_providers": "Choose an org to view cloud providers.", + "den.choose_org_for_skills": "Choose an org to view cloud skills.", + "den.choose_org_for_skill_hubs": "Choose an org to view cloud skill hubs.", + "den.cloud_account_hint": "Manage your connected account and organization.", + "den.cloud_account_title": "Cloud account", + "den.cloud_control_plane_open": "Open in browser", + "den.cloud_control_plane_reset": "Reset", + "den.cloud_control_plane_save": "Save URL", + "den.cloud_control_plane_url_hint": "Developer mode only. Use this to target a local or self-hosted Cloud control plane. Changing it signs you out so the app can re-hydrate against the new control plane.", + "den.cloud_control_plane_url_label": "Cloud control plane URL", + "den.cloud_provider_detail": "{count} models · {source} provider", + "den.cloud_provider_removed_detail": "This imported provider is no longer in cloud. Uninstall the local {providerId} config.", + "den.cloud_provider_sync_detail": "Cloud provider changed. Sync the {count} model {source} config into opencode.jsonc.", + "den.cloud_skill_detail": "Install this cloud skill into .opencode/skills.", + "den.cloud_skill_imported_detail": "Installed locally as {name}.", + "den.cloud_skill_removed_detail": "This cloud skill was removed upstream. Uninstall the local {name} copy.", + "den.cloud_skill_sync_detail": "A newer cloud version is available for {name}. Update the local copy to stay in sync.", + "den.cloud_skills_hint": "Browse individual cloud skills you can access, install them locally, and update them when the remote version changes.", + "den.cloud_skills_title": "Skills", + "den.cloud_providers_hint": "Import managed LLM providers into opencode.jsonc and use the org credential in this workspace.", + "den.cloud_providers_title": "Cloud providers", + "den.cloud_section_desc": "Sign in, pick an org, and open Cloud workers.", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "Sign in to OpenWork Cloud to keep your tasks alive even when your computer sleeps.", + "den.cloud_workers_hint": "Open workers directly into OpenWork using the same remote-connect flow the app already uses elsewhere.", + "den.cloud_workers_title": "Cloud workers", + "den.create_account": "Create account", + "den.credentials_ready_badge": "Credential ready", + "den.error_base_url": "Enter a valid http:// or https:// Cloud control plane URL.", + "den.error_choose_org": "Choose an org before opening a worker.", + "den.error_load_orgs": "Failed to load orgs.", + "den.error_load_skills": "Failed to load cloud skills.", + "den.error_load_workers": "Failed to load workers.", + "den.error_no_session": "No active Cloud session found.", + "den.error_no_token": "Desktop sign-in completed, but OpenWork Cloud did not return a session token.", + "den.error_open_worker": "Failed to open {name} in OpenWork.", + "den.error_open_worker_fallback": "Failed to open {name}.", + "den.error_paste_valid_code": "Paste a valid OpenWork sign-in link or one-time sign-in code.", + "den.error_signin_failed": "Failed to complete OpenWork Cloud sign-in.", + "den.error_worker_not_ready": "Worker is not ready to open yet. Try again after provisioning finishes.", + "den.finish_signin": "Finish sign-in", + "den.finishing": "Finishing...", + "den.hide_signin_code": "Hide sign-in code", + "den.import_all": "Import all", + "den.import_skill": "Install", + "den.import_skill_failed": "Failed to install {name}.", + "den.import_provider": "Import", + "den.import_provider_failed": "Failed to import {name}.", + "den.imported_badge": "Imported", + "den.imported_provider": "Imported {name}.", + "den.importing": "Importing...", + "den.needs_attention": "Needs attention", + "den.no_cloud_providers": "No cloud providers are available for this org yet.", + "den.no_cloud_skills": "No cloud skills are available for this org yet.", + "den.no_cloud_workers": "No cloud workers are visible for this org yet. Create one in Cloud, then refresh this tab.", + "den.no_org_selected": "No org selected", + "den.no_skill_hubs": "No cloud skill hubs are available for this org yet.", + "den.open": "Open", + "den.opening": "Opening...", + "den.org_member_suffix": "(Member)", + "den.org_owner_suffix": "(Owner)", + "den.org_switched": "Switched to {name}.", + "den.out_of_sync_badge": "Out of sync", + "den.private_badge": "Private", + "den.paste_signin_code": "Paste sign-in code", + "den.refresh": "Refresh", + "den.reload_workspace": "Reload workspace to apply config changes.", + "den.remove_provider_failed": "Failed to remove {name}.", + "den.remove_skill_failed": "Failed to uninstall {name}.", + "den.removed_from_cloud_badge": "Removed from cloud", + "den.removed_provider": "Removed {name}.", + "den.removing": "Removing...", + "den.sign_out": "Sign out", + "den.signed_out": "Signed out", + "den.signin_button": "Sign in", + "den.signin_code_note": "Accepts an openwork://den-auth link or the raw one-time grant.", + "den.signin_link_hint": "If your browser doesn't bounce back into OpenWork automatically, paste the sign-in link or one-time code from OpenWork Cloud here.", + "den.signin_link_label": "Sign-in link or one-time code", + "den.signin_link_placeholder": "openwork://den-auth?... or pasted code", + "den.signin_title": "Sign in to OpenWork Cloud", + "den.signing_in": "Finishing OpenWork Cloud sign-in...", + "den.signing_out": "Signing out...", + "den.skill_hub_detail": "Import {count} shared skills into .opencode/skills.", + "den.skill_hub_imported_detail": "Imported {count} skills into this workspace.", + "den.skill_hub_removed_detail": "This hub was removed from cloud. Uninstall the {importedCount} imported skills from this workspace.", + "den.skill_hub_skills_badge": "{count} skills", + "den.skill_hub_sync_detail": "Cloud now has {liveCount} skills; this workspace imported {importedCount}. Sync to update the installed set.", + "den.skill_hubs_hint": "Import every skill from a shared cloud hub into this workspace in one step.", + "den.skill_hubs_title": "Skill hubs", + "den.status_base_url_updated": "Updated the Cloud control plane URL. Sign in again to continue.", + "den.status_browser_signin": "Finish signing in in your browser to connect OpenWork.", + "den.status_browser_signup": "Finish account creation in your browser to connect OpenWork.", + "den.status_cloud_signed_in_as": "Connected OpenWork Cloud as {email}.", + "den.status_cloud_signin_done": "Connected OpenWork Cloud.", + "den.status_loaded_orgs": "Loaded {count} org{plural}.", + "den.status_loaded_skills": "Loaded {count} cloud skill{plural} for {name}.", + "den.status_loaded_workers": "Loaded {count} worker{plural} for {name}.", + "den.status_no_skills": "No cloud skills found for {name}.", + "den.status_no_workers": "No workers found for {name}.", + "den.status_opened_worker": "Opened {name} in OpenWork.", + "den.status_signed_in_as": "Signed in as {email}.", + "den.status_signed_out": "Signed out and cleared your OpenWork Cloud session on this device.", + "den.sync": "Sync", + "den.sync_provider_failed": "Failed to sync {name}.", + "den.sync_skill_failed": "Failed to update {name}.", + "den.synced_provider": "Synced {name}.", + "den.syncing": "Syncing...", + "den.installed_name_badge": "Local: {name}", + "den.uninstall": "Uninstall", + "den.worker_mine_badge": "Mine", + "den.worker_not_ready_title": "This worker is not ready to open yet.", + "den.worker_provider_label": "{provider} worker", + "den.worker_secondary_cloud": "Cloud worker", + "extensions.app_count_one": "{count} app connected", + "extensions.app_count_many": "{count} apps connected", + "extensions.apps_mcp_header": "Apps (MCP)", + "extensions.filter_all": "All", + "extensions.filter_apps": "Apps", + "extensions.filter_plugins": "Plugins", + "extensions.plugin_count_one": "{count} plugin", + "extensions.plugin_count_many": "{count} plugins", + "extensions.plugins_opencode_header": "Plugins (OpenCode)", + "extensions.subtitle": "Apps (MCP) and OpenCode plugins live in one place.", + "extensions.title": "Extensions", + "identities.agent_behavior_desc": "One file per workspace. Add optional first line @agent to route via a specific OpenCode agent.", + "identities.agent_behavior_title": "Messaging agent behavior", + "identities.agent_created": "Created default messaging agent file.", + "identities.agent_file_changed": "File changed remotely. Reload and save again.", + "identities.agent_loading": "Loading agent file…", + "identities.agent_none": "none", + "identities.agent_not_found": "Agent file not found in this workspace yet.", + "identities.agent_saved": "Saved messaging behavior.", + "identities.agent_scope_status": "Active scope: workspace · status: {status} · selected agent: {agent}", + "identities.agent_status_loaded": "loaded", + "identities.agent_status_missing": "missing", + "identities.agent_worker_scope_unavailable": "Worker scope unavailable.", + "identities.all_channels": "All channels", + "identities.app_token_label": "App token", + "identities.auto_bind_label": "Auto-bind peer to directory on direct send", + "identities.available_channels": "Available channels", + "identities.bot_token_label": "Bot token", + "identities.bot_token_placeholder": "Paste Telegram bot token from @BotFather", + "identities.botfather_step1_open": "1. Open @BotFather in Telegram", + "identities.botfather_step1_run": "and run /newbot", + "identities.botfather_step3_choose": "3. Choose a name and username for your bot", + "identities.botfather_step3_or_private": "for open inbox or", + "identities.botfather_step3_private": "Private", + "identities.botfather_step3_public": "Public", + "identities.botfather_step3_to_require": "to require", + "identities.channel_label": "Channel", + "identities.channels_connected": "connected", + "identities.channels_label": "Channels", + "identities.configured_suffix": "configured", + "identities.connect_server_desc": "Identities are available when you are connected to an OpenWork host.", + "identities.connect_server_title": "Connect to an OpenWork server", + "identities.connect_slack": "Connect Slack", + "identities.connected_badge": "Connected", + "identities.connecting": "Connecting...", + "identities.copy_bot_token_hint": "Copy the bot token and paste it below.", + "identities.copy_code": "Copy code", + "identities.create_default_file": "Create default file", + "identities.create_private_bot": "Create private bot", + "identities.create_public_bot": "Create public bot", + "identities.days_ago": "{days}d ago", + "identities.default_routing": "Default routing", + "identities.directory_label": "Directory (optional)", + "identities.disable_messaging": "Disable messaging", + "identities.disable_messaging_message": "This will turn off messaging for this workspace. Telegram and Slack setup will be hidden until messaging is enabled again, and you will need to restart the worker to fully stop the messaging sidecar.", + "identities.disable_messaging_title": "Disable messaging for this worker?", + "identities.disabled_label": "Disabled", + "identities.disabling": "Disabling...", + "identities.disconnect": "Disconnect", + "identities.dispatched_messages": "Dispatched {sent}/{attempted} messages.", + "identities.enable_messaging": "Enable messaging", + "identities.enable_messaging_risk": "Messaging can expose this worker to remote commands. If a bot is public or compromised, it can access files, credentials, and API keys available to this worker.", + "identities.enable_messaging_title": "Enable messaging for this worker?", + "identities.enabled_label": "Enabled", + "identities.enabling": "Enabling...", + "identities.health_offline": "Offline", + "identities.health_running": "Running", + "identities.health_unavailable": "Unavailable", + "identities.health_unknown": "Unknown", + "identities.hours_ago": "{hours}h ago", + "identities.identities_label": "Identities", + "identities.just_now": "Just now", + "identities.last_activity": "Last activity", + "identities.later": "Later", + "identities.message_label": "Message", + "identities.message_routing_desc": "Control which conversations go to which workspace folder. Messages are routed to the worker's default folder unless you set up rules here.", + "identities.message_routing_title": "Message routing", + "identities.messages_today": "Messages today", + "identities.messaging_disabled_hint": "Enable messaging only if you understand the risk and plan to secure access (for example, private Telegram pairing).", + "identities.messaging_disabled_restart": "Messaging disabled. Restart this worker to stop the messaging sidecar.", + "identities.messaging_disabled_risk": "Messaging bots can execute actions against your local worker. If exposed publicly, they may allow access to files, credentials, and API keys available to this worker.", + "identities.messaging_disabled_title": "Messaging is disabled by default", + "identities.messaging_enabled_restart": "Messaging enabled. Restart this worker to apply before configuring channels.", + "identities.messaging_sidecar_not_running": "Messaging is enabled in this workspace, but the messaging sidecar is not running yet. Restart this worker, then return to Messaging settings to connect Telegram or Slack.", + "identities.minutes_ago": "{minutes}m ago", + "identities.not_set": "Not set", + "identities.open_bot_link": "Open @{username} in Telegram", + "identities.pairing_code_copied": "Pairing code copied.", + "identities.pairing_code_copy_failed": "Could not copy pairing code. Copy it manually.", + "identities.pairing_code_instruction_prefix": "Send", + "identities.peer_id_label": "Peer ID (optional)", + "identities.peer_id_placeholder_slack": "e.g. slack:U12345678", + "identities.peer_id_placeholder_telegram": "e.g. telegram:123456789", + "identities.private_label": "Private", + "identities.private_pairing_code": "Private pairing code", + "identities.public_bot_confirm": "Yes I understand the risk", + "identities.public_bot_warning_message": "Your bot will be accessible to the public and anyone who gets access to your bot will be able to have full access to your local worker including any files or API keys that you've given it. If you create a private bot, you can limit who can access it by requiring a pairing token. Are you sure you want to make your bot public?", + "identities.public_bot_warning_title": "Make this bot public?", + "identities.public_label": "Public", + "identities.quick_setup": "Quick setup", + "identities.reconnect_failed": "Reconnect failed. Check OpenWork URL/token and try again.", + "identities.reconnected": "Reconnected.", + "identities.reconnected_refreshing": "Reconnected. Refreshing worker state...", + "identities.reload": "Reload", + "identities.repair_reconnect": "Repair & reconnect", + "identities.restart_failed": "Restart failed. Please restart the worker from Settings and try again.", + "identities.restart_to_disable_messaging": "Messaging was disabled for this workspace. Restart the worker now to stop the messaging sidecar.", + "identities.restart_to_enable_messaging": "Messaging was enabled for this workspace. Restart the worker now to start the messaging sidecar and unlock Telegram and Slack setup.", + "identities.restart_worker": "Restart worker", + "identities.restart_worker_title": "Restart worker now?", + "identities.restarting": "Restarting...", + "identities.routing_override_prefix": "All messages routed to", + "identities.routing_override_suffix": "(override active)", + "identities.running_label": "Running", + "identities.save_behavior": "Save behavior", + "identities.saving": "Saving...", + "identities.send_test_button": "Send test message", + "identities.send_test_desc": "Validate outbound wiring. Use a peer ID for direct send, or leave peer ID empty to fan out by bindings in a directory.", + "identities.send_test_title": "Send test message", + "identities.sending": "Sending...", + "identities.slack_desc": "Your worker appears as a bot in Slack channels. Team members can message it directly or mention it in threads.", + "identities.slack_intro": "Connect your Slack workspace to let team members interact with this worker in channels and DMs.", + "identities.slack_unavailable": "Slack identities unavailable.", + "identities.status_active": "Active", + "identities.status_label": "Status", + "identities.status_stopped": "Stopped", + "identities.stopped_label": "Stopped", + "identities.subtitle": "Let people reach your worker through messaging apps. Connect a channel and your worker will automatically read and respond to messages.", + "identities.tab_general": "General", + "identities.telegram_bot_access_desc": "Public bot: first Telegram chat auto-links. Private bot: requires a pairing code before any messages run tools.", + "identities.telegram_delete_failed": "Failed to delete.", + "identities.telegram_deleted": "Deleted.", + "identities.telegram_deleted_pending": "Deleted (pending apply).", + "identities.telegram_desc": "Connect a Telegram bot in public mode (open inbox) or private mode (pairing code required).", + "identities.telegram_private_saved_pair": "Private bot saved. Pair via /pair {code}", + "identities.telegram_save_failed": "Failed to save.", + "identities.telegram_saved": "Saved.", + "identities.telegram_saved_pending": "Saved (pending apply).", + "identities.telegram_saved_username": "Saved (@{username})", + "identities.telegram_unavailable": "Telegram identities unavailable.", + "identities.title": "Messaging channels", + "identities.unsaved_changes": "Unsaved changes", + "identities.worker_offline": "Worker offline", + "identities.worker_online": "Worker online", + "identities.worker_restarted": "Worker restarted.", + "identities.worker_restarted_refreshing": "Worker restarted. Refreshing messaging status...", + "identities.worker_scope_unavailable": "Worker scope unavailable.", + "identities.worker_scope_unavailable_detail": "Worker scope unavailable. Reconnect using a worker URL or switch to a known worker.", + "identities.worker_unavailable": "Worker unavailable", + "identities.workspace_id_required": "Workspace ID is required to manage identities. Reconnect with a workspace URL or select a workspace mapped on this host.", + "identities.workspace_scope_prefix": "Workspace scope:", + "inbox_panel.connect_to_download": "Connect to a worker to download shared files.", + "inbox_panel.connect_to_see": "Connect to see shared files.", + "inbox_panel.connect_to_upload": "Connect to a worker to upload", + "inbox_panel.copy_failed": "Copy failed. Your browser may block clipboard access.", + "inbox_panel.download": "Download", + "inbox_panel.drop_to_upload": "Drop files here to upload", + "inbox_panel.helper_text": "Share files with this worker from the app.", + "inbox_panel.load_failed": "Failed to load shared folder", + "inbox_panel.missing_file_id": "Missing shared file id.", + "inbox_panel.no_files": "No shared files yet.", + "inbox_panel.refresh_tooltip": "Refresh shared folder", + "inbox_panel.shared_folder": "Shared folder", + "inbox_panel.showing_first": "Showing first {count}.", + "inbox_panel.upload_failed": "Shared folder upload failed", + "inbox_panel.upload_needs_worker": "Connect to a worker to upload files to the shared folder.", + "inbox_panel.upload_prompt": "Drop files or click to upload", + "inbox_panel.upload_success": "Uploaded to the shared folder.", + "inbox_panel.uploading": "Uploading...", + "inbox_panel.uploading_label": "Uploading {label}...", + "mcp.activate_button": "Activate", + "mcp.disable_app": "Disable", + "mcp.enable_app": "Enable", + "mcp.reloading_status": "Reloading MCP servers…", + "mcp.toggle_failed": "Failed to update MCP enabled state.", + "mcp.toggle_requires_server": "Connect to an OpenWork server to enable or disable MCPs.", + "mcp.add_modal_subtitle": "Connect a custom MCP server by URL or local command.", + "mcp.add_modal_title": "Add Custom App", + "mcp.add_server_button": "Add App", + "mcp.advanced": "Advanced", + "mcp.advanced_settings": "Advanced settings", + "mcp.advanced_settings_hint": "Edit config files and manage connections manually.", + "mcp.app_connected": "app connected", + "mcp.apps_connected": "apps connected", + "mcp.apps_subtitle": "Connect your favorite tools so OpenWork can use them on your behalf.", + "mcp.apps_title": "Apps", + "mcp.auth.already_connected": "Already Connected", + "mcp.auth.already_connected_description": "{server} is already authenticated and ready to use.", + "mcp.auth.applying_changes_body": "We are restarting the worker so the new MCP is ready to authenticate.", + "mcp.auth.applying_changes_title": "Applying changes before sign-in", + "mcp.auth.authorization_link": "Authorization link", + "mcp.auth.authorization_still_required": "Authorization is still required. Try again to restart the flow.", + "mcp.auth.callback_invalid": "Paste the callback URL or the code parameter to finish OAuth.", + "mcp.auth.callback_label": "Callback URL or code", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "Cancel", + "mcp.auth.client_registration_required": "Client registration is required before OAuth can continue.", + "mcp.auth.complete_connection": "Complete connection", + "mcp.auth.configured_previously": "The MCP may have been configured globally or in a previous session. You can close this modal and start using the MCP tools right away.", + "mcp.auth.connect_server": "Connect {server}", + "mcp.auth.copied": "Copied", + "mcp.auth.copy_link": "Copy link", + "mcp.auth.done": "Done", + "mcp.auth.failed_to_start_oauth": "Failed to start OAuth flow", + "mcp.auth.follow_browser_steps": "Follow the authorization steps in the browser.", + "mcp.auth.force_stop": "Force stop", + "mcp.auth.force_stopping": "Stopping...", + "mcp.auth.im_done": "I'm done", + "mcp.auth.invalid_refresh_token": "The OAuth refresh token is invalid or expired. Reauthorize to continue.", + "mcp.auth.manual_finish_hint": "Paste the callback URL (localhost:19876) or just the code to finish connecting.", + "mcp.auth.manual_finish_title": "Remote server?", + "mcp.auth.oauth_completed_reload": "OAuth completed. Reload the engine to activate the MCP.", + "mcp.auth.oauth_failed": "OAuth authentication failed.", + "mcp.auth.oauth_not_supported_hint": "This could mean:\n• The MCP server doesn't advertise OAuth capabilities\n• The engine needs to reload to discover server capabilities\n• Try: opencode mcp auth {server} from the CLI", + "mcp.auth.open_browser_signin": "We'll open your browser to finish sign-in.", + "mcp.auth.port_forward_hint": "Tip: forward the callback port if needed: ssh -L 19876:127.0.0.1:19876 user@host", + "mcp.auth.reauth_action": "Reauthorize OAuth", + "mcp.auth.reauth_cli_hint": "Run: opencode mcp auth {server}", + "mcp.auth.reauth_failed": "Reauthorization failed.", + "mcp.auth.reauth_remote_hint": "Reauthorize from the machine running this worker.", + "mcp.auth.reauth_running": "Reauthorizing...", + "mcp.auth.reload_blocked": "Reload is paused while a session is running. Stop the run to finish setup.", + "mcp.auth.reload_engine_retry": "Apply changes and retry", + "mcp.auth.reload_failed": "Failed to reload the worker before sign-in.", + "mcp.auth.reload_notice": "For this to take effect, OpenWork needs to refresh the worker service. This can interrupt a running session.", + "mcp.auth.reload_remote_confirm": "For this to take effect, OpenWork needs to refresh the worker service. This might stop your running session. Continue?", + "mcp.auth.reopen_browser_link": "Click here to re-open the browser", + "mcp.auth.request_timed_out": "Request timed out.", + "mcp.auth.retry": "Retry", + "mcp.auth.retry_now": "Retry Now", + "mcp.auth.server_disabled": "This MCP server is disabled. Enable it and try again.", + "mcp.auth.step1_description": "We'll launch {server}'s sign-in flow automatically.", + "mcp.auth.step1_title": "Opening your browser", + "mcp.auth.step2_description": "Sign in and approve access when prompted.", + "mcp.auth.step2_title": "Authorize OpenWork", + "mcp.auth.step3_description": "We'll finish connecting as soon as authorization completes.", + "mcp.auth.step3_title": "Return here when you're done", + "mcp.auth.try_reload_engine": "{message}. Try reloading the engine first.", + "mcp.auth.waiting_authorization": "Waiting for authorization to complete in your browser...", + "mcp.auth.waiting_for_conversation_body": "We will redirect you to authenticate as soon as possible.", + "mcp.auth.waiting_for_conversation_title": "Waiting for conversation to complete", + "mcp.auth.waiting_for_session": "Waiting for {session} to finish working", + "mcp.available_apps": "Available apps", + "mcp.cap_signin": "Account sign-in", + "mcp.cap_tools": "AI tools", + "mcp.config_file": "Config file", + "mcp.config_load_failed": "Couldn't load the config file", + "mcp.config_not_loaded": "Not loaded yet", + "mcp.config_source": "From config", + "mcp.configured": "configured", + "mcp.connect": "Connect", + "mcp.connect_failed": "Couldn't connect. Try again.", + "mcp.connect_server_first": "Connect to the server first.", + "mcp.connected": "Connected", + "mcp.connected_badge": "Connected", + "mcp.connecting": "Connecting...", + "mcp.connection_failed": "Connection issue — try again", + "mcp.connection_type": "Connection", + "mcp.control_chrome_browser_hint": "In Chrome 144 or newer, do this first:", + "mcp.control_chrome_browser_step_one": "Open chrome://inspect/#remote-debugging.", + "mcp.control_chrome_browser_step_two": "Enable remote debugging.", + "mcp.control_chrome_browser_step_three": "Allow incoming debugging connections when Chrome asks.", + "mcp.control_chrome_browser_title": "1. Turn on Chrome access", + "mcp.control_chrome_connect": "Add Control Chrome", + "mcp.control_chrome_docs": "Official MCP guide", + "mcp.control_chrome_edit": "Edit settings", + "mcp.control_chrome_profile_hint": "Control Chrome normally opens a separate Chrome profile. Turn this on if you want OpenWork to reuse the Chrome window you already have open.", + "mcp.control_chrome_profile_title": "2. Choose which Chrome to use", + "mcp.control_chrome_save": "Save settings", + "mcp.control_chrome_setup_subtitle": "Turn on Chrome access, then choose whether OpenWork should use its own clean profile or attach to the Chrome you already use.", + "mcp.control_chrome_setup_title": "Set up Control Chrome", + "mcp.control_chrome_toggle_hint": "When this is on, OpenWork adds --autoConnect so the MCP attaches to a Chrome instance you already started.", + "mcp.control_chrome_toggle_label": "Use my existing Chrome profile", + "mcp.control_chrome_toggle_off": "OpenWork will launch a separate Chrome profile just for automation.", + "mcp.control_chrome_toggle_on": "OpenWork will reuse your current tabs, cookies, and sign-ins.", + "mcp.custom_app_cta_hint": "Connect your own MCP server, internal tool, or hosted app.", + "mcp.desktop_required": "Apps require the desktop app.", + "mcp.docs_link": "Learn more", + "mcp.file_not_found": "Config file not created yet", + "mcp.finish_setup": "Almost there", + "mcp.finish_setup_hint": "Tap Activate to finish connecting your app.", + "mcp.friendly_status_issue": "Issue", + "mcp.friendly_status_needs_signin": "Sign in needed", + "mcp.friendly_status_offline": "Offline", + "mcp.friendly_status_paused": "Paused", + "mcp.friendly_status_ready": "Ready", + "mcp.last_synced": "Synced", + "mcp.login_action": "Sign in", + "mcp.login_hint": "Connect your account to finish setting up this app.", + "mcp.login_unavailable": "This app does not support sign-in from OpenWork.", + "mcp.logout_action": "Log out", + "mcp.logout_failed": "Failed to log out.", + "mcp.logout_hint": "Removes stored OAuth credentials. You'll need to sign in again.", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "This will remove stored OAuth credentials for {server}. You'll need to sign in again to use this app.", + "mcp.logout_modal_title": "Log out of this app?", + "mcp.logout_success": "Logged out of {server}.", + "mcp.logout_working": "Logging out...", + "mcp.name_required": "Enter a server name.", + "mcp.no_apps_hint": "Connect one above to get started.", + "mcp.no_apps_yet": "No apps connected yet", + "mcp.oauth": "Sign in", + "mcp.oauth_optional_hint": "Uses OAuth in the browser to connect your account.", + "mcp.oauth_optional_label": "This app requires sign-in", + "mcp.one_click_connect": "One-click connect", + "mcp.open_file": "Open file", + "mcp.opening_label": "Opening...", + "mcp.pick_workspace_error": "Choose a workspace folder first.", + "mcp.pick_workspace_first": "Choose a workspace folder first.", + "mcp.quick_connect_chrome_desc": "Drive Chrome tabs with browser automation.", + "mcp.quick_connect_chrome_title": "Control Chrome", + "mcp.quick_connect_context7_desc": "Search product docs with richer context.", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "Plan sprints and ship tickets faster.", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "Pages, databases, and project docs in sync.", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "Track releases and resolve production errors.", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "Inspect payments, invoices, and subscriptions.", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "Stop the running task to activate.", + "mcp.reload_banner_description": "Tap Activate to finish connecting your app.", + "mcp.reload_banner_description_blocked": "A task is running. Stop it first, then activate.", + "mcp.remote_workspace_url_hint": "Remote workers connect fastest with URL-based MCP servers.", + "mcp.remove_app": "Remove", + "mcp.remove_failed": "Couldn't remove the app.", + "mcp.remove_modal_message": "Are you sure you want to remove {server}? You can always add it back later.", + "mcp.remove_modal_title": "Remove app", + "mcp.reveal_config_failed": "Couldn't open the config file", + "mcp.reveal_in_finder": "Show in Finder", + "mcp.scope_global": "All workspaces", + "mcp.scope_project": "This workspace", + "mcp.server_command": "Command", + "mcp.server_command_hint": "The shell command to start the server.", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "App name", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "Type", + "mcp.server_url": "Server URL", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "Sign-in", + "mcp.tap_to_connect": "Tap to connect", + "mcp.technical_details": "Technical details", + "mcp.type_cloud": "Cloud (sign in with your account)", + "mcp.type_local": "Local (runs on this device)", + "mcp.type_local_cmd": "Local (command)", + "mcp.type_remote": "Remote (URL)", + "mcp.url_or_command_required": "Enter a URL for remote or a command for local servers.", + "mcp.your_apps": "Your apps", + "message.tool_request_label": "Request", + "message.tool_result_label": "Result", + "message.waiting_subagent": "Waiting for the subagent transcript to arrive.", + "message_list.copy_message": "Copy message", + "message_list.open_session": "Open session", + "message_list.step_updates_progress": "Updates progress", + "message_list.subagent_loading_transcript": "Loading transcript", + "message_list.subagent_message_count": "{count} message{plural}", + "message_list.subagent_running": "Running", + "message_list.subagent_session_fallback": "Subagent session", + "message_list.subagent_type_task": "{agentType} task", + "message_list.subagent_waiting_transcript": "Waiting for transcript", + "message_list.tool_checked_url": "Checked {url}", + "message_list.tool_checked_web_fallback": "Checked web page", + "message_list.tool_delegate_agent": "Delegate {agent}", + "message_list.tool_delegate_task_fallback": "Delegate task", + "message_list.tool_load_skill_fallback": "Load skill", + "message_list.tool_load_skill_named": "Load skill {name}", + "message_list.tool_read_todo": "Read todo list", + "message_list.tool_reviewed_file": "Reviewed {file}", + "message_list.tool_reviewed_file_fallback": "Reviewed file", + "message_list.tool_reviewed_files_fallback": "Reviewed files", + "message_list.tool_reviewed_path": "Reviewed {path}", + "message_list.tool_run_command": "Run {command}", + "message_list.tool_run_command_fallback": "Run command", + "message_list.tool_searched_code_fallback": "Searched code", + "message_list.tool_searched_pattern": "Searched {pattern}", + "message_list.tool_update_file": "Update {file}", + "message_list.tool_update_file_fallback": "Update file", + "message_list.tool_update_todo": "Update todo list", + "message_list.tool_updated_file": "Updated {file}", + "message_list.tool_updated_file_fallback": "Updated file", + "model_behavior.desc_builtin": "This model decides its own reasoning path and does not expose profiles here.", + "model_behavior.desc_generic": "Use the {label} profile.", + "model_behavior.desc_high": "Spend more time reasoning before answering.", + "model_behavior.desc_high_anthropic": "Use the standard extended-thinking budget.", + "model_behavior.desc_low": "Use a lighter reasoning pass before answering.", + "model_behavior.desc_low_google": "Use a lighter reasoning budget for quicker responses.", + "model_behavior.desc_max": "Use the provider's deepest reasoning profile.", + "model_behavior.desc_max_anthropic": "Use the largest extended-thinking budget available.", + "model_behavior.desc_medium": "Balance speed and reasoning depth.", + "model_behavior.desc_minimal": "Use a very small amount of reasoning.", + "model_behavior.desc_none": "Favor speed with the lightest reasoning path.", + "model_behavior.desc_standard": "This model does not expose extra reasoning controls.", + "model_behavior.label_balanced": "Balanced", + "model_behavior.label_builtin": "Built in", + "model_behavior.label_deep": "Deep", + "model_behavior.label_extended": "Extended", + "model_behavior.label_fast": "Fast", + "model_behavior.label_light": "Light", + "model_behavior.label_maximum": "Maximum", + "model_behavior.label_quick": "Quick", + "model_behavior.label_standard": "Standard", + "model_behavior.title_builtin_reasoning": "Built-in reasoning", + "model_behavior.title_extended_thinking": "Extended thinking", + "model_behavior.title_reasoning_budget": "Reasoning budget", + "model_behavior.title_reasoning_effort": "Reasoning effort", + "model_behavior.title_standard_generation": "Standard generation", + "model_picker.chat_model_desc": "Choose the model for this chat. If a model supports reasoning profiles, configure them on its card.", + "model_picker.chat_model_title": "Chat model", + "model_picker.connect_provider_hint": "Connect this provider to browse and save models", + "model_picker.default_model_desc": "Choose the default model for new chats, then fine-tune reasoning profiles on its card before pressing Done.", + "model_picker.default_model_title": "Default model", + "model_picker.model_count": "{count} models", + "model_picker.model_count_one": "1 model", + "model_picker.more_providers": "More providers", + "model_picker.no_results": "No models match your search.", + "model_picker.other_connected_models": "Other connected models", + "model_picker.recommended": "Recommended", + "onboarding.access_label": "Access", + "onboarding.add": "Add", + "onboarding.add_folder_path": "Add folder path", + "onboarding.advanced_settings": "Advanced settings", + "onboarding.attach": "Attach", + "onboarding.attach_description": "Attach to the existing session on this device.", + "onboarding.authorize_folder": "Authorize folder", + "onboarding.back": "Back", + "onboarding.checking_cli": "Checking OpenCode CLI...", + "onboarding.choose_workspace_folder": "Choose workspace folder", + "onboarding.cli_checking": "Checking install...", + "onboarding.cli_install_commands": "Install OpenCode with one of the commands below, then restart OpenWork.", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "OpenCode CLI needs an update for serve.", + "onboarding.cli_not_found": "OpenCode CLI not found.", + "onboarding.cli_not_found_hint": "Not found. Install to run the local server.", + "onboarding.cli_ready": "OpenCode CLI ready.", + "onboarding.cli_recheck": "Re-check", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "Installed", + "onboarding.create_first_workspace": "Create your first workspace", + "onboarding.create_workspace": "Create a workspace", + "onboarding.engine_running": "Engine already running", + "onboarding.folders_allowed": "{count} folder{plural} allowed", + "onboarding.getting_ready": "Getting everything ready", + "onboarding.install": "Install OpenCode", + "onboarding.install_instruction": "Install OpenCode to enable the local server (no terminal required).", + "onboarding.last_checked": "Last checked {time}", + "onboarding.manage_access_hint": "You can manage access in advanced settings.", + "onboarding.open_settings": "Open Settings", + "onboarding.open_settings_hint": "Need engine or access options? Open Settings.", + "onboarding.pick": "Pick", + "onboarding.ready_message": "OpenCode is ready to start the local server.", + "onboarding.remember_choice": "Remember my choice for next time", + "onboarding.remote_workspace_action": "Connect", + "onboarding.remote_workspace_card_description": "Connect to an OpenWork server to access a shared workspace.", + "onboarding.remote_workspace_card_title": "Connect a remote workspace", + "onboarding.remote_workspace_description": "Connect to an OpenWork server to access a workspace from anywhere.", + "onboarding.remote_workspace_title": "Connect to OpenWork server", + "onboarding.remove": "Remove", + "onboarding.resolved_path": "Resolved path", + "onboarding.run_local": "Run locally", + "onboarding.run_local_description": "OpenWork runs OpenCode locally and keeps your work private.", + "onboarding.search_notes": "Search notes", + "onboarding.searching_host": "Connecting to OpenWork server...", + "onboarding.serve_help": "serve --help output", + "onboarding.show_search_notes": "Show search notes", + "onboarding.start": "Start OpenWork", + "onboarding.starting_host": "Starting OpenWork server...", + "onboarding.theme_current": "Current: {mode}", + "onboarding.theme_dark": "Dark", + "onboarding.theme_label": "Theme", + "onboarding.theme_light": "Light", + "onboarding.theme_system": "System", + "onboarding.verifying": "Verifying secure handshake", + "onboarding.version": "Version", + "onboarding.welcome_title": "How would you like to run OpenWork today?", + "onboarding.windows_install_instruction": "Install OpenCode for Windows, then restart OpenWork. Ensure opencode.exe is on PATH.", + "onboarding.workspace_folder_label": "A workspace is a folder with its own skills, plugins, and commands.", + "welcome.title": "Welcome to OpenWork", + "welcome.subtitle": "Your computer, but it works for you.", + "welcome.get_started": "Get started", + "welcome.capability_spreadsheets": "Edit spreadsheets", + "welcome.capability_spreadsheets_desc": "Create, clean, and transform CSV and Excel files.", + "welcome.capability_browser": "Control your browser", + "welcome.capability_browser_desc": "Automate Chrome for repetitive web tasks.", + "welcome.capability_files": "Organize files", + "welcome.capability_files_desc": "Read, write, and manage files and folders.", + "welcome.capability_automate": "Automate tasks", + "welcome.capability_automate_desc": "Build reusable workflows with skills and commands.", + "welcome.capability_content": "Generate content", + "welcome.capability_content_desc": "Draft documents, emails, and reports.", + "welcome.capability_apis": "Connect to APIs", + "welcome.capability_apis_desc": "Plug into external services and tools via MCP.", + "welcome.folder_title": "Pick a folder", + "welcome.folder_explanation": "This folder becomes your workspace. OpenWork will be able to:", + "welcome.folder_read": "Read files you put in there", + "welcome.folder_write": "Create and edit files for you", + "welcome.folder_anything": "Work with spreadsheets, docs, images \u2014 anything in the folder", + "welcome.folder_drop_hint": "Drop files in anytime and OpenWork can pick them up.", + "plugins.add": "Add", + "plugins.add_hint": "Add npm package names, e.g. opencode-wakatime", + "plugins.add_label": "Add plugin", + "plugins.added": "Added", + "plugins.config": "Config", + "plugins.config_label": "Config", + "plugins.desc": "Manage `opencode.json` for your project or global OpenCode plugins.", + "plugins.empty": "No plugins configured yet.", + "plugins.enabled": "Enabled", + "plugins.hide_setup": "Hide setup", + "plugins.not_loaded": "Not loaded yet", + "plugins.not_loaded_yet": "Not loaded yet", + "plugins.remove": "Remove", + "plugins.scope_global": "Global", + "plugins.scope_project": "Project", + "plugins.setup": "Setup", + "plugins.suggested": "Suggested plugins", + "plugins.suggested_heading": "Suggested plugins", + "plugins.title": "OpenCode plugins", + "providers.api_key_label": "API key", + "providers.api_key_required": "API key is required", + "providers.auth_failed": "Authentication failed", + "providers.connect_failed": "Failed to connect provider", + "providers.disabled_in_config_suffix": "and disabled it in OpenCode config.", + "providers.disconnect_failed": "Failed to disconnect provider", + "providers.disconnected_prefix": "Disconnected", + "providers.load_failed": "Failed to load providers", + "providers.no_oauth_prefix": "No OAuth flow available for", + "providers.no_providers_available": "No providers available", + "providers.not_connected": "Not connected to a server", + "providers.not_oauth_flow_prefix": "Selected auth method is not an OAuth flow for", + "providers.oauth_failed": "Failed to complete OAuth", + "providers.oauth_method_required": "OAuth method is required", + "providers.provider_error": "Provider error ({provider})", + "providers.provider_id_required": "Provider ID is required", + "providers.rate_limit_exceeded": "Rate limit exceeded", + "providers.removal_unsupported": "Provider auth removal is not supported by this client.", + "providers.request_failed": "Request failed", + "providers.save_api_key_failed": "Failed to save API key", + "providers.still_connected_suffix": ", but the worker still reports it as connected. Clear any remaining API key or OAuth credentials and restart the worker to fully disconnect.", + "providers.unknown_provider": "Unknown provider", + "providers.use_api_key_suffix": "Use an API key instead.", + "question_modal.custom_answer_label": "Or type a custom answer", + "question_modal.custom_answer_placeholder": "Type your answer here...", + "question_modal.question_counter": "Question {current} of {total}", + "session.allow_for_session": "Allow for session", + "session.allow_once": "Allow once", + "session.api_key_saved": "API key saved", + "session.attachments_add_token": "Add a server token to attach files.", + "session.attachments_connect_server": "Connect to OpenWork server to attach files.", + "session.back": "Back", + "session.close_quick_actions": "Close quick actions", + "session.close_search": "Close search", + "session.cmd_compact_detail": "Send a compact instruction to OpenCode for this session", + "session.cmd_compact_detail_empty": "No user messages to compact yet", + "session.cmd_compact_meta": "Compact", + "session.cmd_compact_title": "Compact Conversation", + "session.cmd_current_workspace": "Current workspace", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "Model", + "session.cmd_model_meta": "Open", + "session.cmd_model_title": "Change model", + "session.cmd_new_session_detail": "Start a fresh task in the current workspace", + "session.cmd_new_session_meta": "Create", + "session.cmd_new_session_title": "Create new session", + "session.cmd_provider_detail": "Open provider connection flow", + "session.cmd_provider_meta": "Open", + "session.cmd_provider_title": "Connect provider", + "session.cmd_rename_detail_fallback": "Give your selected session a clearer name", + "session.cmd_rename_meta": "Rename", + "session.cmd_rename_title": "Rename current session", + "session.cmd_sessions_detail": "{count} available across workspaces", + "session.cmd_sessions_meta": "Jump", + "session.cmd_sessions_title": "Search sessions", + "session.cmd_settings_meta": "Open", + "session.cmd_switch": "Switch", + "session.palette_no_matches": "No matches.", + "session.compacted": "Session compacted.", + "session.compacting": "Compacting session context...", + "session.compacting_auto": "OpenCode is auto-compacting this session", + "session.compacting_manual": "OpenCode is compacting this session", + "session.compaction_finished": "OpenCode finished compacting the session context.", + "session.compaction_started": "OpenCode started compacting the session context.", + "session.conflict_sync_toast": "Conflict syncing {path}. Saved local changes to {conflictPath}.", + "session.connect_failed": "Connect failed", + "session.connect_to_sync": "Connect to OpenWork server to sync remote files.", + "session.create_or_connect_workspace": "Create or connect a workspace", + "session.create_workspace_desc": "Open the workspace creator and choose how you want to start.", + "session.create_workspace_title": "Create workspace", + "session.default_agent": "Default agent", + "session.default_model": "Pick a model", + "session.default_title": "New session", + "session.delete": "Delete", + "session.delete_named_session_message": "This will permanently delete \"{title}\" and its messages.", + "session.delete_session_generic": "This will permanently delete the selected session and its messages.", + "session.delete_session_title": "Delete session?", + "session.deleted": "Session deleted", + "session.deleting": "Deleting...", + "session.deny": "Deny", + "session.details": "Details", + "session.details_label": "Details", + "session.doom_loop_label": "Doom Loop", + "session.doom_loop_message": "OpenCode detected repeated tool calls with identical input and is asking whether it should continue after repeated failures.", + "session.doom_loop_note": "Reject to stop the loop, or allow if you want the agent to keep trying.", + "session.doom_loop_repeated_call_label": "Repeated call", + "session.doom_loop_repeated_tool_call": "Repeated tool call", + "session.doom_loop_title": "Doom Loop Detected", + "session.doom_loop_tool_label": "Tool", + "session.downloading": "Downloading", + "session.downloading_percent": "Downloading {percent}%", + "session.downloading_update_title": "Downloading update {version}", + "session.export_already_running": "Export is already running.", + "session.export_desktop_only": "Export is available in the desktop app.", + "session.export_desktop_only_local": "Export is available for local workers in the desktop app.", + "session.export_local_only": "Export is only supported for local workers.", + "session.failed_to_compact": "Failed to compact session", + "session.failed_to_create_session": "Failed to create session", + "session.failed_to_delete": "Failed to delete session", + "session.failed_to_load_agents": "Failed to load agents", + "session.failed_to_load_providers": "Failed to load providers", + "session.failed_to_redo": "Failed to redo", + "session.failed_to_save_api_key": "Failed to save API key", + "session.failed_to_stop": "Failed to stop", + "session.failed_to_undo": "Failed to undo", + "session.file_open_desktop_only": "File open is available in the desktop app.", + "session.file_open_failed": "File open failed", + "session.file_open_remote_unavailable": "File open is unavailable for remote workspaces.", + "session.flyout_file_modified": "File Modified", + "session.flyout_new_task": "New Task", + "session.install_update": "Install update", + "session.jump_to_latest": "Jump to latest", + "session.jump_to_start": "Jump to start of message", + "session.load_earlier": "Load earlier messages", + "session.loading_detail": "Pulling in the latest messages for this task.", + "session.loading_earlier": "Loading earlier messages...", + "session.loading_session": "Loading session", + "session.loading_title": "Loading session", + "session.menu_label": "Menu", + "session.model": "Model", + "session.model_fallback": "Model", + "session.new_task": "New task", + "session.next_match": "Next match", + "session.no_matches": "No matches", + "session.no_matches_command": "No matches.", + "session.no_session_selected": "No session selected", + "session.nothing_to_compact": "Nothing to compact yet.", + "session.nothing_to_redo": "Nothing to redo.", + "session.nothing_to_retry": "Nothing to retry yet", + "session.nothing_to_undo": "Nothing to undo yet.", + "session.oauth_failed": "OAuth failed", + "session.obsidian_worker_relative_only": "Only worker-relative files can be opened in Obsidian.", + "session.open": "Open", + "session.palette_hint_navigate": "Arrow keys to navigate", + "session.palette_hint_run": "Enter to run · Esc to close", + "session.palette_placeholder_actions": "Search actions", + "session.palette_placeholder_sessions": "Find by session title or workspace", + "session.palette_title_actions": "Quick actions", + "session.palette_title_sessions": "Search sessions", + "session.permission_label": "Permission", + "session.permission_detail_command": "Command", + "session.permission_detail_cwd": "Working directory", + "session.permission_detail_description": "Description", + "session.permission_detail_diff": "Diff", + "session.permission_detail_files": "Files", + "session.permission_detail_file": "File", + "session.permission_detail_agent": "Agent", + "session.permission_detail_parent_directory": "Parent directory", + "session.permission_detail_path": "Path", + "session.permission_detail_query": "Query", + "session.permission_detail_target": "Target", + "session.permission_detail_tool": "Tool", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "File edit", + "session.permission_kind_external_directory": "External directory", + "session.permission_kind_question": "Question", + "session.permission_kind_read": "File read", + "session.permission_kind_skill": "Skill", + "session.permission_kind_task": "Subtask", + "session.permission_kind_todowrite": "Todo write", + "session.permission_message": "OpenCode is requesting permission to continue.", + "session.permission_message_bash": "Review the command scope before allowing OpenCode to continue.", + "session.permission_message_edit": "Review the file and diff before allowing OpenCode to make changes.", + "session.permission_message_external_directory": "Review the folder before allowing access outside the workspace.", + "session.permission_message_read": "Review the requested file scope before allowing access.", + "session.permission_message_task": "Review the requested subtask before allowing it to start.", + "session.permission_metadata_unavailable": "Metadata could not be displayed.", + "session.permission_required": "Permission Required", + "session.permission_review_label": "Review", + "session.permission_scope_empty": "No specific scope provided.", + "session.permission_title_bash": "Run a shell command?", + "session.permission_title_edit": "Modify files?", + "session.permission_title_external_directory": "Access an external folder?", + "session.permission_title_generic": "Approve {permission}?", + "session.permission_title_read": "Read files?", + "session.permission_title_task": "Start a subtask?", + "session.permission_decision_hint": "Allow once for this request, or allow for session when you trust this scope.", + "session.phase_responding": "Responding", + "session.phase_retrying": "Retrying", + "session.phase_run_failed": "Run failed", + "session.phase_sending": "Sending", + "session.pick_folder_desc": "Choose an existing project or notes folder and OpenWork will use it as your workspace.", + "session.pick_folder_title": "Pick a folder you want to work in", + "session.pick_workspace_to_open": "Pick a workspace to open files.", + "session.prev_match": "Previous match", + "session.provider_auth_in_progress": "Provider auth is already in progress.", + "session.provider_connected": "Provider connected", + "session.quick_actions_label": "Quick actions", + "session.quick_actions_title": "Quick actions (Ctrl/Cmd+K)", + "session.redo_aria_label": "Redo last reverted message", + "session.redo_label": "Redo", + "session.redo_title": "Redo last reverted message", + "session.remote_sync_failed": "Remote file sync failed", + "session.rename_description": "Update the name for this session.", + "session.rename_label": "Session name", + "session.rename_placeholder": "Enter a new name", + "session.rename_title": "Rename session", + "session.resize_workspace_column": "Resize workspace column", + "session.restart_update_title": "Restart to apply update {version}", + "session.restored_message": "Restored the reverted message.", + "session.reveal": "Reveal", + "session.reveal_desktop_only": "Reveal is available in the desktop app.", + "session.revert_label": "Revert", + "session.reverted_last_message": "Reverted the last user message.", + "session.run": "Run", + "session.scope_label": "Scope", + "session.search_conversation_label": "Search conversation", + "session.search_conversation_title": "Search conversation (Ctrl/Cmd+F)", + "session.search_next": "Next", + "session.search_placeholder": "Search in this chat", + "session.search_position": "{current} of {total}", + "session.search_prev": "Prev", + "session.select_or_create_session": "Select or create a session to get started.", + "session.share_active_cloud_org": "Active Cloud org", + "session.share_choose_org": "Choose an organization in Settings -> Cloud before sharing with your team.", + "session.share_collaborator_hint": "Routine remote access when you do not need owner-only actions.", + "session.share_collaborator_host_hint": "Routine remote access to this host without owner-only actions.", + "session.share_collaborator_label": "Collaborator token", + "session.share_collaborator_token": "Collaborator token", + "session.share_connected_with_hint": "This workspace is currently connected with this password.", + "session.share_desktop_app_required": "Desktop app required", + "session.share_desktop_required": "Desktop app required", + "session.share_host_url_and_token_required": "OpenWork host URL and token are required.", + "session.share_local_host_not_ready": "Local OpenWork host is not ready yet.", + "session.share_missing_host_url": "Missing OpenWork host URL.", + "session.share_missing_token": "Missing OpenWork token.", + "session.share_no_skills": "No skills found in this workspace.", + "session.share_note_direct_runtime": "Remote access shares the currently running local worker. If you switch local folders later, reopen this panel to confirm the URL and password.", + "session.share_opencode_base_url": "OpenCode base URL", + "session.share_openwork_workers_only": "Share service links are available for OpenWork workers.", + "session.share_owner_permission_hint": "Use when the remote client must answer permission prompts.", + "session.share_password": "Password", + "session.share_password_owner_hint": "Use when the remote client must answer permission prompts.", + "session.share_publish_skills_failed": "Failed to publish skills set", + "session.share_publish_workspace_failed": "Failed to publish workspace profile", + "session.share_resolve_local_workspace_failed": "Could not resolve this workspace on the local OpenWork host.", + "session.share_resolve_remote_workspace_failed": "Could not resolve this workspace on the OpenWork host.", + "session.share_save_team_template_failed": "Failed to save team template", + "session.share_saved_to_org": "Saved {name} to {org}.", + "session.share_select_workspace": "Select a workspace first.", + "session.share_set_token_hint": "Set token in workspace settings", + "session.share_sign_in_required": "Sign in to OpenWork Cloud in Settings to share with your team.", + "session.share_skills_set_desc": "Complete skills set from an OpenWork workspace.", + "session.share_starting_server": "Starting server...", + "session.share_team_fallback_name": "your team templates", + "session.share_url_resolving_hint": "Worker URL is resolving; host URL shown as fallback.", + "session.share_url_worker_hint": "Use on phones or laptops connecting to this worker.", + "session.share_worker_url": "Worker URL", + "session.share_worker_url_phones_hint": "Use on phones or laptops connecting to this worker.", + "session.share_worker_url_resolving_hint": "Worker URL is resolving; host URL shown as fallback.", + "session.shared_folder_upload_failed": "Shared folder upload failed", + "session.show_earlier": "Show {count} earlier message{plural}", + "session.status_active": "Session Active", + "session.status_compacting": "Compacting Context", + "session.status_delegating": "Delegating", + "session.status_gathering_context": "Gathering context", + "session.status_planning": "Planning", + "session.status_ready": "Ready", + "session.status_ready_session": "Session Ready", + "session.status_running_shell": "Running shell", + "session.status_searching_codebase": "Searching codebase", + "session.status_searching_web": "Searching the web", + "session.status_thinking": "Thinking", + "session.status_working": "Working", + "session.status_writing_file": "Writing file", + "session.stopped": "Stopped.", + "session.stopping_run": "Stopping the run...", + "session.todo_label": "Tasks", + "session.todo_progress": "{completed} out of {total} tasks completed", + "session.todo_progress_label": "Progress", + "session.trying_again": "Trying again...", + "session.unable_to_open_file": "Unable to open file", + "session.unable_to_open_obsidian": "Unable to open file in Obsidian", + "session.unable_to_reveal": "Unable to reveal workspace", + "session.undo_label": "Revert", + "session.undo_title": "Undo last message", + "session.update_available": "Update available", + "session.update_available_title": "Update available {version}", + "session.update_ready": "Update ready", + "session.update_ready_stop_runs_title": "Update ready {version}. Stop active runs to restart.", + "session.upload_connect_server": "Connect to the OpenWork server to upload files to the shared folder.", + "session.uploaded_to_shared_folder": "Uploaded to the shared folder.", + "session.uploaded_with_summary": "Uploaded to the shared folder: {summary}", + "session.uploading_to_shared_folder": "Uploading {label} to the shared folder...", + "session.workspace_fallback": "Workspace", + "session.workspace_label": "Workspace", + "session.workspace_path_unavailable": "Workspace path is unavailable.", + "session.workspace_setup_desc": "Start with a guided OpenWork workspace, or choose an existing folder you want to work in.", + "session.workspace_setup_label": "Workspace setup", + "session.workspace_setup_title": "Set up your first workspace", + "settings.action_download": "Download", + "settings.action_install": "Install", + "settings.actor_host": "host", + "settings.actor_remote": "remote", + "settings.actor_unknown": "unknown", + "settings.advanced": "Advanced", + "settings.audit_actor_host": "host", + "settings.audit_actor_remote": "remote", + "settings.advanced_title": "Advanced", + "settings.api_keys_info": "API keys are stored locally by OpenCode. Environment-backed providers must be changed in the worker environment and then reloaded.", + "settings.appearance_hint": "Match the system or force light/dark mode.", + "settings.appearance_title": "Appearance", + "settings.audit_error": "Error", + "settings.audit_loading": "Loading", + "settings.audit_log_title": "Audit log", + "settings.audit_ready": "Ready", + "settings.auto_compact": "Auto context compaction", + "settings.auto_compact_desc": "Controls OpenCode compaction.auto for this workspace. Reload the engine after changing it.", + "settings.auto_update_desc": "Download updates automatically (prompts to", + "settings.auto_update_title": "Auto-update", + "settings.available_count": "{count} available", + "settings.background_checks_desc": "OpenWork always checks on launch. Also checks once", + "settings.background_checks_title": "Background checks", + "settings.base_url_unavailable": "Base URL unavailable", + "settings.binary_unavailable": "Binary unavailable", + "settings.cache_nothing_to_repair": "No OpenCode cache found. Nothing to repair.", + "settings.cache_repair_requires_desktop": "Cache repair requires the desktop app", + "settings.cache_repaired": "OpenCode cache repaired. Restart the engine if it was running.", + "settings.cap_browser_tools": "Browser tools: {value}", + "settings.cap_commands": "Commands: {value}", + "settings.cap_config": "Config: {value}", + "settings.cap_file_tools": "File tools: {value}", + "settings.cap_inbox_off": "inbox off", + "settings.cap_inbox_on": "inbox on", + "settings.cap_mcp": "MCP: {value}", + "settings.cap_outbox_off": "outbox off", + "settings.cap_outbox_on": "outbox on", + "settings.cap_plugins": "Plugins: {value}", + "settings.cap_read": "read", + "settings.cap_read_only": "read only", + "settings.cap_read_write": "read/write", + "settings.cap_sandbox": "Sandbox: {value}", + "settings.cap_skills": "Skills: {value}", + "settings.cap_write": "write", + "settings.cap_write_only": "write only", + "settings.capabilities_title": "OpenWork server capabilities", + "settings.capabilities_unavailable": "Capabilities unavailable. Connect with a client token.", + "settings.change": "Change", + "settings.check_update": "Check", + "settings.checking_for_updates": "Checking for updates", + "settings.choose": "Choose", + "settings.clear": "Clear", + "settings.clipboard_unavailable": "Clipboard is unavailable in this environment.", + "settings.config_updated": "Configuration updated. Reload the engine if the change affects OpenCode.", + "settings.configure": "Configure", + "settings.connected_providers_count": "{count, plural, =0 {No providers connected} =1 {1 provider connected} other {# providers connected}}", + "settings.connect_opencode_hint": "Connect to OpenCode to load providers.", + "settings.connect_provider": "Connect provider", + "settings.connected_count": "{count} connected", + "settings.connection": "Connection", + "settings.connection_failed": "Connection failed", + "settings.connection_title": "Connection", + "settings.copied_debug_report": "Copied runtime report JSON.", + "settings.copy_failed": "Failed to copy runtime report.", + "settings.copy_json": "Copy JSON", + "settings.custom_binary_hint": "Use this to point OpenWork at a local OpenCode build", + "settings.custom_binary_label": "Custom OpenCode binary", + "settings.data_dir_unavailable": "Data directory unavailable", + "settings.debug_base_url": "Base URL: {url}", + "settings.debug_cli_version": "CLI: {version}", + "settings.debug_commit": "Commit: {sha}", + "settings.debug_connect_url": "Connect URL: {url}", + "settings.debug_daemon_pid": "Daemon PID: {pid}", + "settings.debug_daemon_url": "Daemon URL: {url}", + "settings.debug_data_dir": "Data dir: {path}", + "settings.debug_desktop_app": "Desktop app: {version}", + "settings.debug_health_port": "Health port: {port}", + "settings.debug_hostname": "Hostname: {hostname}", + "settings.debug_lan_url": "LAN URL: {url}", + "settings.debug_mdns_url": "mDNS URL: {url}", + "settings.debug_opencode_pid": "OpenCode PID: {pid}", + "settings.debug_opencode_url": "OpenCode URL: {url}", + "settings.debug_opencode_version": "OpenCode: {version}", + "settings.debug_openwork_server_version": "OpenWork server: {version}", + "settings.debug_pid": "PID: {pid}", + "settings.debug_port": "Port: {port}", + "settings.debug_project_dir": "Project dir: {path}", + "settings.debug_remote_access": "Remote access: {value}", + "settings.debug_router_version": "Router: {version}", + "settings.debug_runtime": "Runtime: {runtime}", + "settings.debug_section_title": "Developer", + "settings.debug_workspace_path": "Workspace path: {path}", + "settings.error": "Error", + "settings.idle": "Idle", + "settings.loading": "Loading", + "settings.deeplink_failed": "Failed to open deep link.", + "settings.deeplink_hint": "Accepts openwork://, openwork-dev://, or a raw supported https://share.openworklabs.com/b/... URL.", + "settings.default_label": "Default", + "settings.default_model": "Default model", + "settings.delete_containers": "Removing containers...", + "settings.delete_local_config": "Removing local state...", + "settings.desktop_only_hint": "Available in the desktop app.", + "settings.dev_mode_badge": "Dev mode", + "settings.developer": "Developer", + "settings.developer_mode_desc": "Enables debug tools, diagnostics, and the Developer tab.", + "settings.developer_mode_title": "Developer mode", + "settings.developer_panel_disabled": "Developer panel enabled.", + "settings.developer_panel_enabled": "Developer panel enabled.", + "settings.devlog_cleared": "Cleared developer log output.", + "settings.devlog_clipboard_unavailable": "Clipboard is unavailable in this environment.", + "settings.devlog_copied": "Copied developer log output.", + "settings.devlog_copy_failed": "Failed to copy developer log output.", + "settings.devlog_export_failed": "Failed to export developer log output.", + "settings.devlog_export_unavailable": "Export is unavailable in this environment.", + "settings.devlog_exported": "Exported developer log output.", + "settings.devtools_desc": "Sidecar health, capabilities, and audit trail.", + "settings.devtools_title": "Devtools", + "settings.diag_approval": "Approval: {mode} ({ms}ms)", + "settings.diag_config_path": "Config path: {path}", + "settings.diag_daemon_url": "Daemon: {url}", + "settings.diag_default": "default", + "settings.diag_health_port": "Health port: {port}", + "settings.diag_healthy_ms": "Healthy: {ms}ms", + "settings.diag_host_token_source": "Host token source: {source}", + "settings.diag_last_attempt": "Last attempt: {time}", + "settings.diag_load_sessions_ms": "Load sessions: {ms}ms", + "settings.diag_opencode_binary": "OpenCode binary: {binary}", + "settings.diag_opencode_url": "OpenCode: {url}", + "settings.diag_pending_permissions_ms": "Pending permissions: {ms}ms", + "settings.diag_pid": "PID: {pid}", + "settings.diag_providers_ms": "Providers: {ms}ms", + "settings.diag_read_only": "Read-only: {value}", + "settings.diag_reason": "Reason: {reason}", + "settings.diag_runtime_workspace": "Runtime workspace: {id}", + "settings.diag_selected_workspace": "Selected workspace: {id}", + "settings.diag_sidecar": "Sidecar: {info}", + "settings.diag_started": "Started: {time}", + "settings.diag_token_source": "Token source: {source}", + "settings.diag_total_ms": "Total: {ms}ms", + "settings.diag_version": "Version: {version}", + "settings.diag_workspaces": "Workspaces: {count}", + "settings.diagnostics_unavailable": "Diagnostics unavailable.", + "settings.disable_developer_mode": "Disable Developer Mode", + "settings.disabled": "Disabled", + "settings.disconnect": "Disconnect", + "settings.disconnect_confirm_suffix": "Disconnect {resolved}? This removes stored API keys or OAuth credentials for this provider.", + "settings.disconnect_server": "Disconnect server", + "settings.disconnected_prefix": "Disconnected {resolved}.", + "settings.disconnecting": "Disconnecting...", + "settings.docker_containers_desc": "Force-remove Docker containers launched by OpenWork", + "settings.docker_containers_title": "OpenWork Docker containers", + "settings.docker_requires_desktop": "Docker cleanup requires the desktop app", + "settings.done": "Done", + "settings.downloading_bytes": "Downloading {downloaded}", + "settings.downloading_progress": "Downloading {downloaded} / {total} ({percent}%)", + "settings.enable_developer_mode": "Enable Developer Mode", + "settings.enable_exa": "Enable Exa web search", + "settings.enable_exa_desc": "Applies the next time OpenCode is started by OpenWork. Off by default.", + "settings.enabled": "Enabled", + "settings.engine_bundled": "Bundled (recommended)", + "settings.engine_bundled_hint": "Bundled engine is the most reliable option. Use System", + "settings.engine_custom_binary": "Custom binary", + "settings.engine_desc": "Choose how OpenCode runs locally.", + "settings.engine_runtime_label": "Engine runtime", + "settings.engine_source": "Engine source", + "settings.engine_source_debug": "Engine source", + "settings.engine_system_path": "System install (PATH)", + "settings.engine_title": "Engine", + "settings.exa_restart_hint": "Restart OpenCode after changing this setting.", + "settings.export": "Export", + "settings.export_failed": "Failed to export runtime report.", + "settings.export_unavailable": "Export is unavailable in this environment.", + "settings.exported_debug_report": "Exported runtime report JSON.", + "settings.failed": "Failed", + "settings.failed_open_providers": "Failed to open providers", + "settings.feedback_badge": "We read every message", + "settings.feedback_desc": "Tell us what feels great and what feels rough. Feedback goes straight to the team and helps us prioritize what ships next.", + "settings.feedback_title": "Help shape OpenWork", + "settings.group_global": "Global", + "settings.group_workspace": "Workspace", + "settings.hide_titlebar": "Hide titlebar", + "settings.hide_titlebar_desc": "Hide the window titlebar. Useful for tiling window", + "settings.join_discord": "Join Discord", + "settings.language": "Language", + "settings.language.description": "Choose your preferred language", + "settings.last_error": "Last error", + "settings.last_stderr": "Last stderr", + "settings.last_stdout": "Last stdout", + "settings.loading_providers": "Loading providers...", + "settings.logs_on_host": "Logs are available on the host.", + "settings.managed_by_env": "Managed by env", + "settings.messaging_bridge_service": "Messaging bridge service.", + "settings.messaging_section_desc": "Manage Telegram/Slack identities and bindings in the Identities tab.", + "settings.messaging_section_title": "Messaging", + "settings.model": "Model", + "settings.model_behavior": "Model behavior", + "settings.model_behavior_desc": "Open the default model picker to choose reasoning profiles when they are available.", + "settings.model_default": "Default", + "settings.model_description": "Defaults + thinking controls for runs.", + "settings.model_description_default": "Choose from your configured providers. This selection will be used for new sessions.", + "settings.model_description_session": "Choose from your configured providers. This selection applies to your next message.", + "settings.model_fallback": "Fallback", + "settings.model_reasoning": "Reasoning", + "settings.model_section_desc": "Pick the default chat model and review how it reasons.", + "settings.model_title": "Model", + "settings.no_access": "no access", + "settings.no_active_workspace": "No active local workspace.", + "settings.no_providers_connected": "No providers connected yet.", + "settings.no_audit_entries": "No audit entries yet.", + "settings.no_binary_selected": "No binary selected.", + "settings.no_custom_path_set": "No custom path set", + "settings.no_project_directory": "No project directory", + "settings.no_stderr": "No stderr captured yet.", + "settings.no_stdout": "No stdout captured yet.", + "settings.no_worker_directory": "No project directory", + "settings.no_worker_path": "No worker path available", + "settings.nuke_confirm_dev": "This is irreversible. It WILL delete all OpenWork data for this dev build and all isolated OpenCode dev config, auth, cache, data, and state, then quit OpenWork. Continue?", + "settings.nuke_confirm_prod": "This is irreversible. It WILL delete all OpenWork data for this dev build and all isolated OpenCode dev config, auth, cache, data, and state, then quit OpenWork. Continue?", + "settings.nuke_failed": "Failed to remove OpenWork and OpenCode state.", + "settings.nuke_hint": "Use this only when you want to fully reset the desktop app and its OpenCode runtime state.", + "settings.nuke_success": "Removed OpenWork and OpenCode state. OpenWork is closing...", + "settings.off": "Off", + "settings.offline": "Offline", + "settings.on": "On", + "settings.open_deeplink_action": "Opening...", + "settings.open_deeplink_button": "Hide", + "settings.open_deeplink_desc": "Paste an OpenWork deeplink or share URL to open it.", + "settings.open_deeplink_failed": "Failed to open deeplink", + "settings.open_deeplink_title": "Open Deeplink", + "settings.opencode_cache": "OpenCode cache", + "settings.opencode_cache_description": "Repairs cached data used to start the engine. Safe to run.", + "settings.opencode_engine_desc": "Local runtime for agents, tools, and model providers.", + "settings.opencode_engine_label": "OpenCode engine", + "settings.opencode_engine_sidecar": "OpenCode engine", + "settings.opencode_engine_sidecar_desc": "Local OpenCode process managed by OpenWork.", + "settings.opencode_sdk_desc": "Browser → engine connection.", + "settings.opencode_sdk_title": "OpenCode SDK link", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "Base URL unavailable", + "settings.opening": "Open deeplink", + "settings.openwork_config_sidecar_desc": "Local OpenWork server (Bun) that hosts approvals, audit, and OpenCode lifecycle.", + "settings.openwork_diagnostics_title": "OpenWork server diagnostics", + "settings.openwork_server_desc": "Session control plane for app sync, workers, and remote", + "settings.openwork_server_label": "OpenWork server", + "settings.pending_permissions": "Pending permissions", + "settings.production_mode_badge": "Production", + "settings.provider_default_desc": "Use the model's built-in default reasoning behavior.", + "settings.provider_default_label": "Provider default", + "settings.provider_source_config": "Config", + "settings.provider_source_custom": "Custom", + "settings.provider_source_env": "Environment", + "settings.providers_desc": "Connect services for models and tools.", + "settings.providers_title": "Providers", + "settings.quit_hint": "OpenWork quits immediately after cleanup so the next launch starts from a blank local state for this mode.", + "settings.recent_events": "Recent events", + "settings.reconnect_failed": "Reconnect failed. Check server URL/token and try again.", + "settings.reconnect_server": "Reconnecting...", + "settings.reconnect_server_failed": "Failed to reconnect OpenWork server.", + "settings.reconnected": "Reconnected to OpenWork server.", + "settings.reconnecting": "Reconnecting...", + "settings.removing_containers": "Removing containers...", + "settings.removing_local_state": "Removing local state...", + "settings.repair_cache": "Repair cache", + "settings.repairing_cache": "Repairing cache", + "settings.report_issue": "Report an issue", + "settings.reset": "Reset", + "settings.reset_app_data": "Reset app data", + "settings.reset_app_data_description": "More aggressive. Clears OpenWork cache + app data.", + "settings.reset_app_data_title": "Reset app data", + "settings.reset_app_data_warning": "Clears OpenWork cache and app data on this device.", + "settings.reset_button": "Reset", + "settings.reset_cancel": "Cancel", + "settings.reset_config_defaults": "Resetting...", + "settings.reset_config_failed": "Failed to reset app config.", + "settings.reset_confirm_button": "Reset & Restart", + "settings.reset_confirmation_hint": "Type {resetWord} to confirm. OpenWork will restart.", + "settings.reset_confirmation_label": "Confirmation", + "settings.reset_confirmation_placeholder": "Type RESET", + "settings.reset_onboarding": "Reset onboarding", + "settings.reset_onboarding_description": "Clears OpenWork preferences and restarts the app.", + "settings.reset_onboarding_title": "Reset onboarding", + "settings.reset_onboarding_warning": "Clears OpenWork local preferences and workspace onboarding markers.", + "settings.reset_openwork_desc_dev": "With dev mode active, it only clears the isolated OpenCode dev state inside openwork-dev-data.", + "settings.reset_openwork_desc_prod": "With dev mode active, it only clears the isolated OpenCode dev state inside openwork-dev-data.", + "settings.reset_openwork_title": "Reset OpenWork + OpenCode state", + "settings.reset_recovery_desc": "Clear data or restart the setup flow.", + "settings.reset_recovery_title": "Reset & Recovery", + "settings.reset_requires_confirm": "Requires typing RESET and will restart the app.", + "settings.reset_startup": "Reset default startup mode", + "settings.reset_startup_pref": "Reset startup preference", + "settings.reset_stop_active_runs": "Stop active runs before resetting.", + "settings.resetting": "Resetting...", + "settings.restart_blocked_message": "OpenWork needs to restart to finish this update. To avoid interrupting your current work, install is paused until your active runs finish or you stop them.", + "settings.restart_failed": "Restart failed. Check logs and try again.", + "settings.restart_opencode": "Restart OpenCode", + "settings.restart_openwork_server": "Restart OpenWork server", + "settings.restart_server_failed": "Failed to restart local server.", + "settings.restarted": "Restarted local server.", + "settings.restarting": "Restarting…", + "settings.restart_succeeded_template": "Restarted {service}.", + "settings.restart_failed_template": "Failed to restart {service}.", + "settings.copy_logs": "Copy logs", + "settings.copied_service_logs": "Copied {service} logs.", + "settings.no_logs_captured": "No logs captured yet.", + "settings.copied_developer_log": "Copied developer log.", + "settings.exported_developer_log": "Exported developer log.", + "settings.cleared_developer_log": "Cleared developer log.", + "settings.developer_log_title": "Developer log stream", + "settings.developer_log_desc": "App, workspace, session, and perf events captured while Developer Mode is on.", + "settings.developer_log_count": "Showing the latest {count} retained records.", + "settings.developer_log_empty": "No developer logs captured yet.", + "settings.services_section_title": "Services", + "settings.services_section_desc": "Local services that power this OpenWork session. Each service has its own restart and logs.", + "settings.activity_section_title": "Activity", + "settings.activity_section_desc": "Audit trail and recent runtime events.", + "settings.tools_section_title": "Tools", + "settings.tools_section_desc": "Probes and binary pickers for diagnosing local execution.", + "settings.recovery_section_title": "Reset & recovery", + "settings.recovery_section_desc": "Roll back state without quitting OpenWork.", + "settings.danger_section_title": "Danger zone", + "settings.danger_section_desc": "Irreversible actions. Use only when you understand the consequences.", + "settings.clear_button": "Clear", + "settings.copy_button": "Copy", + "settings.export_button": "Export", + "settings.copy_log_button": "Copy log", + "settings.export_log_button": "Export .log", + "settings.reveal_config": "Reveal config", + "settings.reveal_config_failed": "Failed to reveal workspace config.", + "settings.reveal_config_requires_desktop": "Reveal config requires the desktop app", + "settings.revealed_workspace_config": "Revealed workspace config.", + "settings.run_sandbox_probe": "Running probe...", + "settings.running_probe": "Running probe...", + "settings.runtime_applies_hint": "Applies the next time the engine starts or reloads.", + "settings.runtime_debug_desc": "Readable diagnostics snapshot with one-click export.", + "settings.runtime_debug_title": "Runtime debug report", + "settings.runtime_desc": "Status for your local engine and OpenWork server.", + "settings.runtime_direct": "Direct (OpenCode)", + "settings.runtime_title": "Runtime", + "settings.sandbox_error": "Error", + "settings.sandbox_export_hint": "Use Export in Runtime debug report above to", + "settings.sandbox_probe_desc": "Runs a temporary Docker sandbox startup check and", + "settings.sandbox_probe_errors": "Sandbox probe completed with errors.", + "settings.sandbox_probe_failed": "Sandbox probe failed.", + "settings.sandbox_probe_success": "Sandbox probe succeeded. Export the debug report for support.", + "settings.sandbox_probe_title": "Sandbox probe", + "settings.sandbox_ready": "Ready", + "settings.sandbox_requires_desktop": "Sandbox probe requires desktop app", + "settings.sandbox_result": "Result: {status}", + "settings.sandbox_run_id": "Run ID: {id}", + "settings.sandbox_stop_runs_hint": "Stop active runs before probing", + "settings.search_models": "Search models…", + "settings.select_binary": "Select OpenCode binary", + "settings.select_workspace_first": "Select a local workspace before revealing config.", + "settings.send_feedback": "Send feedback", + "settings.service_restarts_desc": "Restart a specific service. The result is shown next to the button you press.", + "settings.service_restarts_title": "Service restarts", + "settings.session_model": "Model", + "settings.show_model_reasoning": "Show model reasoning", + "settings.show_model_reasoning_desc": "Expand reasoning traces in the UI when a model exposes them.", + "settings.showing_models": "Showing {count} of {total}", + "settings.sidecar_config_unavailable": "Sidecar config unavailable", + "settings.startup": "Startup", + "settings.startup_local": "Start local server", + "settings.startup_not_set": "Connect to server", + "settings.startup_remote_warning": "Startup preference is currently remote. Engine settings", + "settings.startup_reset_hint": "This clears your saved preference and shows the connection", + "settings.startup_server": "Connect to server", + "settings.startup_title": "Startup", + "settings.stop_local_server": "Stop local server", + "settings.stop_runs_before_cleanup": "Stop active runs before cleanup", + "settings.stop_runs_before_reset_config": "Stop active runs before resetting config", + "settings.stop_runs_to_reset": "Stop active runs to reset", + "settings.switch": "Switch", + "settings.tab_advanced": "Advanced", + "settings.tab_appearance": "Appearance", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "Debug", + "settings.tab_description_advanced": "Inspect runtime health, connection state, and developer-facing controls.", + "settings.tab_description_appearance": "Adjust how OpenWork looks across desktop, system theme, and app chrome.", + "settings.tab_description_debug": "Review runtime diagnostics, logs, and low-level debugging utilities.", + "settings.tab_description_den": "Manage your OpenWork Cloud connection, hosted workers, and workspace access.", + "settings.tab_description_extensions": "Manage MCP apps and OpenCode plugins for this workspace.", + "settings.tab_description_general": "Connect providers, choose the default model, authorize folders, and control the selected OpenWork workspace plus its runtime connection.", + "settings.environment.add_button": "Add variable", + "settings.environment.add_title": "Add environment variable", + "settings.environment.cancel": "Cancel", + "settings.environment.click_to_edit": "Click to edit", + "settings.environment.confirm_delete": "Delete {key}? Agents stop seeing this key after you apply changes.", + "settings.environment.close_editor": "Close editor", + "settings.environment.delete": "Delete", + "settings.environment.delete_title": "Delete environment variable", + "settings.environment.delete_variable": "Delete {key}", + "settings.environment.deleting": "Deleting…", + "settings.environment.description": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device; changes become available after you apply them.", + "settings.environment.edit_title": "Edit environment variable", + "settings.environment.empty_body": "Add keys like ANTHROPIC_API_KEY, GOOGLE_API_KEY, ELEVENLABS_API_KEY, or GITHUB_TOKEN for services your agents and MCP servers need.", + "settings.environment.empty_title": "No environment variables yet", + "settings.environment.empty_value": "(empty)", + "settings.environment.footer_hint": "OPENWORK_ and OPENCODE_ keys are reserved for app/runtime wiring. Configure OpenCode runtime settings from your shell.", + "settings.environment.override_hint": "Environment variables set before OpenWork starts take precedence over values saved here.", + "settings.environment.hide": "Hide", + "settings.environment.hide_value": "Hide value for {key}", + "settings.environment.key_hint": "Letters, digits, and underscores. Cannot start with a digit.", + "settings.environment.key_label": "Key", + "settings.environment.loading": "Loading…", + "settings.environment.remote_workspace_hint": "This workspace is remote. Local environment variables are hidden here; use cloud LLM Providers or configure the worker host directly.", + "settings.environment.apply_button": "Apply changes", + "settings.environment.apply_blocked_active_tasks": "Stop running tasks before applying environment changes.", + "settings.environment.apply_confirm_body": "OpenWork will restart local agents so they can use the latest environment. Running local tasks may stop.", + "settings.environment.apply_no_local_workspace": "OpenWork is not connected to a local workspace.", + "settings.environment.apply_pending_body": "Apply changes to restart local agents and make the latest values available.", + "settings.environment.apply_pending_body_manual": "Restart local agents to make the latest values available.", + "settings.environment.apply_pending_title": "Changes are saved, not active yet", + "settings.environment.apply_refresh_failed": "Changes are active, but OpenWork status did not refresh. Reopen the app if it looks stale.", + "settings.environment.apply_success": "Environment changes are active.", + "settings.environment.apply_title": "Apply environment changes?", + "settings.environment.apply_unavailable": "Apply changes is only available in the desktop app.", + "settings.environment.applying": "Applying…", + "settings.environment.restart_required": "Saved. Apply changes to make the update available.", + "settings.environment.reveal": "Reveal", + "settings.environment.reveal_value": "Reveal value for {key}", + "settings.environment.save": "Save", + "settings.environment.saving": "Saving…", + "settings.environment.title": "Environment variables", + "settings.environment.validation_duplicate": "A variable with this name already exists.", + "settings.environment.validation_empty": "Name is required.", + "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", + "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", + "settings.environment.value_label": "Value", + "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", + "settings.tab_description_messaging": "Configure router identities and inbox behavior from workspace settings.", + "settings.tab_description_model": "Tune the default model, runtime behavior, and assistant output settings.", + "settings.tab_description_recovery": "Repair migration state, reset workspace defaults, and recover local settings.", + "settings.tab_description_skills": "Browse, edit, and install skills without leaving settings.", + "settings.tab_description_updates": "Keep the app current with quiet background checks and install controls.", + "settings.tab_environment": "Environment", + "settings.tab_extensions": "Extensions", + "settings.tab_general": "Settings", + "settings.tab_messaging": "Messaging", + "settings.tab_model": "Model", + "settings.tab_recovery": "Recovery", + "settings.tab_skills": "Skills", + "settings.tab_updates": "Updates", + "settings.theme_dark": "Dark", + "settings.theme_light": "Light", + "settings.theme_system": "System", + "settings.theme_system_hint": "System mode follows your OS preference automatically.", + "settings.toolbar_ready_to_install": "Ready to install", + "settings.update": "Update", + "settings.update_available": "Update available: v", + "settings.update_available_version": "Update available: v{version}", + "settings.update_check_button": "Check", + "settings.update_check_failed": "Update check failed", + "settings.update_checking": "Checking...", + "settings.update_download_button": "Download", + "settings.update_downloading": "Downloading...", + "settings.update_error": "Update check failed", + "settings.update_install_button": "Install & Restart", + "settings.update_last_checked": "Last checked {time}", + "settings.update_published": "Published {date}", + "settings.update_ready": "Ready to install: v", + "settings.update_ready_version": "Ready to install: v{version}", + "settings.update_uptodate": "Up to date", + "settings.updates": "Updates", + "settings.updates_desc": "Keep OpenWork up to date.", + "settings.updates_desktop_only": "Updates are only available in the desktop app.", + "settings.updates_not_supported": "Updates are not supported in this environment.", + "settings.updates_title": "Updates", + "settings.version": "Version", + "settings.versions_desc": "Sidecar + desktop build info.", + "settings.versions_title": "Versions", + "settings.window_appearance_desc": "Customize window appearance.", + "settings.worker_id_label": "Worker {id}", + "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "Workspace config", + "settings.workspace_debug_events_label": "Workspace debug events", + "settings.workspace_fallback_name": "Workspace", + "share.active_cloud_org": "Active Cloud org", + "share.back_hint": "Back to share options", + "share.chooser_subtitle": "Choose how you want to share this workspace.", + "share.close_hint": "Close", + "share.cloud_signin_note": "OpenWork Cloud opens in your browser and returns here after sign-in.", + "share.collaborator_hint": "Routine access without permission approvals.", + "share.connect_messaging_desc": "Use this workspace from Slack, Telegram, and others.", + "share.connect_messaging_title": "Connect messaging", + "share.connection_details_label": "Connection details", + "share.copy_hint": "Copy", + "share.copy_link_hint": "Copy link", + "share.create_template_link": "Create template link", + "share.credentials_disabled_hint": "Enable remote access and click Save to restart the worker and reveal the live connection details for this workspace.", + "share.field_password": "Password", + "share.field_worker_url": "Worker URL", + "share.hide_password": "Hide password", + "share.included_in_template": "Included in this template", + "share.option_access_desc": "Reveal the live connection details needed to reach this running workspace from another machine.", + "share.option_access_title": "Access workspace remotely", + "share.option_public_desc": "Create a share link anyone can use to start from this template.", + "share.option_public_title": "Public template", + "share.option_team_title": "Share with team", + "share.option_template_desc": "Package this setup so someone else can start from the same environment.", + "share.optional_collaborator": "Optional collaborator access", + "share.public_intro": "Share this workspace as a public template link.", + "share.publishing": "Publishing...", + "share.regenerate_link": "Regenerate link", + "share.remote_access_desc": "Off by default. Turn this on only when you want this worker reachable from another machine.", + "share.remote_access_disabled": "Remote access is currently disabled.", + "share.remote_access_enabled": "Remote access is currently enabled.", + "share.remote_access_title": "Remote access", + "share.remote_save": "Save", + "share.remote_save_busy": "Saving...", + "share.reveal_password": "Reveal password", + "share.save_to_team": "Save to team", + "share.saving": "Saving...", + "share.setup": "Setup", + "share.sign_in_to_share": "Sign in to share with team", + "share.subtitle_access": "Reveal the live connection details needed to reach this workspace from another machine.", + "share.team_intro": "Save this template to your active OpenWork Cloud organization so teammates can open it later from Cloud settings.", + "share.template_intro": "Share a reusable setup without granting live access to this running workspace.", + "share.template_item_config": "Commands and config", + "share.template_item_config_desc": "Reusable commands plus OpenWork/OpenCode config.", + "share.template_item_settings": "Workspace settings", + "share.template_item_settings_desc": "The shared workspace profile and default behavior.", + "share.template_item_skills": "Included skills", + "share.template_item_skills_desc": "Custom skills saved in this workspace.", + "share.template_name_label": "Template name", + "share.title": "Share workspace", + "share.view_access": "Access workspace remotely", + "share.warning_basic": "Share with trusted people only. These credentials grant live access to this workspace.", + "share.warning_full": "These credentials grant live access to this workspace. Sharing this workspace remotely may allow anyone with access to your network to control your worker.", + "share.workspace_fallback": "Workspace", + "share.workspace_template_desc": "Share the core setup and workspace defaults.", + "share.workspace_template_title": "Workspace template", + "share_skill_destination.add_to_workspace": "Add skill to workspace", + "share_skill_destination.adding": "Adding skill...", + "share_skill_destination.confirm_busy": "Adding skill...", + "share_skill_destination.confirm_button": "Add skill to workspace", + "share_skill_destination.connect_remote": "Connect remote workspace", + "share_skill_destination.connect_remote_desc": "Attach an OpenWork host, then choose it from the list to import this skill.", + "share_skill_destination.connect_remote_hint": "Attach an OpenWork host, then choose it from the list to import this skill.", + "share_skill_destination.create_worker": "Create new workspace", + "share_skill_destination.create_worker_desc": "Open the workspace setup flow, then add this skill after the new workspace is ready.", + "share_skill_destination.create_worker_hint": "Open the workspace setup flow, then add this skill after the new workspace is ready.", + "share_skill_destination.current_badge": "Current", + "share_skill_destination.existing_workers": "Existing workspaces", + "share_skill_destination.fallback_skill_name": "Shared skill", + "share_skill_destination.footer_idle": "Choose a workspace to continue.", + "share_skill_destination.footer_selected": "Selected workspace:", + "share_skill_destination.local_badge": "Local", + "share_skill_destination.more_options": "More options", + "share_skill_destination.new_destination": "New destination", + "share_skill_destination.no_workers": "No workspaces are ready yet. Create one or connect a remote workspace to install this skill.", + "share_skill_destination.remote_badge": "Remote", + "share_skill_destination.sandbox_badge": "Sandbox", + "share_skill_destination.selected_badge": "Selected", + "share_skill_destination.selected_hint": "Selected. Review the destination below, then confirm.", + "share_skill_destination.skill_label": "Shared skill", + "share_skill_destination.subtitle": "Choose an existing workspace or create a new one before importing this shared skill.", + "share_skill_destination.title": "Where should this skill go?", + "share_skill_destination.trigger_label": "Trigger", + "sidebar.active": "Active", + "sidebar.add_workspace": "Add new workspace", + "sidebar.collapse": "Collapse", + "sidebar.connect_remote": "Connect remote", + "sidebar.delete_session": "Delete session", + "sidebar.drag_reorder": "Drag to reorder", + "sidebar.edit_connection": "Edit connection", + "sidebar.expand": "Expand", + "sidebar.import_config": "Import config", + "sidebar.needs_attention": "Needs attention", + "sidebar.new_worker": "New worker", + "sidebar.no_workspaces": "No workspaces in this session yet. Add one to get started.", + "sidebar.progress": "Progress", + "sidebar.show_fewer": "Show fewer", + "sidebar.show_more": "Show {count} more", + "sidebar.stop_sandbox": "Stop sandbox", + "sidebar.switch": "Switch", + "sidebar.test_connection": "Test connection", + "skills.add_custom_repo": "Add custom GitHub repo", + "skills.add_git_repo": "Add git repo", + "skills.add_openwork_hub": "Add OpenWork Hub", + "skills.available_from_hub": "Available from Hub", + "skills.catalog_search_placeholder": "Search installed, team, and hub skills", + "skills.cloud_add_skill": "Add skill", + "skills.cloud_choose_org_detail": "Use the Cloud panel to pick your active org, then refresh this list.", + "skills.cloud_choose_org_hint": "Choose an organization in Settings → Cloud to load team skills.", + "skills.cloud_footer_label": "Team", + "skills.cloud_hub_label": "Hub: {name}", + "skills.cloud_install_need_server": "Connect to an OpenWork server with skills write access to install team skills on this worker.", + "skills.cloud_installed": "Installed {name} on this worker.", + "skills.cloud_installed_as": "Installed as {name}", + "skills.cloud_installing": "Installing {title}…", + "skills.cloud_installing_short": "Installing", + "skills.cloud_no_search_matches": "No skills match that search.", + "skills.cloud_org_empty": "No organization skills are available yet.", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "Failed to load organization skills.", + "skills.cloud_refresh": "Refresh team skills", + "skills.cloud_section_subtitle": "Skills shared with you through OpenWork Cloud — including team skill hubs you can access.", + "skills.cloud_section_title": "From your organization", + "skills.cloud_shared_org": "Org", + "skills.cloud_shared_private": "Private", + "skills.cloud_shared_public": "Public", + "skills.cloud_sign_in": "Sign in to Cloud", + "skills.cloud_sign_in_hint": "Sign in to OpenWork Cloud to browse team and org skills.", + "skills.cloud_status_installed": "Installed", + "skills.cloud_status_update": "Update available", + "skills.cloud_update_skill": "Update", + "skills.cloud_updated": "Updated {name} on this worker.", + "skills.cloud_updating": "Updating {title}…", + "skills.cloud_removed": "Removed local cloud skill {name}.", + "skills.copy_link_failed": "Failed to copy link", + "skills.create_in_chat": "Create skill in chat", + "skills.desktop_required": "Skill management requires the desktop app.", + "skills.enter_plugin_name": "Enter a plugin package name.", + "skills.failed_load_active": "Failed to load active plugins.", + "skills.failed_load_opencode": "Failed to load opencode.json", + "skills.failed_parse_opencode": "Failed to parse opencode.json", + "skills.failed_to_load": "Failed to load skills", + "skills.failed_update_opencode": "Failed to update opencode.json", + "skills.filter_all": "All", + "skills.filter_cloud": "Team", + "skills.filter_hub": "Hub", + "skills.filter_installed": "Installed", + "skills.from_repo": "From {owner}/{repo}", + "skills.github_repo_hint": "Enter a GitHub repo in owner/repo format.", + "skills.host_mode_only": "Local workspace only", + "skills.host_only_error": "Skill management requires a local workspace or connected OpenWork server.", + "skills.hub_desc": "Browse shared skills from GitHub-backed hubs and add them to this worker.", + "skills.hub_label": "Hub", + "skills.import": "Import", + "skills.import_failed": "Import failed ({status})", + "skills.import_local": "Import local skill", + "skills.import_local_hint": "Copy an existing skill folder into this workspace.", + "skills.import_local_skill": "Import local skill", + "skills.imported": "Imported.", + "skills.install": "Install", + "skills.install_failed": "Skill install failed.", + "skills.install_name_title": "Install {name}", + "skills.install_skill_creator": "Install skill creator", + "skills.install_skill_creator_hint": "This skill allows you to create other skills from within the chat.", + "skills.installed": "Installed skills", + "skills.installed_desc": "Installed skills live on this worker and can be edited or shared.", + "skills.installed_label": "Installed", + "skills.installed_status": "Installed", + "skills.installing": "Add skill", + "skills.installing_prefix": "Installing {name}…", + "skills.installing_skill_creator": "Installing skill creator...", + "skills.link_copied": "Link copied", + "skills.loading": "Loading…", + "skills.no_description": "No description yet.", + "skills.no_hub_repo_label": "No hub repo selected", + "skills.no_hub_repo_selected": "No hub skills available.", + "skills.no_hub_skills": "No hub repo selected. Add a GitHub repo to browse skills.", + "skills.no_opencode_found": "No opencode.json found yet. Add a plugin to create one.", + "skills.no_opencode_workspace": "No opencode.json in this workspace yet.", + "skills.no_skills": "No skills detected in `.opencode/skills`, `.claude/skills`, or `~/.agents/skills`.", + "skills.no_skills_found": "No skills found yet.", + "skills.owner_label": "Owner", + "skills.owner_repo_required": "Owner and repo are required.", + "skills.pick_project_first": "Pick a project folder first.", + "skills.pick_project_for_active": "Pick a project folder to load active plugins.", + "skills.pick_project_for_plugins": "Pick a project folder to manage project plugins.", + "skills.pick_workspace_first": "Pick a workspace folder first.", + "skills.plugin_already_listed": "Plugin already listed in opencode.json.", + "skills.plugin_management_host_only": "Plugin management requires the desktop app.", + "skills.plugins_host_only": "Plugins are only available in the desktop app.", + "skills.ref_label": "Ref (branch/tag/commit)", + "skills.refresh": "Refresh", + "skills.refresh_hub": "Refresh hub", + "skills.refresh_hub_title": "Refresh hub catalog", + "skills.remove_saved_repo": "Remove saved repo", + "skills.repo_label": "Repo", + "skills.reveal_failed": "Failed to open skills folder.", + "skills.reveal_folder": "Open skills folder", + "skills.reveal_folder_hint": "Open the skill directory in Finder.", + "skills.save_and_load": "Save and load", + "skills.save_failed": "Failed to save skill.", + "skills.select_skill_folder": "Select skill folder", + "skills.share_back": "Back", + "skills.share_chooser_subtitle": "Save to your OpenWork Cloud organization or publish a public install link.", + "skills.share_close": "Close", + "skills.share_copy_link": "Copy", + "skills.share_done": "Done", + "skills.share_option_public_desc": "Create a link anyone can use to install this skill.", + "skills.share_option_public_title": "Public link", + "skills.share_option_team_desc": "Add this skill to your active OpenWork Cloud organization.", + "skills.share_option_team_title": "Share with team", + "skills.share_public_create": "Create link", + "skills.share_public_creating": "Publishing…", + "skills.share_public_intro": "Publish a public link. Anyone with the URL can install this skill.", + "skills.share_public_regenerate": "Regenerate link", + "skills.share_publisher_label": "Publisher", + "skills.share_subtitle_public": "Anyone with the link can install this skill.", + "skills.share_subtitle_team": "Stored in your organization for teammates.", + "skills.share_team_choose_org": "Choose an organization in Settings → Cloud before sharing with your team.", + "skills.share_team_permissions_intro": "Upload this skill to your active OpenWork Cloud organization and decide who can see it.", + "skills.share_team_permissions_label": "Sharing Permissions", + "skills.share_team_permission_org": "Organization Only - Not in hub", + "skills.share_team_permission_private": "Private for me only", + "skills.share_team_hub_label": "Add to skill hub (optional)", + "skills.share_team_hub_none": "Organization only — not in a hub", + "skills.share_team_hubs_loading": "Loading hubs…", + "skills.share_team_intro": "Save this skill to your active organization so teammates can install it from Cloud.", + "skills.share_team_org_fallback": "Active Cloud org", + "skills.share_team_save": "Save to team", + "skills.share_team_saving": "Saving…", + "skills.share_team_upload_and_save": "Upload and save", + "skills.share_team_uploading": "Uploading…", + "skills.share_team_sign_in": "Sign in to share with team", + "skills.share_team_sign_in_hint": "OpenWork Cloud opens in your browser. Return here after signing in.", + "skills.share_team_success": "Saved to {org}. Teammates can install it from your organization skills.", + "skills.share_team_uploaded_success": "Uploaded to {org}. Cloud skills will refresh for your account.", + "skills.share_title": "Share skill", + "skills.shown_count": "{count} shown", + "skills.skill_creator_already_installed": "Skill creator is already installed.", + "skills.skill_creator_installed": "Skill creator installed.", + "skills.skill_load_failed": "Failed to load skill.", + "skills.source_label": "Source", + "skills.subtitle": "Manage skills for this workspace.", + "skills.title": "Skills", + "skills.trigger_label": "Trigger: {trigger}", + "skills.uninstall": "Uninstall", + "skills.uninstall_failed": "Failed to uninstall skill.", + "skills.uninstall_title": "Uninstall skill?", + "skills.uninstall_warning": "This will permanently delete the `{name}` skill from your workspace.", + "skills.uninstalled": "Skill removed.", + "skills.unknown_error": "Unknown error", + "skills.worker_profile_desc": "Skills are the core abilities of this worker. Discover them from Hub, manage what is installed, and create new ones directly in chat.", + "status.back": "Back to previous screen", + "status.connected": "Connected", + "status.connecting": "Connecting", + "status.creating_task": "Creating new task", + "status.creating_workspace": "Creating workspace", + "status.developer_mode": "Developer mode", + "status.disconnected": "Disconnected", + "status.disconnected_hint": "Open settings to reconnect", + "status.disconnected_label": "Disconnected", + "status.disconnecting": "Disconnecting", + "status.docs": "Docs", + "status.feedback": "Feedback", + "status.idle": "Idle", + "status.installing_opencode": "Installing OpenCode", + "status.limited_hint": "Reconnect to restore full OpenWork features", + "status.limited_mcp_hint": "{count} MCP connected · reconnect for full features", + "status.limited_mode": "Limited Mode", + "status.live": "Live", + "status.loading_session": "Loading session", + "status.mcp_connected": "{count} MCP connected", + "status.open_docs": "Open documentation", + "status.openwork_ready": "OpenWork Ready", + "status.providers_connected": "{count} provider{plural} connected", + "status.ready_for_tasks": "Ready for new tasks", + "status.reloading_engine": "Reloading engine", + "status.restarting_engine": "Restarting engine", + "status.running": "Running", + "status.send_feedback": "Send feedback", + "status.settings": "Settings", + "status.starting_engine": "Starting engine", + "system.cache_repair_requires_desktop": "Cache repair requires the desktop app.", + "system.docker_cleanup_requires_desktop": "Docker cleanup requires the desktop app.", + "system.reload_body_agents": "OpenCode loads agents at startup. Reload the engine to make updated agents available.", + "system.reload_body_commands": "OpenCode loads commands at startup. Reload the engine to make updated commands available.", + "system.reload_body_config": "OpenCode reads opencode.json at startup. Reload the engine to apply configuration changes.", + "system.reload_body_default": "OpenWork detected changes that require reloading the OpenCode instance.", + "system.reload_body_mcp": "OpenCode loads MCP servers at startup. Reload the engine to activate the new connection.", + "system.reload_body_mixed": "OpenWork detected OpenCode configuration changes. Reload the engine to apply them.", + "system.reload_body_plugins": "OpenCode loads npm plugins at startup. Reload the engine to apply opencode.json changes.", + "system.reload_body_skills": "OpenCode can cache skill discovery/state. Reload the engine to make newly installed skills available.", + "system.reload_failed": "Failed to reload the engine.", + "system.reload_required": "Reload required", + "system.reload_unavailable": "Reload is unavailable for this worker.", + "system.stop_active_runs_before_reset": "Stop active runs before resetting.", + "system.stop_runs_before_update": "Stop active runs before installing an update.", + "system.updates_not_supported": "Updates are not supported in this environment.", + "time.hours_ago": "{count}h ago", + "time.just_now": "just now", + "time.minutes_ago": "{count}m ago", + "time.seconds_ago": "{count}s ago", + "workspace.create_workspace": "Create workspace", + "workspace.empty_state_body": "Create or connect a workspace to get started.", + "workspace.loading_tasks": "Loading tasks...", + "workspace.local_badge": "Local", + "workspace.new_task_inline": "+ New task", + "workspace.no_tasks": "No tasks yet.", + "workspace.remote_badge": "Remote", + "workspace.rename_description": "Update the name shown in the sidebar.", + "workspace.rename_label": "Workspace name", + "workspace.rename_placeholder": "Design team workspace", + "workspace.rename_title": "Edit workspace name", + "workspace.sandbox_badge": "Sandbox", + "workspace.selected": "Selected", + "workspace.switch": "Switch", + "workspace.switching_status_connecting": "Checking your connection", + "workspace.switching_status_loading": "Loading recent tasks", + "workspace.switching_status_preparing": "Getting things ready", + "workspace.switching_subtitle": "We’ll bring your recent work back.", + "workspace.switching_title": "Opening {name}", + "workspace.switching_title_unknown": "Opening workspace", + "workspace_list.add_workspace": "Add workspace", + "workspace_list.connect_remote": "Connect remote workspace", + "workspace_list.connecting": "Connecting...", + "workspace_list.delete_session": "Delete session", + "workspace_list.desktop_only_hint": "Create local workspaces in the desktop app.", + "workspace_list.edit_connection": "Edit connection", + "workspace_list.edit_name": "Edit name", + "workspace_list.hide_child_sessions": "Hide child sessions", + "workspace_list.import_config": "Import config", + "workspace_list.new_workspace": "New workspace", + "workspace_list.recover": "Recover", + "workspace_list.remove_confirm": "Remove this workspace from the sidebar? Sessions and files on disk are preserved.", + "workspace_list.remove_workspace": "Remove workspace", + "workspace_list.rename_session": "Rename session", + "workspace_list.reveal_explorer": "Reveal in Explorer", + "workspace_list.reveal_finder": "Reveal in Finder", + "workspace_list.session_actions": "Session actions", + "workspace_list.share": "Share...", + "workspace_list.show_child_sessions": "Show child sessions", + "workspace_list.show_more": "Show {count} more", + "workspace_list.show_more_fallback": "Show more", + "workspace_list.test_connection": "Test connection", + "workspace_list.workspace_fallback": "Workspace", + "workspace_list.workspace_options": "Workspace options", + "workspace_sidebar.close_sidebar": "Close sidebar", + "workspace_sidebar.collapse_sidebar": "Collapse sidebar", + "workspace_sidebar.configuration": "configuration", + "workspace_sidebar.expand_sidebar": "Expand sidebar", + "workspace_sidebar.extensions": "Extensions", + "workspace_sidebar.messaging": "Messaging", +} as const; diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts new file mode 100644 index 0000000000..da84f9ec73 --- /dev/null +++ b/apps/app/src/i18n/locales/es.ts @@ -0,0 +1,2021 @@ +/** + * Spanish translations (Español) + * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) are NOT translated + */ + +export default { + "app.compact_command_desc": "Resume esta sesión para reducir el contexto.", + "app.connection_lost": "Se ha perdido la conexión con el servidor. Recarga.", + "app.deep_link_auth_queued": "Hemos puesto en cola el deep link de autenticación de OpenWork Cloud.", + "app.deep_link_remote_queued": "Hemos puesto en cola el enlace del worker remoto. OpenWork debería intentar conectar.", + "app.error.choose_folder": "Elige una carpeta para continuar.", + "app.error.host_requires_local": "Elige un local workspace para arrancar el motor de procesamiento.", + "app.error.install_failed": "La instalación de OpenCode ha fallado. Mira los logs arriba.", + "app.error.pick_workspace_folder": "Elige una carpeta para el workspace primero.", + "app.error.remote_base_url_required": "Añade una URL del servidor para continuar.", + "app.error.tauri_required": "Esta acción necesita el runtime de la app de escritorio de OpenWork.", + "app.error_audit_load": "No se pudo cargar el audit log.", + "app.error_auth_failed": "Error de autenticación", + "app.error_auto_compact_scope": "La compactación automática del contexto solo se puede cambiar en un espacio de trabajo local o en un editable del servidor de OpenWork.", + "app.error_cloud_signin": "No se pudo completar el inicio de sesión en OpenWork Cloud.", + "app.error_command_not_resolved": "No se pudo resolver el Commando.", + "app.error_compact_empty": "Nada que compactar todavía.", + "app.error_compact_no_session": "Selecciona una sesión con mensajes antes de ejecutar /compactar.", + "app.error_compact_no_session_id": "Selecciona una sesión antes de compactar.", + "app.error_connect_first": "Conecta a este worker antes de realizar cambios del runtime.", + "app.error_connection_failed": "La conexión falló", + "app.error_connection_failed_url": "La conexión ha fallado. Comprueba la URL y el token.", + "app.error_deep_link_unrecognized": "Ese enlace no es un deep link de OpenWork reconocido ni una URL compartida.", + "app.error_desktop_signin": "El inicio de sesión de escritorio se completó, pero OpenWork Cloud no devolvió un token de sesión.", + "app.error_not_connected": "No conectado a un servidor", + "app.error_pick_local_folder": "Elige una carpeta de worker local antes de reiniciar el servidor local.", + "app.error_rate_limit": "Has superado el límite de peticiones", + "app.error_remote_access": "No se pudo actualizar el acceso remoto.", + "app.error_request_failed": "Solicitud fallida", + "app.error_reset_config": "No se pudieron restablecer los valores predeterminados de configuración de la aplicación.", + "app.error_restart_local_worker": "No se pudo reiniciar el worker local con la configuración de uso compartido actualizada.", + "app.error_runtime_changes": "No se han podido aplicar los cambios del runtime.", + "app.error_session_name_required": "El nombre de la sesión es obligatorio.", + "app.error_update_opencode_json": "No se pudo actualizar opencode.json", + "app.import_bundle_desc": "Elige cómo quieres importar este paquete.", + "app.import_shared_bundle": "Importar paquete compartido", + "app.local_disabled_reason": "Crea workspaces locales en la app de escritorio. Los workspaces remotos y compartidos siguen funcionando aquí.", + "app.local_worker_detail": "Worker local", + "app.model_behavior_desc": "Elige primero el modelo para ver los controles de comportamiento específicos del proveedor.", + "app.model_behavior_title": "Comportamiento del modelo", + "app.plugins_hint_disconnected": "El servidor de OpenWork no está disponible. Los Plugins son de solo lectura.", + "app.plugins_hint_limited": "El servidor de OpenWork necesita un token para editar Plugins.", + "app.plugins_hint_readonly": "El servidor de OpenWork es de solo lectura para Plugins.", + "app.reload_later": "Más tarde", + "app.reload_now": "Recargar ahora", + "app.reload_stop_tasks": "Recargar y detener tareas", + "app.remote_worker_detail": "Worker remoto", + "app.reset_config_ok": "Se han restaurado los ajustes por defecto de la app. Reinicia OpenWork si queda alguna configuración antigua.", + "app.shared_setup": "Configuración compartida", + "app.skill_added": "Skill añadida", + "app.skills_hint_disconnected": "El servidor de OpenWork no está disponible. Añade la URL y el token del servidor en Avanzado para gestionar Skills.", + "app.skills_hint_limited": "El servidor de OpenWork necesita un token de host para instalar o actualizar Skills. Agrégalo en Avanzado y vuelve a conectarte.", + "app.skills_hint_readonly": "El servidor de OpenWork es de solo lectura para Skills. Añade un token de host en Avanzado para poder instalarlas.", + "app.unknown_error": "Error desconocido", + "app.worker_fallback": "Worker", + "blueprint.automation_body": "Empieza con un flujo de trabajo reutilizable o escribe tu propia tarea abajo.", + "blueprint.automation_title": "¿Qué quieres automatizar?", + "blueprint.csv_session_assistant": "Puedo ayudarte a generar, limpiar, fusionar y resumir archivos CSV. ¿Qué tipo de trabajo con CSV quieres automatizar?", + "blueprint.csv_session_title": "Ideas de flujo de trabajo CSV", + "blueprint.csv_session_user": "Quiero combinar exportaciones de múltiples herramientas en un CSV limpio.", + "blueprint.empty_body": "Elige un punto de partida o simplemente escribe abajo.", + "blueprint.empty_title": "¿Qué quieres hacer?", + "blueprint.minimal_body": "Haz una pregunta sobre este espacio de trabajo o usa un prompt inicial.", + "blueprint.minimal_title": "Empezar con una tarea", + "blueprint.starter_blueprint_desc": "Diseña un flujo de trabajo repetible con Skills, Commands y pasos de handoff.", + "blueprint.starter_blueprint_prompt": "Ayúdame a diseñar un blueprint de automatización reutilizable para este espacio de trabajo. Pregunta qué conviene estandarizar y luego propón el flujo de trabajo.", + "blueprint.starter_blueprint_title": "Planificar un blueprint de automatización", + "blueprint.starter_chrome_desc": "Inicia una conversación sobre automatización del navegador de inmediato.", + "blueprint.starter_chrome_prompt": "Ayúdame a conectarme a Chrome y automatizar una tarea repetitiva.", + "blueprint.starter_chrome_title": "Automatizar Chrome", + "blueprint.starter_command_desc": "Convierte un flujo de trabajo repetido en un /command para este espacio de trabajo.", + "blueprint.starter_command_prompt": "Ayúdame a crear un /command reutilizable para este espacio de trabajo. Pregunta qué flujo de trabajo quiero automatizar y luego redacta el Command.", + "blueprint.starter_command_title": "Crear un /command reutilizable", + "blueprint.starter_connect_openai_desc": "Añade tu proveedor de OpenAI para que los modelos ChatGPT estén listos en las sesiones nuevas.", + "blueprint.starter_connect_openai_title": "Conectar ChatGPT", + "blueprint.starter_csv_desc": "Limpiar o generar datos de hojas de cálculo.", + "blueprint.starter_csv_prompt": "Ayúdame a crear o editar archivos CSV en este ordenador.", + "blueprint.starter_csv_title": "Trabajar en un CSV", + "blueprint.starter_explore_desc": "Resume los archivos y sugiere la mejor tarea para empezar.", + "blueprint.starter_explore_prompt": "Resume este espacio de trabajo, señala los archivos más importantes y sugiere la mejor tarea para empezar.", + "blueprint.starter_explore_title": "Explorar este espacio de trabajo", + "blueprint.welcome_message": "Hola, te damos la bienvenida a OpenWork.\n\nLa gente usa OpenWork para crear archivos .csv en su ordenador, conectarse a Chrome, automatizar tareas repetitivas y sincronizar contactos con Notion.\n\nPero puedes ir mucho más allá.\n\n¿Qué te apetece hacer?", + "blueprint.welcome_title": "Bienvenido a OpenWork", + "common.add": "Añadir", + "common.cancel": "Cancelar", + "common.choose": "Elegir", + "common.close": "Cerrar", + "common.default_parens": "(predeterminado)", + "common.done": "Hecho", + "common.edit": "Editar", + "common.hide": "Ocultar", + "common.install": "Instalar", + "common.navigate": "navegar", + "common.next": "Siguiente", + "common.off": "Desactivado", + "common.on": "Activado", + "common.path": "Ruta", + "common.question": "Pregunta", + "common.refresh": "Actualizar", + "common.remove": "Eliminar", + "common.reset": "Restablecer", + "common.retry": "Reintentar", + "common.save": "Guardar", + "common.select": "seleccionar", + "common.show": "Mostrar", + "common.something_went_wrong": "Algo salió mal", + "common.submit": "Enviar", + "common.unknown": "Desconocido", + "composer.agent_label": "Agente", + "composer.attach_files": "Adjuntar archivos", + "composer.attachments_unavailable": "Los archivos adjuntos no están disponibles.", + "composer.behavior_label": "Comportamiento", + "composer.configure": "Configurar", + "composer.default_agent": "Agente predeterminado", + "composer.expand_pasted": "Haz clic para expandir el texto pegado", + "composer.failed_read_attachment": "No se pudo leer el archivo adjunto", + "composer.file_exceeds_limit": "{name} supera el límite de 8 MB.", + "composer.file_kind": "Archivo", + "composer.file_too_large_encoding": "{name} es demasiado grande después de codificarlo. Prueba con una imagen más pequeña.", + "composer.image_kind": "Imagen", + "composer.inserted_links_unsupported": "Enlaces insertados para archivos no compatibles.", + "composer.loading_agents": "Cargando agentes...", + "composer.loading_commands": "Cargando Commands...", + "composer.mcps_label": "MCPs", + "composer.no_commands": "No se encontraron Commands.", + "composer.no_matches": "No se encontraron coincidencias.", + "composer.placeholder": "Describe tu tarea...", + "composer.remote_worker_paste_warning": "Este es un worker remoto. Los sandboxes también son remotos. Para compartir archivos con él, súbelos a la carpeta compartida de la barra lateral.", + "composer.run_task": "Ejecutar tarea", + "composer.skill_source": "Skill", + "composer.stop": "Detener", + "composer.tools_label": "Commands, Skills y MCPs", + "composer.unsupported_attachment_type": "Tipo de archivo adjunto no admitido.", + "composer.upload_failed_local_links": "No se pudieron subir los archivos a la carpeta compartida. En su lugar, se insertaron enlaces locales.", + "composer.upload_to_shared_folder": "Subir a la carpeta compartida", + "composer.uploaded_multiple_files": "Se subieron {count} archivos a la carpeta compartida y se añadieron enlaces.", + "composer.uploaded_single_file": "Se subió {name} a la carpeta compartida y se agregó un enlace.", + "config.auto_reload_desc": "Recargar automáticamente después de que los agentes/skills/commands/config cambien (solo cuando están inactivos).", + "config.auto_reload_title": "Recarga automática (local)", + "config.auto_reload_unavailable": "Disponible para espacios de trabajo locales en la app de escritorio.", + "config.collaborator_token_disabled_hint": "Almacenado de antemano para compartirlo de forma remota, pero el acceso remoto está actualmente deshabilitado.", + "config.collaborator_token_label": "Token de colaborador", + "config.collaborator_token_remote_hint": "Acceso remoto habitual para móviles o portátiles que se conectan a este servidor.", + "config.connection_failed": "La conexión falló.", + "config.connection_failed_check": "La conexión ha fallado. Comprueba la URL del host y el token.", + "config.connection_status_updated": "Estado de conexión actualizado.", + "config.connection_successful": "Conexión exitosa.", + "config.copied": "Copiado", + "config.copy": "Copiar", + "config.desktop_only_hint": "Algunas funciones de configuración (uso compartido del servidor local + puente de mensajería) requieren la app de escritorio.", + "config.diagnostics_desc": "Copia el estado del runtime, ya depurado, para diagnosticar problemas.", + "config.diagnostics_title": "Paquete de diagnóstico", + "config.enable_auto_reload_first": "Activa primero la recarga automática", + "config.engine_reload_desc": "Reinicia el servidor de OpenCode de este espacio de trabajo.", + "config.engine_reload_title": "Recarga del motor", + "config.host_admin_token_hint": "Token interno solo para el host para la CLI de aprobaciones y las APIs de administración. No lo uses en el flujo de conexión remota de la app.", + "config.host_admin_token_label": "Token de administrador del host", + "config.host_local_only": "Solo local", + "config.host_offline": "Desconectado", + "config.host_remote_enabled": "Remoto habilitado", + "config.local_ip_hint": "Usa tu IP local en la misma red Wi-Fi para obtener la conexión más rápida.", + "config.mdns_hint": "Los nombres.local son más fáciles de recordar, pero es posible que no se resuelvan en todas las redes.", + "config.messaging_identities_desc": "Gestiona las identidades de Telegram/Slack y el enrutamiento en la pestaña Identidades.", + "config.messaging_identities_title": "Identidades de mensajería", + "config.not_set": "No establecido", + "config.owner_token_disabled_hint": "Solo es relevante después de habilitar el acceso remoto para este worker.", + "config.owner_token_label": "Token de propietario", + "config.owner_token_remote_hint": "Úsalo cuando un cliente remoto tenga que responder solicitudes de permiso o realizar acciones exclusivas del propietario.", + "config.reload_active_tasks_warning": "La recarga detendrá las tareas activas.", + "config.reload_availability_hint": "La recarga solo está disponible para workers locales o para servidores de OpenWork conectados.", + "config.reload_connect_hint": "Conecta a este worker para recargar.", + "config.reload_engine": "Recargar motor", + "config.reload_now_desc": "Aplica los cambios de configuración y vuelve a conectar tu sesión.", + "config.reload_now_title": "Recargar ahora", + "config.reloading": "Recargando...", + "config.remote_access_off_hint": "El acceso remoto está desactivado. Usa Compartir espacio de trabajo para habilitarlo antes de conectarte desde otra máquina.", + "config.resolved_worker_url": "URL del worker resuelta:", + "config.resume_sessions_desc": "Si se puso una recarga en cola mientras había tareas en ejecución, envía después un mensaje de reanudación.", + "config.resume_sessions_title": "Reanudar sesiones tras la recarga automática", + "config.server_needed_hint": "Se necesita una conexión al servidor de OpenWork para sincronizar Skills, Plugins y Commands.", + "config.server_section_desc": "Conéctate a un servidor de OpenWork. Usa la URL y un token de colaborador o de propietario proporcionado por el administrador del servidor.", + "config.server_section_title": "Servidor de OpenWork", + "config.server_sharing_desc": "Comparte estos datos con un dispositivo de confianza. Mantén el servidor en la misma red para que la configuración sea más rápida.", + "config.server_sharing_menu_hint": "Para enlaces de uso compartido por espacio de trabajo, usa Compartir... en el menú del espacio de trabajo.", + "config.server_sharing_title": "Uso compartido del servidor de OpenWork", + "config.server_url_hint": "Usa la URL que te compartió tu servidor de OpenWork. Los workers locales de escritorio reutilizan un puerto alto persistente dentro del rango 48000-51000.", + "config.server_url_input_label": "URL del servidor de OpenWork", + "config.server_url_label": "URL del servidor de OpenWork", + "config.starting_server": "Iniciando servidor…", + "config.status_connected": "Conectado", + "config.status_limited": "Limitado", + "config.status_not_connected": "No conectado", + "config.test_connection": "Probar conexión", + "config.testing": "Probando...", + "config.testing_connection": "Probando conexión...", + "config.token_hint": "Opcional. Pega un token de colaborador para el acceso habitual o un token de propietario cuando este cliente tenga que responder solicitudes de permiso.", + "config.token_label": "Token de colaborador o de propietario", + "config.token_placeholder": "Pega tu token", + "config.unavailable": "Indisponible", + "config.worker_id": "ID del worker:", + "config.workspace_config_desc": "Estos ajustes afectan al espacio de trabajo seleccionado. Las acciones exclusivas del runtime se aplican al espacio de trabajo que esté conectado en ese momento.", + "config.workspace_config_title": "Configuración del espacio de trabajo", + "config.workspace_id_prefix": "Espacio de trabajo:", + "context_panel.add_button": "Añadir", + "context_panel.add_folder_hint": "Añade una carpeta para que este espacio de trabajo pueda leer y editar archivos fuera de su directorio raíz.", + "context_panel.adding_button": "Añadiendo...", + "context_panel.always_available": "Siempre disponible", + "context_panel.authorized_folders": "Carpetas autorizadas", + "context_panel.authorized_folders_desc": "Permite que este espacio de trabajo lea y edite archivos en directorios fuera de su carpeta raíz.", + "context_panel.authorized_folders_no_access": "Conéctate a un espacio de trabajo editable del servidor de OpenWork para modificar las carpetas autorizadas.", + "context_panel.browse_button": "Navegar", + "context_panel.config_access_unavailable": "El acceso a la configuración del servidor de OpenWork no está disponible para este espacio de trabajo.", + "context_panel.config_read_only": "El servidor de OpenWork está conectado en modo de solo lectura para la configuración del espacio de trabajo.", + "context_panel.context": "Contexto", + "context_panel.folder_already_authorized": "La carpeta ya está autorizada.", + "context_panel.folders_updated": "Carpetas autorizadas actualizadas.", + "context_panel.input_placeholder": "Escribe una ruta de carpeta para autorizar...", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "Conectado", + "context_panel.mcp_disabled": "Desactivado", + "context_panel.mcp_disconnected": "Desconectado", + "context_panel.mcp_failed": "Fallido", + "context_panel.mcp_needs_auth": "Necesita autenticación", + "context_panel.mcp_register_client": "Registrar cliente", + "context_panel.no_external_folders": "No se autorizan carpetas externas", + "context_panel.no_mcp": "No hay servidores MCP cargados.", + "context_panel.no_plugins": "No hay Plugins cargados.", + "context_panel.no_server_workspace": "No hay ningún espacio de trabajo del servidor activo seleccionado.", + "context_panel.no_skills": "No hay Skills cargadas.", + "context_panel.none_yet": "Ninguno todavía.", + "context_panel.plugins": "Plugins", + "context_panel.preserving_entries": "Se conservarán {count} entradas de permisos que no son carpetas.", + "context_panel.preserving_entry": "Se conservará 1 entrada de permisos que no corresponde a una carpeta.", + "context_panel.remove_folder": "Quitar {name}", + "context_panel.saving_folders": "Guardando carpetas autorizadas...", + "context_panel.server_disconnected": "El servidor de OpenWork está desconectado.", + "context_panel.skills": "Skills", + "context_panel.working_files": "Archivos de trabajo", + "context_panel.workspace_root_available": "La raíz del espacio de trabajo ya está disponible.", + "context_panel.workspace_root_badge": "Raíz del espacio de trabajo", + "context_panel.writable_workspace_required": "Se necesita un espacio de trabajo editable del servidor de OpenWork para actualizar las carpetas autorizadas.", + "dashboard.access_token": "Token de acceso", + "dashboard.access_token_optional_hint": "Añade un token solo si el worker lo necesita.", + "dashboard.blueprints_workspace": "Blueprints", + "dashboard.blueprints_workspace_desc": "Empieza con un espacio de trabajo listo para automatización, con Skills, Commands y flujos compartidos reutilizables.", + "dashboard.change": "Cambiar", + "dashboard.choose_folder": "Elige una carpeta", + "dashboard.choose_folder_continue": "Elige una carpeta para continuar.", + "dashboard.choose_folder_next": "Comparte archivos con tu espacio de trabajo.", + "dashboard.choose_preset": "Elige preajuste", + "dashboard.chooser_local_desc": "Crea un espacio de trabajo en este dispositivo y, si quieres, empieza desde una plantilla del equipo.", + "dashboard.chooser_remote_desc": "Conéctate a un worker de OpenWork autohospedado con una URL y un token de acceso.", + "dashboard.chooser_shared_desc": "Explora los workers de Cloud compartidos con tu organización y conéctate en un solo paso.", + "dashboard.close_settings": "Cerrar ajustes", + "dashboard.cloud_signin_button": "Continuar con Cloud", + "dashboard.cloud_signin_hint": "Accede a workers remotos compartidos con tu organización.", + "dashboard.cloud_signin_next": "Después elegirás un equipo y te conectarás a un espacio de trabajo existente.", + "dashboard.cloud_signin_title": "Iniciar sesión en OpenWork Cloud", + "dashboard.cloud_worker": "Worker de Cloud", + "dashboard.commands": "Commands", + "dashboard.connect_remote_button": "Conectar en remoto", + "dashboard.connected": "Conectado", + "dashboard.connecting": "Conectando...", + "dashboard.create_local_workspace_subtitle": "Crea un espacio de trabajo en este dispositivo y, si quieres, empieza desde una plantilla del equipo.", + "dashboard.create_local_workspace_title": "Espacio de trabajo local", + "dashboard.create_remote_custom_subtitle": "Conéctate a un worker de OpenWork autohospedado.", + "dashboard.create_remote_custom_title": "Conectar remoto personalizado", + "dashboard.create_remote_workspace_confirm": "Añadir espacio de trabajo", + "dashboard.create_remote_workspace_subtitle": "Guarda un servidor de OpenWork como espacio de trabajo.", + "dashboard.create_remote_workspace_title": "Añadir espacio de trabajo remoto", + "dashboard.create_sandbox_confirm": "Crear como zona de pruebas", + "dashboard.create_shared_subtitle_signed_in": "Explora los workers de Cloud compartidos con tu organización y conéctate en un solo paso.", + "dashboard.create_shared_subtitle_signed_out": "Inicia sesión en OpenWork Cloud para acceder a los workers compartidos con tu organización.", + "dashboard.create_shared_title": "Espacios de trabajo compartidos", + "dashboard.create_workspace_confirm": "Crear espacio de trabajo", + "dashboard.create_workspace_subtitle": "Inicializa un nuevo espacio de trabajo basado en una carpeta.", + "dashboard.create_workspace_title": "Crear espacio de trabajo", + "dashboard.creating": "Creando...", + "dashboard.desktop_badge": "Desktop", + "dashboard.display_name_label": "Nombre para mostrar", + "dashboard.display_name_optional": "(opcional)", + "dashboard.docker_debug_details": "Detalles de depuración de Docker", + "dashboard.edit_remote_workspace_confirm": "Guardar conexión", + "dashboard.edit_remote_workspace_subtitle": "Actualiza los datos del servidor de OpenWork para este espacio de trabajo.", + "dashboard.edit_remote_workspace_title": "Editar conexión remota", + "dashboard.empty_workspace": "Espacio de trabajo vacío", + "dashboard.empty_workspace_desc": "Empieza con una carpeta vacía y añade lo que necesites.", + "dashboard.error_choose_org": "Elige una organización antes de abrir un espacio de trabajo.", + "dashboard.error_connect_worker": "No se pudo conectar a {name}.", + "dashboard.error_create_template": "No se pudo crear {name}.", + "dashboard.error_load_orgs": "No se pudieron cargar las organizaciones.", + "dashboard.error_load_shared_workspaces": "No se pudieron cargar los espacios de trabajo compartidos.", + "dashboard.error_workspace_not_ready": "Este espacio de trabajo aún no está listo para conectarse. Inténtalo de nuevo en un momento.", + "dashboard.import_config": "Importar configuración", + "dashboard.importing": "Importando…", + "dashboard.modal_back": "Atrás", + "dashboard.modal_close": "Cerrar modal de añadir espacio de trabajo", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "Aún no se ha seleccionado ninguna carpeta.", + "dashboard.open_cloud_dashboard": "Abrir panel de Cloud", + "dashboard.opening": "Abriendo...", + "dashboard.openwork_host_hint": "Usa la URL compartida por tu servidor de OpenWork.", + "dashboard.openwork_host_label": "URL del servidor de OpenWork", + "dashboard.openwork_host_placeholder": "https://tu-servidor.openwork.app", + "dashboard.openwork_host_token_hint": "Opcional. Pega un token de colaborador para el acceso habitual o un token de propietario cuando este cliente tenga que responder solicitudes de permiso.", + "dashboard.openwork_host_token_label": "Token de colaborador o de propietario", + "dashboard.openwork_host_token_placeholder": "Pega tu token", + "dashboard.recently_updated": "Actualizado recientemente", + "dashboard.remote": "Remoto", + "dashboard.remote_base_url_required": "Añade una URL del servidor para continuar.", + "dashboard.remote_connection_direct": "Direct", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "Déjalo en blanco para usar el valor predeterminado del servidor.", + "dashboard.remote_directory_label": "Directorio del espacio de trabajo (opcional)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "Nombre para mostrar (opcional)", + "dashboard.remote_display_name_placeholder": "Espacio de trabajo del equipo de diseño", + "dashboard.remote_server_details_hint": "Conéctate a un worker de OpenWork autohospedado.", + "dashboard.remote_server_details_title": "Detalles del servidor remoto", + "dashboard.remote_workspace_hint": "Guarda un servidor de OpenWork y vuelve a conectarte cuando quieras.", + "dashboard.remote_workspace_title": "Espacio de trabajo remoto", + "dashboard.repair_cache": "Reparar caché", + "dashboard.repairing_cache": "Reparando caché", + "dashboard.sandbox_checking_docker": "Comprobando Docker...", + "dashboard.sandbox_get_ready_action": "Prepara tu sistema", + "dashboard.sandbox_get_ready_desc": "Ejecuta este espacio de trabajo en un contenedor de Docker aislado para trabajar con más seguridad y de forma más reproducible.", + "dashboard.sandbox_get_ready_title": "Los sandboxes necesitan Docker", + "dashboard.sandbox_hide_logs": "Ocultar registros", + "dashboard.sandbox_live_logs": "Registros en vivo", + "dashboard.sandbox_setup": "Configuración del sandbox", + "dashboard.sandbox_show_logs": "Mostrar registros", + "dashboard.search_shared_workspaces": "Buscar espacios de trabajo compartidos", + "dashboard.select_folder": "Seleccionar carpeta", + "dashboard.settings": "Ajustes", + "dashboard.shared_workspaces_loading": "Cargando espacios de trabajo compartidos…", + "dashboard.shared_workspaces_no_match": "No hay espacios de trabajo compartidos que coincidan con esa búsqueda.", + "dashboard.shared_workspaces_none": "Todavía no hay espacios de trabajo compartidos disponibles.", + "dashboard.shared_workspaces_refreshing": "Actualizando espacios de trabajo…", + "dashboard.skills": "Skills", + "dashboard.starter_workspace": "Espacio de trabajo inicial", + "dashboard.starter_workspace_desc": "Preconfigurado para mostrarte cómo usar Plugins, Commands y Skills.", + "dashboard.unknown_creator": "Creador desconocido", + "dashboard.worker_status_attention": "Atención", + "dashboard.worker_status_ready": "Listo", + "dashboard.worker_status_starting": "Iniciando", + "dashboard.worker_status_stopped": "Detenido", + "dashboard.worker_status_unknown": "Desconocido", + "dashboard.worker_url_hint": "Pega la URL del worker de OpenWork al que quieres conectarte.", + "dashboard.worker_url_label": "URL del worker", + "dashboard.workspace_connect": "Conectar", + "dashboard.workspace_connect_unavailable": "La conexión a espacios de trabajo compartidos no está disponible aquí.", + "dashboard.workspace_connecting": "Conectando", + "dashboard.workspace_folder_hint": "Elige dónde quieres guardar este espacio de trabajo en tu dispositivo.", + "dashboard.workspace_folder_title": "Carpeta del espacio de trabajo", + "dashboard.workspace_not_ready_title": "Este espacio de trabajo aún no está listo para conectarse.", + "dashboard.workspaces": "Espacios de trabajo", + "den.active_org_hint": "Los workers de Cloud y las plantillas de equipo dependen de la organización seleccionada.", + "den.active_org_title": "Organización activa", + "den.auto_reconnect_hint": "Termina la autenticación en tu navegador y OpenWork volverá a conectarse aquí automáticamente.", + "den.checking_session": "Comprobando la sesión", + "den.choose_org_for_providers": "Elige una organización para ver los proveedores de Cloud.", + "den.choose_org_for_skills": "Elige una organización para ver las Skills en Cloud.", + "den.choose_org_for_skill_hubs": "Elige una organización para ver los centros de Skills en Cloud.", + "den.cloud_account_hint": "Gestiona tu cuenta conectada y tu organización.", + "den.cloud_account_title": "Cuenta de Cloud", + "den.cloud_control_plane_open": "Abrir en el navegador", + "den.cloud_control_plane_reset": "Reiniciar", + "den.cloud_control_plane_save": "Guardar URL", + "den.cloud_control_plane_url_hint": "Solo en modo desarrollador. Úsalo para apuntar a un plano de control de Cloud local o autohospedado. Al cambiarlo, se cierra la sesión para que la app pueda volver a hidratarse con el nuevo plano de control.", + "den.cloud_control_plane_url_label": "URL del plano de control de Cloud", + "den.cloud_provider_detail": "Modelos {count} · Proveedor {source}", + "den.cloud_provider_removed_detail": "Este proveedor importado ya no está en Cloud. Desinstala la configuración local de {providerId}.", + "den.cloud_provider_sync_detail": "El proveedor de Cloud cambió. Sincroniza la configuración del modelo {count} {source} con `opencode.jsonc`.", + "den.cloud_skill_detail": "Instala esta Skill de Cloud en `.opencode/skills`.", + "den.cloud_skill_imported_detail": "Instalado localmente como {name}.", + "den.cloud_skill_removed_detail": "Esta Skill de Cloud se eliminó del origen. Desinstala la copia local de {name}.", + "den.cloud_skill_sync_detail": "Hay una versión más nueva en Cloud disponible para {name}. Actualiza la copia local para permanecer sincronizada.", + "den.cloud_skills_hint": "Explora las Skills de Cloud a las que tienes acceso, instálalas localmente y actualízalas cuando cambie la versión remota.", + "den.cloud_skills_title": "Skills", + "den.cloud_providers_hint": "Importa proveedores LLM gestionados a `opencode.jsonc` y usa la credencial de la organización en este espacio de trabajo.", + "den.cloud_providers_title": "Proveedores de Cloud", + "den.cloud_section_desc": "Inicia sesión, elige una organización y abre workers de Cloud o plantillas de equipo.", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "Inicia sesión en OpenWork Cloud para mantener activas tus tareas incluso cuando tu equipo esté inactivo.", + "den.cloud_workers_hint": "Abre workers directamente en OpenWork con el mismo flujo de conexión remota que ya usa la app en otros lugares.", + "den.cloud_workers_title": "Workers de Cloud", + "den.create_account": "Crear una cuenta", + "den.credentials_ready_badge": "Credencial lista", + "den.error_base_url": "Introduce una URL válida del plano de control de Cloud con `http://` o `https://`.", + "den.error_choose_org": "Elige una organización antes de abrir un worker.", + "den.error_load_orgs": "No se pudieron cargar las organizaciones.", + "den.error_load_skills": "No se pudieron cargar las Skills en Cloud.", + "den.error_load_workers": "No se pudieron cargar los workers.", + "den.error_no_session": "No se encontró ninguna sesión activa de Cloud.", + "den.error_no_token": "El inicio de sesión de escritorio se completó, pero OpenWork Cloud no devolvió un token de sesión.", + "den.error_open_worker": "No se pudo abrir {name} en OpenWork.", + "den.error_open_worker_fallback": "No se pudo abrir {name}.", + "den.error_paste_valid_code": "Pega un enlace de inicio de sesión OpenWork válido o un código de inicio de sesión único.", + "den.error_signin_failed": "No se pudo completar el inicio de sesión en OpenWork Cloud.", + "den.error_worker_not_ready": "El worker todavía no está listo para abrirse. Inténtalo de nuevo cuando termine el aprovisionamiento.", + "den.finish_signin": "Finalizar el inicio de sesión", + "den.finishing": "Finalizando...", + "den.hide_signin_code": "Ocultar código de inicio de sesión", + "den.import_all": "Importar todo", + "den.import_skill": "Instalar", + "den.import_skill_failed": "No se pudo instalar {name}.", + "den.import_provider": "Importar", + "den.import_provider_failed": "No se pudo importar {name}.", + "den.imported_badge": "Importado", + "den.imported_provider": "Importado {name}.", + "den.importing": "Importando...", + "den.needs_attention": "Necesita atención", + "den.no_cloud_providers": "Aún no hay proveedores de Cloud disponibles para esta organización.", + "den.no_cloud_skills": "Aún no hay Skills en Cloud disponibles para esta organización.", + "den.no_cloud_workers": "Todavía no hay workers de Cloud visibles para esta organización. Crea uno en Cloud y luego actualiza esta pestaña.", + "den.no_org_selected": "Ninguna organización seleccionada", + "den.no_skill_hubs": "Aún no hay centros de Skills en Cloud disponibles para esta organización.", + "den.open": "Abierto", + "den.opening": "Abriendo...", + "den.org_member_suffix": "(Miembro)", + "den.org_owner_suffix": "(Propietario)", + "den.org_switched": "Cambiado a {name}.", + "den.out_of_sync_badge": "Fuera de sincronización", + "den.private_badge": "Privado", + "den.paste_signin_code": "Pegar código de inicio de sesión", + "den.refresh": "Actualizar", + "den.reload_workspace": "Vuelve a cargar el espacio de trabajo para aplicar los cambios de configuración.", + "den.remove_provider_failed": "No se pudo eliminar {name}.", + "den.remove_skill_failed": "No se pudo desinstalar {name}.", + "den.removed_from_cloud_badge": "Eliminado de Cloud", + "den.removed_provider": "Se eliminó {name}.", + "den.removing": "Eliminando...", + "den.sign_out": "Cerrar sesión", + "den.signed_out": "Sesión cerrada", + "den.signin_button": "Iniciar sesión", + "den.signin_code_note": "Acepta un enlace openwork://den-auth o la concesión única sin procesar.", + "den.signin_link_hint": "Si tu navegador no vuelve automáticamente a OpenWork, pega aquí el enlace o el código de inicio de sesión de OpenWork Cloud.", + "den.signin_link_label": "Enlace de inicio de sesión o código de un solo uso", + "den.signin_link_placeholder": "openwork://den-auth?... o código pegado", + "den.signin_title": "Iniciar sesión en OpenWork Cloud", + "den.signing_in": "Finalizando el inicio de sesión en OpenWork Cloud...", + "den.signing_out": "Cerrando sesión...", + "den.skill_hub_detail": "Importa {count} Skills compartidas a `.opencode/skills`.", + "den.skill_hub_imported_detail": "{count} Skills importadas a este espacio de trabajo.", + "den.skill_hub_removed_detail": "Este hub se eliminó de Cloud. Desinstala las {importedCount} Skills importadas en este espacio de trabajo.", + "den.skill_hub_skills_badge": "{count} Skills", + "den.skill_hub_sync_detail": "Cloud ahora tiene {liveCount} Skills; este espacio de trabajo importó {importedCount}. Sincroniza para actualizar el conjunto instalado.", + "den.skill_hubs_hint": "Importa todas las Skills desde un hub de Cloud compartido a este espacio de trabajo en un solo paso.", + "den.skill_hubs_title": "Hubs de Skills", + "den.status_base_url_updated": "Se actualizó la URL del plano de control de Cloud. Vuelve a iniciar sesión para continuar.", + "den.status_browser_signin": "Termina de iniciar sesión en tu navegador para conectar OpenWork.", + "den.status_browser_signup": "Termina de crear tu cuenta en el navegador para conectar OpenWork.", + "den.status_cloud_signed_in_as": "Conectado a OpenWork Cloud como {email}.", + "den.status_cloud_signin_done": "OpenWork Cloud conectado.", + "den.status_loaded_orgs": "Se cargaron {count} organización{plural}.", + "den.status_loaded_skills": "Se cargaron {count} Skill{plural} de Cloud para {name}.", + "den.status_loaded_workers": "Se cargaron {count} worker{plural} para {name}.", + "den.status_no_skills": "No se encontraron Skills en Cloud para {name}.", + "den.status_no_workers": "No se encontraron workers para {name}.", + "den.status_opened_worker": "Se abrió {name} en OpenWork.", + "den.status_signed_in_as": "Has iniciado sesión como {email}.", + "den.status_signed_out": "Se cerró la sesión y se borró tu sesión de OpenWork Cloud en este dispositivo.", + "den.sync": "Sincronizar", + "den.sync_provider_failed": "No se pudo sincronizar {name}.", + "den.sync_skill_failed": "No se pudo actualizar {name}.", + "den.synced_provider": "Sincronizado {name}.", + "den.syncing": "Sincronizando...", + "den.installed_name_badge": "Local: {name}", + "den.uninstall": "Desinstalar", + "den.worker_mine_badge": "Mío", + "den.worker_not_ready_title": "Este worker aún no está listo para abrir.", + "den.worker_provider_label": "Worker de {provider}", + "den.worker_secondary_cloud": "Worker de Cloud", + "extensions.app_count_one": "Aplicación {count} conectada", + "extensions.app_count_many": "Aplicaciones {count} conectadas", + "extensions.apps_mcp_header": "Aplicaciones (MCP)", + "extensions.filter_all": "Todo", + "extensions.filter_apps": "Aplicaciones", + "extensions.filter_plugins": "Plugins", + "extensions.plugin_count_one": "Plugin {count}", + "extensions.plugin_count_many": "Plugins {count}", + "extensions.plugins_opencode_header": "Plugins (OpenCode)", + "extensions.subtitle": "Las aplicaciones (MCP) y los Plugins OpenCode se encuentran en un solo lugar.", + "extensions.title": "Extensiones", + "identities.agent_behavior_desc": "Un archivo por espacio de trabajo. Añade una primera línea opcional `@agent ` para enviarlo por un agente concreto de OpenCode.", + "identities.agent_behavior_title": "Comportamiento del agente de mensajería", + "identities.agent_created": "Archivo de agente de mensajería predeterminado creado.", + "identities.agent_file_changed": "Archivo cambiado de forma remota. Vuelve a cargar y guardar nuevamente.", + "identities.agent_loading": "Cargando archivo de agente…", + "identities.agent_none": "ninguno", + "identities.agent_not_found": "El archivo del agente aún no se ha encontrado en este espacio de trabajo.", + "identities.agent_saved": "Comportamiento de mensajería guardado.", + "identities.agent_scope_status": "Alcance activo: espacio de trabajo · estado: {status} · agente seleccionado: {agent}", + "identities.agent_status_loaded": "cargado", + "identities.agent_status_missing": "desaparecido", + "identities.agent_worker_scope_unavailable": "Alcance del worker no disponible.", + "identities.all_channels": "Todos los canales", + "identities.app_token_label": "Token de la app", + "identities.auto_bind_label": "Vinculación automática entre pares y directorio en envío directo", + "identities.available_channels": "Canales disponibles", + "identities.bot_token_label": "Token del bot", + "identities.bot_token_placeholder": "Pega el token de Telegram de @BotFather", + "identities.botfather_step1_open": "1. Abre @BotFather en Telegram", + "identities.botfather_step1_run": "y ejecuta /newbot", + "identities.botfather_step3_choose": "3. Elige un nombre y nombre de usuario para tu bot.", + "identities.botfather_step3_or_private": "para abrir bandeja de entrada o", + "identities.botfather_step3_private": "Privado", + "identities.botfather_step3_public": "Público", + "identities.botfather_step3_to_require": "requerir", + "identities.channel_label": "Canal", + "identities.channels_connected": "conectado", + "identities.channels_label": "Canales", + "identities.configured_suffix": "configurado", + "identities.connect_server_desc": "Las identidades están disponibles cuando estás conectado a un host de OpenWork.", + "identities.connect_server_title": "Conéctate a un servidor de OpenWork", + "identities.connect_slack": "Conectar Slack", + "identities.connected_badge": "Conectado", + "identities.connecting": "Conectando...", + "identities.copy_bot_token_hint": "Copia el token del bot y pégalo abajo.", + "identities.copy_code": "Copiar código", + "identities.create_default_file": "Crear archivo predeterminado", + "identities.create_private_bot": "Crear bot privado", + "identities.create_public_bot": "Crear bot público", + "identities.days_ago": "Hace {days}d", + "identities.default_routing": "Enrutamiento predeterminado", + "identities.directory_label": "Directorio (opcional)", + "identities.disable_messaging": "Desactivar mensajes", + "identities.disable_messaging_message": "Esto desactivará los mensajes para este espacio de trabajo. La configuración de Telegram y Slack estará oculta hasta que se habilite la mensajería nuevamente y deberá reiniciar el worker para detener completamente el sidecar de mensajería.", + "identities.disable_messaging_title": "¿Desactivar mensajes para este worker?", + "identities.disabled_label": "Desactivado", + "identities.disabling": "Desactivando...", + "identities.disconnect": "Desconectar", + "identities.dispatched_messages": "Se enviaron {sent}/{attempted} mensajes.", + "identities.enable_messaging": "Habilitar mensajería", + "identities.enable_messaging_risk": "La mensajería puede exponer este worker a Commands remotos. Si un bot es público o está comprometido, puede acceder a archivos, credenciales y claves API disponibles para este worker.", + "identities.enable_messaging_title": "¿Habilitar mensajería para este worker?", + "identities.enabled_label": "Activado", + "identities.enabling": "Habilitando...", + "identities.health_offline": "Desconectado", + "identities.health_running": "En ejecución", + "identities.health_unavailable": "Indisponible", + "identities.health_unknown": "Desconocido", + "identities.hours_ago": "Hace {hours}h", + "identities.identities_label": "Identidades", + "identities.just_now": "En este momento", + "identities.last_activity": "Última actividad", + "identities.later": "Más tarde", + "identities.message_label": "Mensaje", + "identities.message_routing_desc": "Controla qué conversaciones van a qué carpeta del espacio de trabajo. Los mensajes se envían a la carpeta predeterminada del worker salvo que definas reglas aquí.", + "identities.message_routing_title": "Enrutamiento de mensajes", + "identities.messages_today": "Mensajes de hoy", + "identities.messaging_disabled_hint": "Activa la mensajería solo si entiendes el riesgo y piensas proteger el acceso (por ejemplo, con emparejamiento privado en Telegram).", + "identities.messaging_disabled_restart": "Mensajería deshabilitada. Reinicia este worker para detener el sidecar de mensajería.", + "identities.messaging_disabled_risk": "Los bots de mensajería pueden ejecutar acciones contra tu worker local. Si se exponen públicamente, pueden permitir el acceso a archivos, credenciales y claves API disponibles para este worker.", + "identities.messaging_disabled_title": "La mensajería está deshabilitada de forma predeterminada", + "identities.messaging_enabled_restart": "Mensajería habilitada. Reinicia este worker para aplicar antes de configurar canales.", + "identities.messaging_sidecar_not_running": "La mensajería está habilitada en este espacio de trabajo, pero el sidecar de mensajería todavía no está en ejecución. Reinicia este worker y luego vuelve a Ajustes de mensajería para conectar Telegram o Slack.", + "identities.minutes_ago": "Hace {minutes}m", + "identities.not_set": "No establecido", + "identities.open_bot_link": "Abrir @{username} en Telegram", + "identities.pairing_code_copied": "Código de emparejamiento copiado.", + "identities.pairing_code_copy_failed": "No se pudo copiar el código de emparejamiento. Cópialo manualmente.", + "identities.pairing_code_instruction_prefix": "Enviar", + "identities.peer_id_label": "ID de compañero (opcional)", + "identities.peer_id_placeholder_slack": "p. ej. slack:U12345678", + "identities.peer_id_placeholder_telegram": "p. ej. telegram:123456789", + "identities.private_label": "Privado", + "identities.private_pairing_code": "Código de emparejamiento privado", + "identities.public_bot_confirm": "Sí, entiendo el riesgo.", + "identities.public_bot_warning_message": "Tu bot será público y cualquiera que consiga acceso a él podrá tener control total sobre tu worker local, incluidos los archivos o claves API a los que tenga acceso. Si creas un bot privado, puedes limitar el acceso exigiendo un token de emparejamiento. ¿Seguro que quieres hacerlo público?", + "identities.public_bot_warning_title": "¿Hacer público este bot?", + "identities.public_label": "Público", + "identities.quick_setup": "Configuración rápida", + "identities.reconnect_failed": "No se ha podido reconectar. Comprueba la URL y el token de OpenWork y vuelve a intentarlo.", + "identities.reconnected": "Reconectado.", + "identities.reconnected_refreshing": "Reconectado. Actualizando el estado del worker...", + "identities.reload": "Recargar", + "identities.repair_reconnect": "Reparar y reconectar", + "identities.restart_failed": "El reinicio falló. Reinicia el worker desde Ajustes y vuelve a intentarlo.", + "identities.restart_to_disable_messaging": "La mensajería fue deshabilitada para este espacio de trabajo. Reinicia el worker ahora para detener el sidecar de mensajería.", + "identities.restart_to_enable_messaging": "La mensajería fue habilitada para este espacio de trabajo. Reinicia el worker ahora para iniciar el sidecar de mensajería y desbloquear la configuración de Telegram y Slack.", + "identities.restart_worker": "Reiniciar worker", + "identities.restart_worker_title": "¿Reiniciar worker ahora?", + "identities.restarting": "Reiniciando...", + "identities.routing_override_prefix": "Todos los mensajes se envían a", + "identities.routing_override_suffix": "(anulación activa)", + "identities.running_label": "En ejecución", + "identities.save_behavior": "Guardar comportamiento", + "identities.saving": "Guardando...", + "identities.send_test_button": "Enviar mensaje de prueba", + "identities.send_test_desc": "Valida la configuración de salida. Usa un ID de par para el envío directo o deja el campo vacío para repartir los mensajes según las vinculaciones de un directorio.", + "identities.send_test_title": "Enviar mensaje de prueba", + "identities.sending": "Envío...", + "identities.slack_desc": "Tu worker aparece como un bot en los canales Slack. Los miembros del equipo pueden enviarle mensajes directamente o mencionarlo en hilos.", + "identities.slack_intro": "Conecta tu espacio de trabajo de Slack para que tu equipo pueda interactuar con este worker en canales y mensajes directos.", + "identities.slack_unavailable": "Las identidades de Slack no están disponibles.", + "identities.status_active": "Activo", + "identities.status_label": "Estado", + "identities.status_stopped": "Interrumpido", + "identities.stopped_label": "Interrumpido", + "identities.subtitle": "Permite que otras personas lleguen a tu worker a través de apps de mensajería. Conecta un canal y tu worker leerá y responderá automáticamente a los mensajes.", + "identities.tab_general": "General", + "identities.telegram_bot_access_desc": "Bot público: primeros enlaces automáticos del chat Telegram. Bot privado: requiere un código de emparejamiento antes de que cualquier mensaje ejecute herramientas.", + "identities.telegram_delete_failed": "No se pudo eliminar.", + "identities.telegram_deleted": "Eliminado.", + "identities.telegram_deleted_pending": "Eliminado (pendiente de aplicar).", + "identities.telegram_desc": "Conecta un bot de Telegram en modo público (abrir bandeja de entrada) o en modo privado (requiere código de emparejamiento).", + "identities.telegram_private_saved_pair": "Bot privado guardado. Emparejar a través de /pair {code}", + "identities.telegram_save_failed": "No se pudo guardar.", + "identities.telegram_saved": "Guardado.", + "identities.telegram_saved_pending": "Guardado (pendiente de aplicar).", + "identities.telegram_saved_username": "Guardado (@{username})", + "identities.telegram_unavailable": "Las identidades de Telegram no están disponibles.", + "identities.title": "Canales de mensajería", + "identities.unsaved_changes": "Cambios no guardados", + "identities.worker_offline": "Worker desconectado", + "identities.worker_online": "Worker en línea", + "identities.worker_restarted": "El worker se reinició.", + "identities.worker_restarted_refreshing": "El worker se reinició. Actualizando el estado de la mensajería...", + "identities.worker_scope_unavailable": "Alcance del worker no disponible.", + "identities.worker_scope_unavailable_detail": "El alcance del worker no está disponible. Vuelve a conectarte con una URL de worker o cambia a un worker conocido.", + "identities.worker_unavailable": "Worker no disponible", + "identities.workspace_id_required": "Se necesita el ID del espacio de trabajo para gestionar identidades. Vuelve a conectarte con una URL de espacio de trabajo o selecciona uno asignado en este host.", + "identities.workspace_scope_prefix": "Alcance del espacio de trabajo:", + "inbox_panel.connect_to_download": "Conecta a un worker para descargar archivos compartidos.", + "inbox_panel.connect_to_see": "Conecta para ver archivos compartidos.", + "inbox_panel.connect_to_upload": "Conecta a un worker para cargar", + "inbox_panel.copy_failed": "La copia ha fallado. Tu navegador puede bloquear el acceso al portapapeles.", + "inbox_panel.download": "Descargar", + "inbox_panel.drop_to_upload": "Suelta archivos aquí para subirlos", + "inbox_panel.helper_text": "Comparte archivos con este worker desde la app.", + "inbox_panel.load_failed": "No se pudo cargar la carpeta compartida", + "inbox_panel.missing_file_id": "Falta la identificación del archivo compartido.", + "inbox_panel.no_files": "Aún no hay archivos compartidos.", + "inbox_panel.refresh_tooltip": "Actualizar carpeta compartida", + "inbox_panel.shared_folder": "carpeta compartida", + "inbox_panel.showing_first": "Mostrando el primer {count}.", + "inbox_panel.upload_failed": "Falló la carga de carpeta compartida", + "inbox_panel.upload_needs_worker": "Conecta a un worker para cargar archivos a la carpeta compartida.", + "inbox_panel.upload_prompt": "Suelte archivos o haga clic para cargar", + "inbox_panel.upload_success": "Subido a la carpeta compartida.", + "inbox_panel.uploading": "Subiendo...", + "inbox_panel.uploading_label": "Subiendo {label}...", + "mcp.activate_button": "Activar", + "mcp.add_modal_subtitle": "Conecta un servidor MCP personalizado mediante URL o un Command local.", + "mcp.add_modal_title": "Añadir aplicación personalizada", + "mcp.add_server_button": "Añadir aplicación", + "mcp.advanced": "Avanzado", + "mcp.advanced_settings": "Ajustes avanzados", + "mcp.advanced_settings_hint": "Edita archivos de configuración y gestiona conexiones manualmente.", + "mcp.app_connected": "aplicación conectada", + "mcp.apps_connected": "aplicaciones conectadas", + "mcp.apps_subtitle": "Conecta tus herramientas favoritas para que OpenWork pueda usarlas por ti.", + "mcp.apps_title": "Aplicaciones", + "mcp.auth.already_connected": "Ya conectado", + "mcp.auth.already_connected_description": "{server} ya está autenticado y listo para usar.", + "mcp.auth.applying_changes_body": "Estamos reiniciando el worker para que el nuevo MCP esté listo para autenticarse.", + "mcp.auth.applying_changes_title": "Aplicar cambios antes de iniciar sesión", + "mcp.auth.authorization_link": "Enlace de autorización", + "mcp.auth.authorization_still_required": "La autorización sigue siendo necesaria. Inténtalo otra vez para reiniciar el flujo.", + "mcp.auth.callback_invalid": "Pega la URL de callback o el parámetro `code` para completar OAuth.", + "mcp.auth.callback_label": "URL de callback o código", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "Cancelar", + "mcp.auth.client_registration_required": "Es necesario registrarse como cliente antes de que OAuth pueda continuar.", + "mcp.auth.complete_connection": "Conexión completa", + "mcp.auth.configured_previously": "Es posible que el MCP se haya configurado globalmente o en una sesión anterior. Puedes cerrar este modal y empezar a usar las herramientas MCP de inmediato.", + "mcp.auth.connect_server": "Conectar {server}", + "mcp.auth.copied": "Copiado", + "mcp.auth.copy_link": "Copiar enlace", + "mcp.auth.done": "Hecho", + "mcp.auth.failed_to_start_oauth": "No se pudo iniciar el flujo OAuth", + "mcp.auth.follow_browser_steps": "Sigue los pasos de autorización en el navegador.", + "mcp.auth.force_stop": "Forzar parada", + "mcp.auth.force_stopping": "Parada...", + "mcp.auth.im_done": "He terminado", + "mcp.auth.invalid_refresh_token": "El token de refresco de OAuth no es válido o ha caducado. Vuelve a autorizar para continuar.", + "mcp.auth.manual_finish_hint": "Pega la URL de callback (`localhost:19876`) o simplemente el código para terminar la conexión.", + "mcp.auth.manual_finish_title": "¿Servidor remoto?", + "mcp.auth.oauth_completed_reload": "OAuth completado. Vuelve a cargar el motor para activar el MCP.", + "mcp.auth.oauth_failed": "Error de autenticación OAuth.", + "mcp.auth.oauth_not_supported_hint": "Esto podría significar:\n• El servidor MCP no anuncia capacidades de OAuth\n• El motor necesita recargarse para descubrir las capacidades del servidor\n• Prueba: `opencode mcp auth {server}` desde la CLI", + "mcp.auth.open_browser_signin": "Abriremos tu navegador para completar el inicio de sesión.", + "mcp.auth.port_forward_hint": "Consejo: reenvía el puerto de callback si hace falta: `ssh -L 19876:127.0.0.1:19876 usuario@host`", + "mcp.auth.reauth_action": "Reautorizar OAuth", + "mcp.auth.reauth_cli_hint": "Ejecuta: `opencode mcp auth {server}`", + "mcp.auth.reauth_failed": "La reautorización falló.", + "mcp.auth.reauth_remote_hint": "Vuelve a autorizar desde la máquina que ejecuta este worker.", + "mcp.auth.reauth_running": "Reautorizando...", + "mcp.auth.reload_blocked": "La recarga se pausa mientras se ejecuta una sesión. Detenga la ejecución para finalizar la configuración.", + "mcp.auth.reload_engine_retry": "Aplicar cambios y volver a intentarlo", + "mcp.auth.reload_failed": "No se pudo recargar el worker antes de iniciar sesión.", + "mcp.auth.reload_notice": "Para que esto tenga efecto, OpenWork necesita actualizar el servicio del worker. Esto puede interrumpir una sesión en curso.", + "mcp.auth.reload_remote_confirm": "Para que esto tenga efecto, OpenWork necesita actualizar el servicio del worker. Esto puede parar tu sesión en curso. ¿Quieres seguir?", + "mcp.auth.reopen_browser_link": "Haz clic aquí para volver a abrir el navegador", + "mcp.auth.request_timed_out": "Se agotó el tiempo de espera de la solicitud.", + "mcp.auth.retry": "Reintentar", + "mcp.auth.retry_now": "Reintentar ahora", + "mcp.auth.server_disabled": "Este servidor MCP está desactivado. Actívalo y vuelve a intentarlo.", + "mcp.auth.step1_description": "Iniciaremos automáticamente el flujo de inicio de sesión de {server}.", + "mcp.auth.step1_title": "Abriendo tu navegador", + "mcp.auth.step2_description": "Inicia sesión y aprueba el acceso cuando se te pida.", + "mcp.auth.step2_title": "Autorizar OpenWork", + "mcp.auth.step3_description": "Terminaremos de conectarnos tan pronto como se complete la autorización.", + "mcp.auth.step3_title": "Vuelve aquí cuando hayas terminado.", + "mcp.auth.try_reload_engine": "{message}. Prueba primero a recargar el motor.", + "mcp.auth.waiting_authorization": "Esperando a que se complete la autorización en tu navegador...", + "mcp.auth.waiting_for_conversation_body": "Lo redirigiremos para que se autentique lo antes posible.", + "mcp.auth.waiting_for_conversation_title": "Esperando a que se complete la conversación", + "mcp.auth.waiting_for_session": "Esperando a que {session} termine de funcionar", + "mcp.available_apps": "Aplicaciones disponibles", + "mcp.cap_signin": "Iniciar sesión en la cuenta", + "mcp.cap_tools": "Herramientas de IA", + "mcp.config_file": "Archivo de configuración", + "mcp.config_load_failed": "No se pudo cargar el archivo de configuración", + "mcp.config_not_loaded": "Aún no cargado", + "mcp.config_source": "Desde la configuración", + "mcp.configured": "configurado", + "mcp.connect": "Conectar", + "mcp.connect_failed": "No se pudo conectar. Inténtalo de nuevo.", + "mcp.connect_server_first": "Conecta primero al servidor.", + "mcp.connected": "Conectado", + "mcp.connected_badge": "Conectado", + "mcp.connecting": "Conectando...", + "mcp.connection_failed": "Problema de conexión: inténtalo de nuevo", + "mcp.connection_type": "Conexión", + "mcp.control_chrome_browser_hint": "En Chrome 144 o posterior, haz esto primero:", + "mcp.control_chrome_browser_step_one": "Abre `chrome://inspect/#remote-debugging`.", + "mcp.control_chrome_browser_step_two": "Activa la depuración remota.", + "mcp.control_chrome_browser_step_three": "Permite las conexiones de depuración entrantes cuando Chrome te lo pida.", + "mcp.control_chrome_browser_title": "1. Activa el acceso a Chrome", + "mcp.control_chrome_connect": "Añadir Control Chrome", + "mcp.control_chrome_docs": "Guía oficial MCP", + "mcp.control_chrome_edit": "Editar configuración", + "mcp.control_chrome_profile_hint": "Control Chrome suele abrir un perfil de Chrome aparte. Activa esta opción si quieres que OpenWork reutilice la ventana de Chrome que ya tienes abierta.", + "mcp.control_chrome_profile_title": "2. Elige qué Chrome usar", + "mcp.control_chrome_save": "Guardar configuración", + "mcp.control_chrome_setup_subtitle": "Activa el acceso a Chrome y luego elige si OpenWork debe usar un perfil limpio propio o conectarse al Chrome que ya usas.", + "mcp.control_chrome_setup_title": "Configurar Control Chrome", + "mcp.control_chrome_toggle_hint": "Cuando esto está activado, OpenWork añade --autoConnect para que MCP se conecte a una instancia de Chrome que ya has abierto.", + "mcp.control_chrome_toggle_label": "Usar mi perfil Chrome existente", + "mcp.control_chrome_toggle_off": "OpenWork abrirá un perfil de Chrome aparte solo para la automatización.", + "mcp.control_chrome_toggle_on": "OpenWork reutilizará tus pestañas, cookies e inicios de sesión actuales.", + "mcp.custom_app_cta_hint": "Conecta tu propio servidor MCP, herramienta interna o app alojada.", + "mcp.desktop_required": "Las apps requieren la app de escritorio.", + "mcp.docs_link": "Más información", + "mcp.file_not_found": "Archivo de configuración aún no creado", + "mcp.finish_setup": "Casi llegamos", + "mcp.finish_setup_hint": "Toca Activar para terminar de conectar tu app.", + "mcp.friendly_status_issue": "Problema", + "mcp.friendly_status_needs_signin": "Hace falta iniciar sesión", + "mcp.friendly_status_offline": "Desconectado", + "mcp.friendly_status_paused": "En pausa", + "mcp.friendly_status_ready": "Listo", + "mcp.last_synced": "Sincronizado", + "mcp.login_action": "Iniciar sesión", + "mcp.login_hint": "Conecta tu cuenta para terminar de configurar esta app.", + "mcp.login_unavailable": "Esta aplicación no admite el inicio de sesión desde OpenWork.", + "mcp.logout_action": "Finalizar la sesión", + "mcp.logout_failed": "No se pudo cerrar sesión.", + "mcp.logout_hint": "Elimina las credenciales OAuth almacenadas. Tendrás que iniciar sesión nuevamente.", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "Esto eliminará las credenciales OAuth almacenadas para {server}. Deberá iniciar sesión nuevamente para usar esta aplicación.", + "mcp.logout_modal_title": "¿Cerrar sesión en esta aplicación?", + "mcp.logout_success": "Se cerró la sesión de {server}.", + "mcp.logout_working": "Cerrar sesión...", + "mcp.name_required": "Escribe un nombre de servidor.", + "mcp.no_apps_hint": "Conecta una de las opciones de arriba para empezar.", + "mcp.no_apps_yet": "Aún no hay aplicaciones conectadas", + "mcp.oauth": "Iniciar sesión", + "mcp.oauth_optional_hint": "Usa OAuth en el navegador para conectar tu cuenta.", + "mcp.oauth_optional_label": "Esta aplicación requiere iniciar sesión", + "mcp.one_click_connect": "Conexión con un clic", + "mcp.open_file": "Abrir archivo", + "mcp.opening_label": "Abriendo...", + "mcp.pick_workspace_error": "Elige primero una carpeta del espacio de trabajo.", + "mcp.pick_workspace_first": "Elige primero una carpeta del espacio de trabajo.", + "mcp.quick_connect_chrome_desc": "Controla pestañas de Chrome con automatización del navegador.", + "mcp.quick_connect_chrome_title": "Control Chrome", + "mcp.quick_connect_context7_desc": "Busca documentación del producto con más contexto.", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "Planifica sprints y lanza tickets más rápido.", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "Páginas, bases de datos y documentos de proyectos sincronizados.", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "Sigue lanzamientos y resuelve errores de producción.", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "Inspeccionar pagos, facturas y suscripciones.", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "Detenga la tarea en ejecución para activarla.", + "mcp.reload_banner_description": "Toca Activar para terminar de conectar tu app.", + "mcp.reload_banner_description_blocked": "Hay una tarea en ejecución. Deténla primero y luego actívalo.", + "mcp.remote_workspace_url_hint": "Los workers remotos se conectan más rápido con servidores MCP basados en URL.", + "mcp.remove_app": "Eliminar", + "mcp.remove_failed": "No se pudo eliminar la aplicación.", + "mcp.remove_modal_message": "¿Seguro que quieres eliminar {server}? Siempre podrás volver a añadirlo más tarde.", + "mcp.remove_modal_title": "Quitar aplicación", + "mcp.reveal_config_failed": "No se pudo abrir el archivo de configuración", + "mcp.reveal_in_finder": "Mostrar en Finder", + "mcp.scope_global": "Todos los espacios de trabajo", + "mcp.scope_project": "Este espacio de trabajo", + "mcp.server_command": "Command", + "mcp.server_command_hint": "El Command de shell para iniciar el servidor.", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "Nombre de la aplicación", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "Tipo", + "mcp.server_url": "URL del servidor", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "Iniciar sesión", + "mcp.tap_to_connect": "Toca para conectarte", + "mcp.technical_details": "Detalles técnicos", + "mcp.type_cloud": "Cloud (inicia sesión con tu cuenta)", + "mcp.type_local": "Local (se ejecuta en este dispositivo)", + "mcp.type_local_cmd": "Local (Command)", + "mcp.type_remote": "Remoto (URL)", + "mcp.url_or_command_required": "Introduce una URL para servidores remotos o un Command para servidores locales.", + "mcp.your_apps": "Tus aplicaciones", + "message.tool_request_label": "Pedido", + "message.tool_result_label": "Resultado", + "message.waiting_subagent": "Esperando que llegue la transcripción del subagente.", + "message_list.copy_message": "Copiar mensaje", + "message_list.open_session": "sesión abierta", + "message_list.step_updates_progress": "Progreso de las actualizaciones", + "message_list.subagent_loading_transcript": "Cargando transcripción", + "message_list.subagent_message_count": "Mensaje {count}{plural}", + "message_list.subagent_running": "En ejecución", + "message_list.subagent_session_fallback": "sesión de subagente", + "message_list.subagent_type_task": "tarea {agentType}", + "message_list.subagent_waiting_transcript": "Esperando transcripción", + "message_list.tool_checked_url": "Comprobado {url}", + "message_list.tool_checked_web_fallback": "Página web revisada", + "message_list.tool_delegate_agent": "Delegado {agent}", + "message_list.tool_delegate_task_fallback": "Delegar tarea", + "message_list.tool_load_skill_fallback": "Cargar Skill", + "message_list.tool_load_skill_named": "Cargar Skill {name}", + "message_list.tool_read_todo": "Leer lista de tareas pendientes", + "message_list.tool_reviewed_file": "Revisado {file}", + "message_list.tool_reviewed_file_fallback": "Archivo revisado", + "message_list.tool_reviewed_files_fallback": "Archivos revisados", + "message_list.tool_reviewed_path": "Revisado {path}", + "message_list.tool_run_command": "Ejecuta {command}", + "message_list.tool_run_command_fallback": "Ejecutar Command", + "message_list.tool_searched_code_fallback": "código buscado", + "message_list.tool_searched_pattern": "Buscado {pattern}", + "message_list.tool_update_file": "Actualización {file}", + "message_list.tool_update_file_fallback": "Actualizar archivo", + "message_list.tool_update_todo": "Actualizar lista de tareas pendientes", + "message_list.tool_updated_file": "Actualizado {file}", + "message_list.tool_updated_file_fallback": "Archivo actualizado", + "model_behavior.desc_builtin": "Este modelo decide su propio camino de razonamiento y no expone perfiles aquí.", + "model_behavior.desc_generic": "Usa el perfil {label}.", + "model_behavior.desc_high": "Dedica más tiempo a razonar antes de responder.", + "model_behavior.desc_high_anthropic": "Usa el presupuesto estándar de pensamiento extendido.", + "model_behavior.desc_low": "Usa un razonamiento más ligero antes de responder.", + "model_behavior.desc_low_google": "Usa un presupuesto de razonamiento más ligero para obtener respuestas más rápidas.", + "model_behavior.desc_max": "Usa el perfil de razonamiento más profundo del proveedor.", + "model_behavior.desc_max_anthropic": "Usa el mayor presupuesto disponible para pensamiento extendido.", + "model_behavior.desc_medium": "Equilibra la velocidad y la profundidad del razonamiento.", + "model_behavior.desc_minimal": "Usa una cantidad muy pequeña de razonamiento.", + "model_behavior.desc_none": "Favorece la velocidad con el camino de razonamiento más ligero.", + "model_behavior.desc_standard": "Este modelo no expone controles de razonamiento adicionales.", + "model_behavior.label_balanced": "Equilibrado", + "model_behavior.label_builtin": "Construido en", + "model_behavior.label_deep": "Profundo", + "model_behavior.label_extended": "Extendido", + "model_behavior.label_fast": "Rápido", + "model_behavior.label_light": "Claro", + "model_behavior.label_maximum": "Máximo", + "model_behavior.label_quick": "Rápido", + "model_behavior.label_standard": "Estándar", + "model_behavior.title_builtin_reasoning": "Razonamiento incorporado", + "model_behavior.title_extended_thinking": "pensamiento extendido", + "model_behavior.title_reasoning_budget": "presupuesto de razonamiento", + "model_behavior.title_reasoning_effort": "esfuerzo de razonamiento", + "model_behavior.title_standard_generation": "Generación estándar", + "model_picker.chat_model_desc": "Elige el modelo para este chat. Si un modelo admite perfiles de razonamiento, configúrelos en su tarjeta.", + "model_picker.chat_model_title": "modelo de chat", + "model_picker.connect_provider_hint": "Conecta este proveedor para buscar y guardar modelos", + "model_picker.default_model_desc": "Elige el modelo predeterminado para nuevos chats, luego ajuste los perfiles de razonamiento en su tarjeta antes de presionar Listo.", + "model_picker.default_model_title": "Modelo predeterminado", + "model_picker.model_count": "Modelos {count}", + "model_picker.model_count_one": "1 modelo", + "model_picker.more_providers": "Más proveedores", + "model_picker.no_results": "Ningún modelo coincide con tu búsqueda.", + "model_picker.other_connected_models": "Otros modelos conectados", + "model_picker.recommended": "Recomendado", + "onboarding.access_label": "Acceso", + "onboarding.add": "Añadir", + "onboarding.add_folder_path": "Añadir ruta de carpeta", + "onboarding.advanced_settings": "Ajustes avanzados", + "onboarding.attach": "Adjuntar", + "onboarding.attach_description": "Conéctate a la sesión existente en este dispositivo.", + "onboarding.authorize_folder": "Autorizar carpeta", + "onboarding.back": "Atrás", + "onboarding.checking_cli": "Comprobando OpenCode CLI...", + "onboarding.choose_workspace_folder": "Elige la carpeta del espacio de trabajo", + "onboarding.cli_checking": "Comprobando instalación...", + "onboarding.cli_install_commands": "Instala OpenCode con uno de los comandos siguientes y después reinicia OpenWork.", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "OpenCode CLI necesita una actualización para funcionar.", + "onboarding.cli_not_found": "OpenCode CLI no encontrado.", + "onboarding.cli_not_found_hint": "Extraviado. Instalar para ejecutar el servidor local.", + "onboarding.cli_ready": "OpenCode CLI listo.", + "onboarding.cli_recheck": "Vuelve a comprobar", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "Instalado", + "onboarding.create_first_workspace": "Crea tu primer espacio de trabajo", + "onboarding.create_workspace": "Crear un espacio de trabajo", + "onboarding.engine_running": "Motor ya en marcha", + "onboarding.folders_allowed": "{count} carpeta{plural} permitida{plural}", + "onboarding.getting_ready": "Preparando todo", + "onboarding.install": "Instalar OpenCode", + "onboarding.install_instruction": "Instala OpenCode para habilitar el servidor local (no se necesita terminal).", + "onboarding.last_checked": "Última comprobación {time}", + "onboarding.manage_access_hint": "Puedes gestionar el acceso en la configuración avanzada.", + "onboarding.open_settings": "Abrir configuración", + "onboarding.open_settings_hint": "¿Necesitas opciones del motor o de acceso? Abre Ajustes.", + "onboarding.pick": "Elegir", + "onboarding.ready_message": "OpenCode está listo para iniciar el servidor local.", + "onboarding.remember_choice": "Recuerda mi elección para la próxima vez.", + "onboarding.remote_workspace_action": "Conectar", + "onboarding.remote_workspace_card_description": "Conéctate a un servidor de OpenWork para acceder a un espacio de trabajo compartido.", + "onboarding.remote_workspace_card_title": "Conectar un espacio de trabajo remoto", + "onboarding.remote_workspace_description": "Conéctate a un servidor de OpenWork para acceder a un espacio de trabajo desde cualquier lugar.", + "onboarding.remote_workspace_title": "Conectarse al servidor de OpenWork", + "onboarding.remove": "Eliminar", + "onboarding.resolved_path": "Ruta resuelta", + "onboarding.run_local": "Ejecutar localmente", + "onboarding.run_local_description": "OpenWork ejecuta OpenCode localmente y mantiene tu trabajo en privado.", + "onboarding.search_notes": "Notas de búsqueda", + "onboarding.searching_host": "Conectando al servidor de OpenWork...", + "onboarding.serve_help": "Salida de `serve --help`", + "onboarding.show_search_notes": "Mostrar notas de búsqueda", + "onboarding.start": "Iniciar OpenWork", + "onboarding.starting_host": "Iniciando el servidor de OpenWork...", + "onboarding.theme_current": "Actual: {mode}", + "onboarding.theme_dark": "Oscuro", + "onboarding.theme_label": "Tema", + "onboarding.theme_light": "Claro", + "onboarding.theme_system": "Sistema", + "onboarding.verifying": "Comprobando una conexión segura", + "onboarding.version": "Versión", + "onboarding.welcome_title": "¿Cómo quieres ejecutar OpenWork hoy?", + "onboarding.windows_install_instruction": "Instala OpenCode para Windows y después reinicia OpenWork. Asegúrate de que `opencode.exe` esté en el PATH.", + "onboarding.workspace_folder_label": "Un espacio de trabajo es una carpeta con sus propias Skills, Plugins y Commands.", + "plugins.add": "Añadir", + "plugins.add_hint": "Añade nombres de paquetes npm, por ejemplo, opencode-wakatime", + "plugins.add_label": "Añadir Plugin", + "plugins.added": "Añadido", + "plugins.config": "Configuración", + "plugins.config_label": "Configuración", + "plugins.desc": "Gestiona `opencode.json` para tu proyecto o los Plugins globales de OpenCode.", + "plugins.empty": "Aún no hay Plugins configurados.", + "plugins.enabled": "Activado", + "plugins.hide_setup": "Ocultar configuración", + "plugins.not_loaded": "Aún no cargado", + "plugins.not_loaded_yet": "Aún no cargado", + "plugins.remove": "Eliminar", + "plugins.scope_global": "Global", + "plugins.scope_project": "Proyecto", + "plugins.setup": "Configuración", + "plugins.suggested": "Plugins sugeridos", + "plugins.suggested_heading": "Plugins sugeridos", + "plugins.title": "Plugins OpenCode", + "providers.api_key_label": "Clave API", + "providers.api_key_required": "Hace falta una clave API", + "providers.auth_failed": "Error de autenticación", + "providers.connect_failed": "No se pudo conectar el proveedor", + "providers.disabled_in_config_suffix": "y lo deshabilité en la configuración de OpenCode.", + "providers.disconnect_failed": "No se pudo desconectar el proveedor", + "providers.disconnected_prefix": "Desconectado", + "providers.load_failed": "No se pudieron cargar los proveedores", + "providers.no_oauth_prefix": "No hay flujo OAuth disponible para", + "providers.no_providers_available": "No hay proveedores disponibles", + "providers.not_connected": "No conectado a un servidor", + "providers.not_oauth_flow_prefix": "El método de autenticación seleccionado no es un flujo OAuth para", + "providers.oauth_failed": "No se pudo completar OAuth", + "providers.oauth_method_required": "Hace falta el método OAuth", + "providers.provider_error": "Error de proveedor ({provider})", + "providers.provider_id_required": "Hace falta el ID del proveedor", + "providers.rate_limit_exceeded": "Has superado el límite de peticiones", + "providers.removal_unsupported": "Este cliente no admite la eliminación de la autenticación del proveedor.", + "providers.request_failed": "Solicitud fallida", + "providers.save_api_key_failed": "No se pudo guardar la clave API", + "providers.still_connected_suffix": ", pero el worker todavía lo informa como conectado. Borre cualquier clave API restante o credenciales de OAuth y reinicie el worker para desconectarlo por completo.", + "providers.unknown_provider": "Proveedor desconocido", + "providers.use_api_key_suffix": "Usa una clave API en su lugar.", + "question_modal.custom_answer_label": "O escribe una respuesta personalizada", + "question_modal.custom_answer_placeholder": "Escribe tu respuesta aquí...", + "question_modal.question_counter": "Pregunta {current} de {total}", + "session.allow_for_session": "Permitir durante la sesión", + "session.allow_once": "Permitir una vez", + "session.api_key_saved": "Clave API guardada", + "session.attachments_add_token": "Añade un token del servidor para adjuntar archivos.", + "session.attachments_connect_server": "Conéctate al servidor de OpenWork para adjuntar archivos.", + "session.back": "Atrás", + "session.close_quick_actions": "Cerrar acciones rápidas", + "session.close_search": "Cerrar búsqueda", + "session.cmd_compact_detail": "Envía una instrucción de compactación a OpenCode para esta sesión", + "session.cmd_compact_detail_empty": "Aún no hay mensajes de usuario para compactar", + "session.cmd_compact_meta": "Compactar", + "session.cmd_compact_title": "Compactar conversación", + "session.cmd_current_workspace": "Espacio de trabajo actual", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "Modelo", + "session.cmd_model_meta": "Abrir", + "session.cmd_model_title": "Cambiar modelo", + "session.cmd_new_session_detail": "Iniciar una nueva tarea en el espacio de trabajo actual", + "session.cmd_new_session_meta": "Crear", + "session.cmd_new_session_title": "Crear nueva sesión", + "session.cmd_provider_detail": "Abrir flujo de conexión de proveedor", + "session.cmd_provider_meta": "Abrir", + "session.cmd_provider_title": "Conectar proveedor", + "session.cmd_rename_detail_fallback": "Dale un nombre más claro a tu sesión seleccionada", + "session.cmd_rename_meta": "Renombrar", + "session.cmd_rename_title": "Cambiar el nombre de la sesión actual", + "session.cmd_sessions_detail": "{count} disponibles en distintos espacios de trabajo", + "session.cmd_sessions_meta": "Saltar", + "session.cmd_sessions_title": "Buscar sesiones", + "session.cmd_switch": "Cambiar", + "session.compacted": "Sesión compactada.", + "session.compacting": "Compactando el contexto de la sesión...", + "session.compacting_auto": "OpenCode está autocompactando esta sesión", + "session.compacting_manual": "OpenCode está compactando esta sesión.", + "session.compaction_finished": "OpenCode terminó de compactar el contexto de la sesión.", + "session.compaction_started": "OpenCode comenzó a compactar el contexto de la sesión.", + "session.conflict_sync_toast": "Conflicto al sincronizar {path}. Los cambios locales se guardaron en {conflictPath}.", + "session.connect_failed": "Conexión fallida", + "session.connect_to_sync": "Conéctate al servidor de OpenWork para sincronizar archivos remotos.", + "session.create_or_connect_workspace": "Crear o conectar un espacio de trabajo", + "session.create_workspace_desc": "Abre el creador de espacio de trabajo y elige cómo quieres comenzar.", + "session.create_workspace_title": "Crear espacio de trabajo", + "session.default_agent": "Agente predeterminado", + "session.default_title": "Nueva sesión", + "session.delete": "Eliminar", + "session.delete_named_session_message": "Esto eliminará permanentemente \"{title}\" y sus mensajes.", + "session.delete_session_generic": "Esto eliminará permanentemente la sesión seleccionada y sus mensajes.", + "session.delete_session_title": "¿Eliminar sesión?", + "session.deleted": "Sesión eliminada", + "session.deleting": "Eliminando...", + "session.deny": "Denegar", + "session.details": "Detalles", + "session.details_label": "Detalles", + "session.doom_loop_label": "Bucle fatal", + "session.doom_loop_message": "OpenCode detectó llamadas repetidas a herramientas con entradas idénticas y pregunta si debe continuar después de repetidas fallas.", + "session.doom_loop_note": "Rechazar para detener el ciclo o permitir si desea que el agente siga intentándolo.", + "session.doom_loop_repeated_call_label": "llamada repetida", + "session.doom_loop_repeated_tool_call": "Llamada de herramienta repetida", + "session.doom_loop_title": "Bucle fatal detectado", + "session.doom_loop_tool_label": "Herramienta", + "session.downloading": "Descargando", + "session.downloading_percent": "Descargando {percent}%", + "session.downloading_update_title": "Descargando la actualización {version}", + "session.export_already_running": "La exportación ya está en marcha.", + "session.export_desktop_only": "La exportación está disponible en la app de escritorio.", + "session.export_desktop_only_local": "La exportación está disponible para workers locales en la app de escritorio.", + "session.export_local_only": "La exportación solo es compatible con workers locales.", + "session.failed_to_compact": "No se pudo compactar la sesión", + "session.failed_to_create_session": "No se pudo crear la sesión", + "session.failed_to_delete": "No se pudo eliminar la sesión", + "session.failed_to_load_agents": "No se pudieron cargar los agentes", + "session.failed_to_load_providers": "No se pudieron cargar los proveedores", + "session.failed_to_redo": "No se pudo rehacer", + "session.failed_to_save_api_key": "No se pudo guardar la clave API", + "session.failed_to_stop": "No se pudo detener", + "session.failed_to_undo": "No se pudo deshacer", + "session.file_open_desktop_only": "La apertura de archivos está disponible en la app de escritorio.", + "session.file_open_failed": "Error al abrir el archivo", + "session.file_open_remote_unavailable": "La apertura de archivos no está disponible para los espacios de trabajo remotos.", + "session.flyout_file_modified": "Archivo modificado", + "session.flyout_new_task": "Nueva tarea", + "session.install_update": "Instalar actualización", + "session.jump_to_latest": "Saltar a lo último", + "session.jump_to_start": "Saltar al inicio del mensaje", + "session.load_earlier": "Cargar mensajes anteriores", + "session.loading_detail": "Obteniendo los mensajes más recientes para esta tarea.", + "session.loading_earlier": "Cargando mensajes anteriores...", + "session.loading_session": "Cargando sesión", + "session.loading_title": "Cargando sesión", + "session.menu_label": "Menú", + "session.model": "Modelo", + "session.model_fallback": "Modelo", + "session.new_task": "Nueva tarea", + "session.next_match": "Siguiente coincidencia", + "session.no_matches": "No hay coincidencias", + "session.no_matches_command": "Sin coincidencias.", + "session.no_session_selected": "Ninguna sesión seleccionada", + "session.nothing_to_compact": "Nada que compactar todavía.", + "session.nothing_to_redo": "Nada que rehacer.", + "session.nothing_to_retry": "No hay nada que volver a intentar todavía", + "session.nothing_to_undo": "Nada que deshacer todavía.", + "session.oauth_failed": "OAuth falló", + "session.obsidian_worker_relative_only": "Solo se pueden abrir en Obsidian los archivos relativos al worker.", + "session.open": "Abrir", + "session.palette_hint_navigate": "Teclas de flecha para navegar", + "session.palette_hint_run": "Enter para ejecutar · Esc para cerrar", + "session.palette_placeholder_actions": "Acciones de búsqueda", + "session.palette_placeholder_sessions": "Buscar por título de sesión o espacio de trabajo", + "session.palette_title_actions": "Acciones rápidas", + "session.palette_title_sessions": "Buscar sesiones", + "session.permission_detail_command": "Comando", + "session.permission_detail_cwd": "Directorio de trabajo", + "session.permission_detail_description": "Descripción", + "session.permission_detail_diff": "Diferencia", + "session.permission_detail_file": "Archivo", + "session.permission_detail_files": "Archivos", + "session.permission_detail_agent": "Agente", + "session.permission_detail_parent_directory": "Directorio padre", + "session.permission_detail_path": "Ruta", + "session.permission_detail_query": "Consulta", + "session.permission_detail_target": "Objetivo", + "session.permission_detail_tool": "Herramienta", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "Edición de archivo", + "session.permission_kind_external_directory": "Directorio externo", + "session.permission_kind_question": "Pregunta", + "session.permission_kind_read": "Lectura de archivo", + "session.permission_kind_skill": "Skill", + "session.permission_kind_task": "Subtarea", + "session.permission_kind_todowrite": "Escritura de tareas", + "session.permission_label": "Permiso", + "session.permission_message": "OpenCode solicita permiso para continuar.", + "session.permission_message_bash": "Revisa el alcance del comando antes de permitir que OpenCode continúe.", + "session.permission_message_edit": "Revisa el archivo y la diferencia antes de permitir que OpenCode haga cambios.", + "session.permission_message_external_directory": "Revisa la carpeta antes de permitir acceso fuera del workspace.", + "session.permission_message_read": "Revisa el alcance de archivos solicitado antes de permitir el acceso.", + "session.permission_message_task": "Revisa la subtarea solicitada antes de permitir que empiece.", + "session.permission_metadata_unavailable": "No se pudieron mostrar los metadatos.", + "session.permission_required": "Permiso requerido", + "session.permission_review_label": "Revisión", + "session.permission_scope_empty": "No se proporcionó un alcance específico.", + "session.permission_decision_hint": "Permite una vez para esta solicitud, o durante la sesión cuando confíes en este alcance.", + "session.permission_title_bash": "¿Ejecutar un comando de shell?", + "session.permission_title_edit": "¿Modificar archivos?", + "session.permission_title_external_directory": "¿Acceder a una carpeta externa?", + "session.permission_title_generic": "¿Aprobar {permission}?", + "session.permission_title_read": "¿Leer archivos?", + "session.permission_title_task": "¿Iniciar una subtarea?", + "session.phase_responding": "Respondiendo", + "session.phase_retrying": "Reintentando", + "session.phase_run_failed": "La ejecución falló", + "session.phase_sending": "Envío", + "session.pick_folder_desc": "Elige una carpeta de proyecto o de notas y OpenWork la usará como espacio de trabajo.", + "session.pick_folder_title": "Elige una carpeta en la que quieras trabajar", + "session.pick_workspace_to_open": "Elige un espacio de trabajo para abrir archivos.", + "session.prev_match": "Coincidencia anterior", + "session.provider_auth_in_progress": "La autenticación del proveedor ya está en curso.", + "session.provider_connected": "Proveedor conectado", + "session.quick_actions_label": "Acciones rápidas", + "session.quick_actions_title": "Acciones rápidas (Ctrl/Cmd+K)", + "session.redo_aria_label": "Rehacer el último mensaje revertido", + "session.redo_label": "Rehacer", + "session.redo_title": "Rehacer el último mensaje revertido", + "session.remote_sync_failed": "Error en la sincronización remota de archivos", + "session.rename_description": "Actualiza el nombre de esta sesión.", + "session.rename_label": "Nombre de la sesión", + "session.rename_placeholder": "Escribe un nombre nuevo", + "session.rename_title": "Cambiar nombre de sesión", + "session.resize_workspace_column": "Cambiar el tamaño de la columna espacio de trabajo", + "session.restart_update_title": "Reinicia para aplicar la actualización {version}", + "session.restored_message": "Restaurado el mensaje revertido.", + "session.reveal": "Mostrar", + "session.reveal_desktop_only": "La opción Mostrar está disponible en la app de escritorio.", + "session.revert_label": "Revertir", + "session.reverted_last_message": "Se revirtió el último mensaje de usuario.", + "session.run": "Ejecutar", + "session.scope_label": "Alcance", + "session.search_conversation_label": "Buscar conversación", + "session.search_conversation_title": "Buscar conversación (Ctrl/Cmd+F)", + "session.search_next": "Próximo", + "session.search_placeholder": "Buscar en este chat", + "session.search_position": "{current} de {total}", + "session.search_prev": "Anterior", + "session.share_active_cloud_org": "Organización activa de Cloud", + "session.share_choose_org": "Elige una organización en Ajustes -> Cloud antes de compartir con tu equipo.", + "session.share_collaborator_hint": "Acceso remoto habitual cuando no necesitas acciones exclusivas del propietario.", + "session.share_collaborator_host_hint": "Acceso remoto habitual a este host sin acciones exclusivas del propietario.", + "session.share_collaborator_label": "Token de colaborador", + "session.share_collaborator_token": "Token de colaborador", + "session.share_connected_with_hint": "Este espacio de trabajo está actualmente conectado con esta contraseña.", + "session.share_desktop_app_required": "Hace falta la app de escritorio", + "session.share_desktop_required": "Hace falta la app de escritorio", + "session.share_host_url_and_token_required": "Hacen falta la URL y el token del host de OpenWork.", + "session.share_local_host_not_ready": "El host local de OpenWork todavía no está listo.", + "session.share_missing_host_url": "Falta la URL del host de OpenWork.", + "session.share_missing_token": "Falta el token de OpenWork.", + "session.share_no_skills": "No se encontraron Skills en este espacio de trabajo.", + "session.share_note_direct_runtime": "El runtime del motor está configurado en Direct. Cambiar de worker local puede reiniciar el host y desconectar a los clientes. El token puede cambiar después del reinicio.", + "session.share_opencode_base_url": "URL base de OpenCode", + "session.share_openwork_workers_only": "Los enlaces del servicio para compartir solo están disponibles para workers de OpenWork.", + "session.share_owner_permission_hint": "Úsalo cuando el cliente remoto tenga que responder solicitudes de permiso.", + "session.share_password": "Contraseña", + "session.share_password_owner_hint": "Úsalo cuando el cliente remoto tenga que responder solicitudes de permiso.", + "session.share_publish_skills_failed": "No se pudo publicar el conjunto de Skills", + "session.share_publish_workspace_failed": "No se pudo publicar el perfil del espacio de trabajo", + "session.share_resolve_local_workspace_failed": "No se pudo resolver este espacio de trabajo en el host local de OpenWork.", + "session.share_resolve_remote_workspace_failed": "No se pudo resolver este espacio de trabajo en el host de OpenWork.", + "session.share_save_team_template_failed": "No se pudo guardar la plantilla del equipo", + "session.share_saved_to_org": "Guardado {name} en {org}.", + "session.share_select_workspace": "Selecciona primero un espacio de trabajo.", + "session.share_set_token_hint": "Configura el token en los ajustes del espacio de trabajo", + "session.share_sign_in_required": "Inicia sesión en OpenWork Cloud desde Ajustes para compartir con tu equipo.", + "session.share_skills_set_desc": "Conjunto completo de Skills de un espacio de trabajo de OpenWork.", + "session.share_starting_server": "Iniciando servidor...", + "session.share_team_fallback_name": "plantillas de tu equipo", + "session.share_url_resolving_hint": "La URL del worker se está resolviendo; mientras tanto se muestra la URL del host como alternativa.", + "session.share_url_worker_hint": "Úsalo en móviles o portátiles que se conecten a este worker.", + "session.share_worker_url": "URL del worker", + "session.share_worker_url_phones_hint": "Úsalo en móviles o portátiles que se conecten a este worker.", + "session.share_worker_url_resolving_hint": "La URL del worker se está resolviendo; mientras tanto se muestra la URL del host como alternativa.", + "session.shared_folder_upload_failed": "Falló la subida a la carpeta compartida", + "session.show_earlier": "Mostrar {count} mensaje{plural} anterior", + "session.status_active": "Sesión activa", + "session.status_compacting": "Compactando contexto", + "session.status_delegating": "Delegar", + "session.status_gathering_context": "Recopilando contexto", + "session.status_planning": "Planificación", + "session.status_ready": "Listo", + "session.status_ready_session": "Sesión lista", + "session.status_running_shell": "Ejecutando shell", + "session.status_searching_codebase": "Buscando base de código", + "session.status_searching_web": "Buscando en la web", + "session.status_thinking": "Pensando", + "session.status_working": "Trabajando", + "session.status_writing_file": "Escribiendo archivo", + "session.stopped": "Interrumpido.", + "session.stopping_run": "Deteniendo la ejecución...", + "session.todo_progress": "{completed} de {total} tareas completadas", + "session.trying_again": "Intentando de nuevo...", + "session.unable_to_open_file": "No se puede abrir el archivo", + "session.unable_to_open_obsidian": "No se puede abrir el archivo en Obsidian", + "session.unable_to_reveal": "No se pudo mostrar el espacio de trabajo", + "session.undo_label": "Revertir", + "session.undo_title": "Deshacer el último mensaje", + "session.update_available": "Actualización disponible", + "session.update_available_title": "Actualización disponible {version}", + "session.update_ready": "Actualización lista", + "session.update_ready_stop_runs_title": "Actualización lista {version}. Detenga las ejecuciones activas para reiniciar.", + "session.upload_connect_server": "Conéctate al servidor de OpenWork para subir archivos a la carpeta compartida.", + "session.uploaded_to_shared_folder": "Subido a la carpeta compartida.", + "session.uploaded_with_summary": "Subido a la carpeta compartida: {summary}", + "session.uploading_to_shared_folder": "Subiendo {label} a la carpeta compartida...", + "session.workspace_fallback": "workspace", + "session.workspace_label": "workspace", + "session.workspace_path_unavailable": "La ruta del espacio de trabajo no está disponible.", + "session.workspace_setup_desc": "Empieza con un espacio de trabajo guiado de OpenWork o elige una carpeta existente en la que quieras trabajar.", + "session.workspace_setup_label": "Configuración del espacio de trabajo", + "session.workspace_setup_title": "Configura tu primer espacio de trabajo", + "settings.action_download": "Descargar", + "settings.action_install": "Instalar", + "settings.actor_host": "host", + "settings.actor_remote": "remoto", + "settings.actor_unknown": "desconocido", + "settings.advanced": "Avanzado", + "settings.advanced_title": "Avanzado", + "settings.api_keys_info": "OpenCode almacena las claves API localmente. Los proveedores definidos por el entorno deben cambiarse en el entorno del worker y después recargarse.", + "settings.appearance_hint": "Sigue la configuración del sistema o fuerza el modo claro u oscuro.", + "settings.appearance_title": "Apariencia", + "settings.audit_error": "Error", + "settings.audit_loading": "Cargando", + "settings.audit_log_title": "Registro de auditoría", + "settings.audit_ready": "Listo", + "settings.auto_compact": "Compactación automática de contexto", + "settings.auto_compact_desc": "Controla `compaction.auto` de OpenCode para este espacio de trabajo. Recarga el motor después de cambiarlo.", + "settings.auto_update_desc": "Descarga las actualizaciones automáticamente (solicita", + "settings.auto_update_title": "Actualización automática", + "settings.available_count": "{count} disponibles", + "settings.background_checks_desc": "OpenWork siempre comprueba el lanzamiento. También comprueba una vez", + "settings.background_checks_title": "Comprobaciones en segundo plano", + "settings.base_url_unavailable": "Base URL no disponible", + "settings.binary_unavailable": "Binario no disponible", + "settings.cache_nothing_to_repair": "No se encontró caché OpenCode. Nada que reparar.", + "settings.cache_repair_requires_desktop": "La reparación de caché requiere la app de escritorio", + "settings.cache_repaired": "Caché OpenCode reparado. Reinicia el motor si estaba en marcha.", + "settings.cap_browser_tools": "Herramientas del navegador: {value}", + "settings.cap_commands": "Commands: {value}", + "settings.cap_config": "Configuración: {value}", + "settings.cap_file_tools": "Herramientas de archivo: {value}", + "settings.cap_inbox_off": "bandeja de entrada desactivada", + "settings.cap_inbox_on": "bandeja de entrada activada", + "settings.cap_mcp": "MCP: {value}", + "settings.cap_outbox_off": "bandeja de salida desactivada", + "settings.cap_outbox_on": "bandeja de salida activada", + "settings.cap_plugins": "Plugins: {value}", + "settings.cap_read": "leer", + "settings.cap_sandbox": "Sandbox: {value}", + "settings.cap_skills": "Skills: {value}", + "settings.cap_write": "escribir", + "settings.capabilities_title": "Capacidades del servidor de OpenWork", + "settings.capabilities_unavailable": "Capacidades no disponibles. Conéctate con un token de cliente.", + "settings.change": "Cambiar", + "settings.check_update": "Buscar", + "settings.checking_for_updates": "Buscando actualizaciones", + "settings.choose": "Elegir", + "settings.clear": "Borrar", + "settings.clipboard_unavailable": "El portapapeles no está disponible en este entorno.", + "settings.configure": "Configurar", + "settings.connect_opencode_hint": "Conecta a OpenCode para cargar proveedores.", + "settings.connect_provider": "Conectar proveedor", + "settings.connected_count": "{count} conectado", + "settings.connection": "Conexión", + "settings.connection_failed": "La conexión falló", + "settings.connection_title": "Conexión", + "settings.copied_debug_report": "Informe JSON del runtime copiado.", + "settings.copy_failed": "No se ha podido copiar el informe del runtime.", + "settings.copy_json": "Copia JSON", + "settings.custom_binary_hint": "Úsalo para apuntar OpenWork a una compilación local de OpenCode.", + "settings.custom_binary_label": "Binario OpenCode personalizado", + "settings.data_dir_unavailable": "Directorio de datos no disponible", + "settings.debug_commit": "Commit: {sha}", + "settings.debug_desktop_app": "App de escritorio: {version}", + "settings.debug_opencode_version": "OpenCode: {version}", + "settings.debug_openwork_server_version": "Servidor de OpenWork: {version}", + "settings.debug_section_title": "Desarrollador", + "settings.deeplink_failed": "No se pudo abrir deep link.", + "settings.deeplink_hint": "Admite `openwork://`, `openwork-dev://` o una URL compatible `https://share.openworklabs.com/b/...`.", + "settings.default_model": "Modelo predeterminado", + "settings.delete_containers": "Quitando contenedores...", + "settings.delete_local_config": "Eliminando estado local...", + "settings.desktop_only_hint": "Disponible en la app de escritorio.", + "settings.dev_mode_badge": "Modo de desarrollo", + "settings.developer": "Desarrollador", + "settings.developer_mode_desc": "Habilita herramientas de depuración, diagnósticos y la pestaña Desarrollador.", + "settings.developer_mode_title": "Modo desarrollador", + "settings.developer_panel_disabled": "Panel de desarrollador deshabilitado.", + "settings.developer_panel_enabled": "Panel de desarrollador habilitado.", + "settings.devlog_cleared": "Se borró la salida del registro del desarrollador.", + "settings.devlog_clipboard_unavailable": "El portapapeles no está disponible en este entorno.", + "settings.devlog_copied": "Salida del registro del desarrollador copiada.", + "settings.devlog_copy_failed": "No se pudo copiar la salida del registro del desarrollador.", + "settings.devlog_export_failed": "No se pudo exportar la salida del registro del desarrollador.", + "settings.devlog_export_unavailable": "La exportación no está disponible en este entorno.", + "settings.devlog_exported": "Salida de registro de desarrollador exportada.", + "settings.devtools_desc": "Estado, capacidades y seguimiento de auditoría del sidecar.", + "settings.devtools_title": "Herramientas de desarrollo", + "settings.diag_approval": "Aprobación: {mode} ({ms}ms)", + "settings.diag_config_path": "Ruta de configuración: {path}", + "settings.diag_daemon_url": "Demonio: {url}", + "settings.diag_default": "predeterminado", + "settings.diag_health_port": "Puerto sanitario: {port}", + "settings.diag_healthy_ms": "Saludable: {ms}ms", + "settings.diag_host_token_source": "Fuente del token de host: {source}", + "settings.diag_last_attempt": "Último intento: {time}", + "settings.diag_load_sessions_ms": "Sesiones de carga: {ms}ms", + "settings.diag_opencode_binary": "Binario de OpenCode: {binary}", + "settings.diag_opencode_url": "OpenCode: {url}", + "settings.diag_pending_permissions_ms": "Permisos pendientes: {ms}ms", + "settings.diag_pid": "PID: {pid}", + "settings.diag_providers_ms": "Proveedores: {ms}ms", + "settings.diag_read_only": "Solo lectura: {value}", + "settings.diag_reason": "Razón: {reason}", + "settings.diag_runtime_workspace": "Espacio de trabajo del runtime: {id}", + "settings.diag_selected_workspace": "Espacio de trabajo seleccionado: {id}", + "settings.diag_sidecar": "Sidecar: {info}", + "settings.diag_started": "Iniciado: {time}", + "settings.diag_token_source": "Fuente del token: {source}", + "settings.diag_total_ms": "Total: {ms}ms", + "settings.diag_version": "Versión: {version}", + "settings.diag_workspaces": "Espacios de trabajo: {count}", + "settings.diagnostics_unavailable": "Diagnóstico no disponible.", + "settings.disable_developer_mode": "Deshabilitar el modo de desarrollador", + "settings.disabled": "Desactivado", + "settings.disconnect": "Desconectar", + "settings.disconnect_confirm_suffix": "¿Desconectar {resolved}? Esto elimina las claves API almacenadas o las credenciales OAuth para este proveedor.", + "settings.disconnect_server": "Desconectar servidor", + "settings.disconnected_prefix": "{resolved} desconectado.", + "settings.disconnecting": "Desconectando...", + "settings.docker_containers_desc": "Eliminación forzada de contenedores de Docker iniciados por OpenWork.", + "settings.docker_containers_title": "Contenedores OpenWork Docker", + "settings.docker_requires_desktop": "La limpieza de Docker requiere la app de escritorio", + "settings.done": "Hecho", + "settings.downloading_bytes": "Descargando {downloaded}", + "settings.downloading_progress": "Descargando {downloaded} / {total} ({percent}%)", + "settings.enable_developer_mode": "Habilitar el modo de desarrollador", + "settings.enable_exa": "Habilitar la búsqueda web Exa", + "settings.enable_exa_desc": "Se aplica cuando OpenWork Orchestrator inicia OpenCode. Está desactivado por defecto.", + "settings.enabled": "Activado", + "settings.engine_bundled": "Incluido (recomendado)", + "settings.engine_bundled_hint": "El motor integrado es la opción más confiable. Usa Sistema solo si lo necesitas.", + "settings.engine_custom_binary": "Binario personalizado", + "settings.engine_desc": "Elige cómo se ejecuta OpenCode localmente.", + "settings.engine_runtime_label": "Runtime del motor", + "settings.engine_source": "Fuente del motor", + "settings.engine_source_debug": "Fuente del motor", + "settings.engine_system_path": "Instalación del sistema (PATH)", + "settings.engine_title": "Motor", + "settings.environment.add_button": "Add variable", + "settings.environment.add_title": "Add environment variable", + "settings.environment.apply_button": "Apply changes", + "settings.environment.apply_blocked_active_tasks": "Stop running tasks before applying environment changes.", + "settings.environment.apply_confirm_body": "OpenWork will restart local agents so they can use the latest environment. Running local tasks may stop.", + "settings.environment.apply_no_local_workspace": "OpenWork is not connected to a local workspace.", + "settings.environment.apply_pending_body": "Apply changes to restart local agents and make the latest values available.", + "settings.environment.apply_pending_body_manual": "Restart local agents to make the latest values available.", + "settings.environment.apply_pending_title": "Changes are saved, not active yet", + "settings.environment.apply_refresh_failed": "Changes are active, but OpenWork status did not refresh. Reopen the app if it looks stale.", + "settings.environment.apply_success": "Environment changes are active.", + "settings.environment.apply_title": "Apply environment changes?", + "settings.environment.apply_unavailable": "Apply changes is only available in the desktop app.", + "settings.environment.applying": "Applying…", + "settings.environment.cancel": "Cancel", + "settings.environment.click_to_edit": "Click to edit", + "settings.environment.close_editor": "Close editor", + "settings.environment.confirm_delete": "Delete {key}? Agents stop seeing this key after you apply changes.", + "settings.environment.delete": "Delete", + "settings.environment.delete_title": "Delete environment variable", + "settings.environment.delete_variable": "Delete {key}", + "settings.environment.deleting": "Deleting…", + "settings.environment.description": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device; changes become available after you apply them.", + "settings.environment.edit_title": "Edit environment variable", + "settings.environment.empty_body": "Add keys like ANTHROPIC_API_KEY, GOOGLE_API_KEY, ELEVENLABS_API_KEY, or GITHUB_TOKEN for services your agents and MCP servers need.", + "settings.environment.empty_title": "No environment variables yet", + "settings.environment.empty_value": "(empty)", + "settings.environment.footer_hint": "OPENWORK_ and OPENCODE_ keys are reserved for app/runtime wiring. Configure OpenCode runtime settings from your shell.", + "settings.environment.hide": "Hide", + "settings.environment.hide_value": "Hide value for {key}", + "settings.environment.key_hint": "Letters, digits, and underscores. Cannot start with a digit.", + "settings.environment.key_label": "Key", + "settings.environment.loading": "Loading…", + "settings.environment.override_hint": "Environment variables set before OpenWork starts take precedence over values saved here.", + "settings.environment.remote_workspace_hint": "This workspace is remote. Local environment variables are hidden here; use cloud LLM Providers or configure the worker host directly.", + "settings.environment.restart_required": "Saved. Apply changes to make the update available.", + "settings.environment.reveal": "Reveal", + "settings.environment.reveal_value": "Reveal value for {key}", + "settings.environment.save": "Save", + "settings.environment.saving": "Saving…", + "settings.environment.title": "Environment variables", + "settings.environment.validation_duplicate": "A variable with this name already exists.", + "settings.environment.validation_empty": "Name is required.", + "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", + "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", + "settings.environment.value_label": "Value", + "settings.exa_restart_hint": "Reinicia OpenCode o el orquestador después de cambiar esta configuración.", + "settings.export": "Exportar", + "settings.export_failed": "No se ha podido exportar el informe del runtime.", + "settings.export_unavailable": "La exportación no está disponible en este entorno.", + "settings.exported_debug_report": "Informe JSON del runtime exportado.", + "settings.failed": "Fallido", + "settings.failed_open_providers": "No se pudieron abrir los proveedores", + "settings.feedback_badge": "Leemos cada mensaje", + "settings.feedback_desc": "Cuéntanos qué se siente genial y qué se siente difícil. Tus comentarios van directamente al equipo y nos ayudan a priorizar lo próximo que lancemos.", + "settings.feedback_title": "Ayuda a dar forma a OpenWork", + "settings.group_global": "Global", + "settings.group_workspace": "Espacio de trabajo", + "settings.hide_titlebar": "Ocultar barra de título", + "settings.hide_titlebar_desc": "Oculta la barra de título de la ventana. Útil para mosaico de ventanas", + "settings.join_discord": "Únete a Discord", + "settings.language": "Idioma", + "settings.language.description": "Elige tu idioma preferido", + "settings.last_error": "Último error", + "settings.last_stderr": "Último stderr", + "settings.last_stdout": "Última salida estándar", + "settings.loading_providers": "Cargando proveedores...", + "settings.logs_on_host": "Los registros están disponibles en el host.", + "settings.managed_by_env": "Gestionado por variables de entorno", + "settings.messaging_bridge_service": "Servicio puente de mensajería.", + "settings.messaging_section_desc": "Gestiona identidades y conexiones de Telegram/Slack en la pestaña Identidades.", + "settings.messaging_section_title": "Mensajería", + "settings.model": "Modelo", + "settings.model_behavior": "Comportamiento del modelo", + "settings.model_behavior_desc": "Abre el selector del modelo predeterminado para elegir perfiles de razonamiento cuando estén disponibles.", + "settings.model_default": "Por defecto", + "settings.model_description": "Valores predeterminados y controles de razonamiento para las ejecuciones.", + "settings.model_description_default": "Elige entre tus proveedores configurados. Esta selección se usará en las nuevas sesiones.", + "settings.model_description_session": "Elige entre tus proveedores configurados. Esta selección se aplicará a tu próximo mensaje.", + "settings.model_fallback": "Alternativa", + "settings.model_reasoning": "Razonamiento", + "settings.model_section_desc": "Elige el modelo de chat predeterminado y revisa cómo razona.", + "settings.model_title": "Modelo", + "settings.no_access": "Sin acceso", + "settings.no_active_workspace": "No hay espacio de trabajo local activo.", + "settings.no_audit_entries": "Aún no hay entradas de auditoría.", + "settings.no_binary_selected": "No se seleccionó ningún binario.", + "settings.no_custom_path_set": "No se ha establecido ninguna ruta personalizada", + "settings.no_project_directory": "Sin directorio de proyectos", + "settings.no_stderr": "Aún no se ha capturado ningún stderr.", + "settings.no_stdout": "Aún no se ha capturado ninguna salida estándar.", + "settings.no_worker_directory": "Sin directorio de proyectos", + "settings.no_worker_path": "No hay ninguna ruta de worker disponible", + "settings.nuke_confirm_dev": "Esto es irreversible. Eliminará todos los datos de OpenWork para esta compilación de desarrollo y todas las configuraciones, autenticación, caché, datos y estado de desarrollo de OpenCode aislados, luego saldrá de OpenWork. ¿Continuar?", + "settings.nuke_confirm_prod": "Esto es irreversible. Eliminará todos los datos de OpenWork para esta compilación de desarrollo y todas las configuraciones, autenticación, caché, datos y estado de desarrollo de OpenCode aislados, luego saldrá de OpenWork. ¿Continuar?", + "settings.nuke_failed": "No se pudo eliminar el estado de OpenWork y OpenCode.", + "settings.nuke_hint": "Úsalo solo si quieres restablecer del todo la app de escritorio y el estado del runtime de OpenCode.", + "settings.nuke_success": "Se eliminaron los estados OpenWork y OpenCode. OpenWork está cerrando...", + "settings.off": "Desactivado", + "settings.offline": "Desconectado", + "settings.on": "Activado", + "settings.open_deeplink_action": "Abriendo...", + "settings.open_deeplink_button": "Abrir", + "settings.open_deeplink_desc": "Pega un deep link de OpenWork o una URL compartida para abrirla.", + "settings.open_deeplink_title": "Abrir deep link", + "settings.opencode_cache": "Caché de OpenCode", + "settings.opencode_cache_description": "Repara los datos en caché que se usan para iniciar el motor. Es seguro ejecutarlo.", + "settings.opencode_engine_desc": "Entorno de ejecución local para agentes, herramientas y proveedores de modelos.", + "settings.opencode_engine_label": "Motor de OpenCode", + "settings.opencode_engine_sidecar_desc": "Sidecar del runtime local.", + "settings.opencode_sdk_desc": "Diagnósticos de conexión de la interfaz.", + "settings.opencode_sdk_title": "Motor de OpenCode", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "URL base no disponible", + "settings.opening": "Abrir deep link", + "settings.openwork_config_sidecar_desc": "Sidecar de configuración y aprobaciones.", + "settings.openwork_diagnostics_title": "Diagnóstico del servidor de OpenWork", + "settings.openwork_server_desc": "Plano de control de sesiones para la sincronización de la app, los workers y el acceso remoto", + "settings.openwork_server_label": "Servidor de OpenWork", + "settings.pending_permissions": "Permisos pendientes", + "settings.production_mode_badge": "Producción", + "settings.provider_default_desc": "Usa el comportamiento de razonamiento predeterminado integrado del modelo.", + "settings.provider_default_label": "Proveedor predeterminado", + "settings.provider_source_config": "Configuración", + "settings.provider_source_custom": "Personalizado", + "settings.provider_source_env": "Entorno", + "settings.providers_desc": "Servicios de conexión para modelos y herramientas.", + "settings.providers_title": "Proveedores", + "settings.quit_hint": "OpenWork se cierra inmediatamente después de la limpieza, por lo que el siguiente inicio comienza desde un estado local en blanco para este modo.", + "settings.recent_events": "Eventos recientes", + "settings.reconnect_failed": "No se ha podido volver a conectar. Comprueba la URL y el token del servidor, y vuelve a intentarlo.", + "settings.reconnect_server": "Reconectando...", + "settings.reconnect_server_failed": "No se pudo volver a conectar al servidor de OpenWork.", + "settings.reconnected": "Reconectado al servidor de OpenWork.", + "settings.reconnecting": "Reconectando...", + "settings.removing_containers": "Quitando contenedores...", + "settings.removing_local_state": "Eliminando estado local...", + "settings.repair_cache": "Reparar caché", + "settings.repairing_cache": "Reparando caché", + "settings.report_issue": "Informar un problema", + "settings.reset": "Reiniciar", + "settings.reset_app_data": "Restablecer datos de la aplicación", + "settings.reset_app_data_description": "Más agresivo. Borra el caché del OpenWork y los datos de la aplicación.", + "settings.reset_app_data_title": "Restablecer datos de la aplicación", + "settings.reset_app_data_warning": "Borra el caché del OpenWork y los datos de la aplicación en este dispositivo.", + "settings.reset_button": "Reiniciar", + "settings.reset_cancel": "Cancelar", + "settings.reset_config_defaults": "Restableciendo...", + "settings.reset_config_failed": "No se pudo restablecer la configuración de la aplicación.", + "settings.reset_confirm_button": "Restablecer y reiniciar", + "settings.reset_confirmation_hint": "Escriba {resetWord} para confirmar. OpenWork se reiniciará.", + "settings.reset_confirmation_label": "Confirmación", + "settings.reset_confirmation_placeholder": "Tipo RESET", + "settings.reset_onboarding": "Restablecer incorporación", + "settings.reset_onboarding_description": "Borra las preferencias de OpenWork y reinicia la aplicación.", + "settings.reset_onboarding_title": "Restablecer incorporación", + "settings.reset_onboarding_warning": "Borra las preferencias locales de OpenWork y los marcadores de incorporación de espacio de trabajo.", + "settings.reset_openwork_desc_dev": "Con el modo de desarrollo activo, solo borra el estado de desarrollo aislado de OpenCode dentro de openwork-dev-data.", + "settings.reset_openwork_desc_prod": "Con el modo de desarrollo activo, solo borra el estado de desarrollo aislado de OpenCode dentro de openwork-dev-data.", + "settings.reset_openwork_title": "Restablecer el estado de OpenWork + OpenCode", + "settings.reset_recovery_desc": "Borre los datos o reinicie el flujo de configuración.", + "settings.reset_recovery_title": "Restablecimiento y recuperación", + "settings.reset_requires_confirm": "Requiere escribir RESET y reiniciará la aplicación.", + "settings.reset_startup": "Restablecer el modo de inicio predeterminado", + "settings.reset_startup_pref": "Restablecer preferencia de inicio", + "settings.reset_stop_active_runs": "Detenga las ejecuciones activas antes de reiniciar.", + "settings.resetting": "Restableciendo...", + "settings.restart_blocked_message": "OpenWork necesita reiniciarse para finalizar esta actualización. Para evitar interrumpir su trabajo actual, la instalación se pausa hasta que finalicen las ejecuciones activas o usted las detenga.", + "settings.restart_failed": "El reinicio falló. Verifique los registros e inténtelo nuevamente.", + "settings.restart_opencode": "Reiniciando...", + "settings.restart_openwork_server": "Reiniciando...", + "settings.restart_server_failed": "No se pudo reiniciar el servidor local.", + "settings.restarted": "Servidor local reiniciado.", + "settings.restarting": "Reiniciando...", + "settings.reveal_config": "Mostrar configuración", + "settings.reveal_config_failed": "No se pudo revelar la configuración de espacio de trabajo.", + "settings.reveal_config_requires_desktop": "La configuración de revelación requiere la app de escritorio", + "settings.revealed_workspace_config": "Se mostró la configuración del espacio de trabajo.", + "settings.run_sandbox_probe": "Ejecutar prueba del sandbox", + "settings.running_probe": "Ejecutando prueba...", + "settings.runtime_applies_hint": "Aplica la próxima vez que el motor arranque o se recargue.", + "settings.runtime_debug_desc": "Instantánea de diagnóstico legible con exportación con un solo clic.", + "settings.runtime_debug_title": "Informe de depuración del runtime", + "settings.runtime_desc": "Estado de tu motor local y del servidor de OpenWork.", + "settings.runtime_direct": "Direct (OpenCode)", + "settings.runtime_title": "Runtime", + "settings.sandbox_error": "Error", + "settings.sandbox_export_hint": "Usa Exportar en el informe de depuración del runtime para compartirlo con soporte.", + "settings.sandbox_probe_desc": "Ejecuta una comprobación temporal del arranque del sandbox de Docker y registra el resultado en el informe de depuración.", + "settings.sandbox_probe_errors": "Sonda Sandbox completada con errores.", + "settings.sandbox_probe_failed": "Falló la sonda Sandbox.", + "settings.sandbox_probe_success": "La prueba del sandbox se completó correctamente. Exporta el informe de depuración si necesitas ayuda.", + "settings.sandbox_probe_title": "Prueba del sandbox", + "settings.sandbox_ready": "Listo", + "settings.sandbox_requires_desktop": "La sonda Sandbox requiere una app de escritorio", + "settings.sandbox_result": "Resultado: {status}", + "settings.sandbox_run_id": "ID de ejecución: {id}", + "settings.sandbox_stop_runs_hint": "Detenga las ejecuciones activas antes de sondear", + "settings.search_models": "Buscar modelos…", + "settings.select_binary": "Selecciona binario OpenCode", + "settings.select_workspace_first": "Selecciona un espacio de trabajo local antes de revelar la configuración.", + "settings.send_feedback": "Enviar comentarios", + "settings.service_restarts_desc": "Reinicia servicios concretos del host sin salir de aquí.", + "settings.service_restarts_title": "Reinicio de servicios", + "settings.session_model": "Modelo", + "settings.show_model_reasoning": "Mostrar razonamiento del modelo", + "settings.show_model_reasoning_desc": "Amplíe las trazas de razonamiento en la interfaz de usuario cuando un modelo las exponga.", + "settings.showing_models": "Mostrando {count} de {total}", + "settings.sidecar_config_unavailable": "Configuración de sidecar no disponible", + "settings.startup": "Inicio", + "settings.startup_local": "Iniciar servidor local", + "settings.startup_not_set": "Conectar al servidor", + "settings.startup_remote_warning": "La preferencia de inicio está configurada actualmente en remoto. Ajusta la configuración del motor si hace falta.", + "settings.startup_reset_hint": "Esto borra la preferencia guardada y vuelve a mostrar la pantalla de conexión.", + "settings.startup_server": "Conectar al servidor", + "settings.startup_title": "Puesta en marcha", + "settings.stop_local_server": "Detener el servidor local", + "settings.stop_runs_before_cleanup": "Detener ejecuciones activas antes de la limpieza", + "settings.stop_runs_before_reset_config": "Detenga las ejecuciones activas antes de restablecer la configuración", + "settings.stop_runs_to_reset": "Detener ejecuciones activas para restablecer", + "settings.switch": "Cambiar", + "settings.tab_advanced": "Avanzado", + "settings.tab_appearance": "Apariencia", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "Depurar", + "settings.tab_description_advanced": "Inspecciona el estado del runtime, el estado de la conexión y los controles orientados a desarrolladores.", + "settings.tab_description_appearance": "Ajusta la apariencia de OpenWork en el escritorio, el tema del sistema y la app de Chrome.", + "settings.tab_description_debug": "Revisa diagnósticos, registros y utilidades avanzadas de depuración del runtime.", + "settings.tab_description_den": "Gestiona tu conexión con OpenWork Cloud y tu acceso alojado a workers y espacios de trabajo.", + "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", + "settings.tab_description_extensions": "Gestiona aplicaciones MCP y Plugins de OpenCode para este espacio de trabajo.", + "settings.tab_description_general": "Conecta proveedores, elige el modelo predeterminado, autoriza carpetas y controla el espacio de trabajo seleccionado junto con su conexión de runtime.", + "settings.tab_description_messaging": "Configura identidades del router y el comportamiento de la bandeja de entrada desde la configuración del espacio de trabajo.", + "settings.tab_description_model": "Ajusta el modelo predeterminado, el comportamiento del runtime y la configuración de salida del asistente.", + "settings.tab_description_recovery": "Repara el estado de migración, restablece los valores predeterminados del espacio de trabajo y recupera la configuración local.", + "settings.tab_description_skills": "Explora, edita e instala Skills sin salir de la configuración.", + "settings.tab_description_updates": "Mantén la app actualizada con comprobaciones silenciosas en segundo plano y controles de instalación.", + "settings.tab_environment": "Environment", + "settings.tab_extensions": "Extensiones", + "settings.tab_general": "General", + "settings.tab_messaging": "Mensajería", + "settings.tab_model": "Modelo", + "settings.tab_recovery": "Recuperación", + "settings.tab_skills": "Skills", + "settings.tab_updates": "Actualizaciones", + "settings.theme_dark": "Oscuro", + "settings.theme_light": "Claro", + "settings.theme_system": "Sistema", + "settings.theme_system_hint": "El modo Sistema sigue automáticamente las preferencias de tu sistema operativo.", + "settings.toolbar_ready_to_install": "Listo para instalar", + "settings.update": "Actualizar", + "settings.update_available": "Actualización disponible: v", + "settings.update_available_version": "Actualización disponible: v{version}", + "settings.update_check_button": "Buscar", + "settings.update_check_failed": "Error en la comprobación de actualización", + "settings.update_checking": "Buscando...", + "settings.update_download_button": "Descargar", + "settings.update_downloading": "Descargando...", + "settings.update_error": "Error en la comprobación de actualización", + "settings.update_install_button": "Instalar y reiniciar", + "settings.update_last_checked": "Última comprobación {time}", + "settings.update_published": "Publicado {date}", + "settings.update_ready": "Listo para instalar: v", + "settings.update_ready_version": "Listo para instalar: v{version}", + "settings.update_uptodate": "Al día", + "settings.updates": "Actualizaciones", + "settings.updates_desc": "Mantén OpenWork actualizado.", + "settings.updates_desktop_only": "Las actualizaciones solo están disponibles en la app de escritorio.", + "settings.updates_not_supported": "Las actualizaciones no son compatibles en este entorno.", + "settings.updates_title": "Actualizaciones", + "settings.version": "Versión", + "settings.versions_desc": "Información de versión de los sidecars y la app de escritorio.", + "settings.versions_title": "Versiones", + "settings.window_appearance_desc": "Personaliza la apariencia de la ventana.", + "settings.worker_id_label": "Worker {id}", + "settings.worker_unresolved": "Worker {entorno de ejecuciónWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "Configuración del espacio de trabajo", + "settings.workspace_debug_events_label": "Eventos de depuración del espacio de trabajo", + "settings.workspace_fallback_name": "workspace", + "share.active_cloud_org": "Organización activa Cloud", + "share.back_hint": "Volver a compartir opciones", + "share.chooser_subtitle": "Elige cómo quieres compartir este espacio de trabajo.", + "share.close_hint": "Cerrar", + "share.cloud_signin_note": "OpenWork Cloud se abre en tu navegador y vuelve aquí después de iniciar sesión.", + "share.collaborator_hint": "Acceso de rutina sin aprobaciones de permisos.", + "share.connect_messaging_desc": "Usa este espacio de trabajo desde Slack, Telegram y otros canales.", + "share.connect_messaging_title": "Conectar mensajería", + "share.connection_details_label": "Detalles de conexión", + "share.copy_hint": "Copiar", + "share.copy_link_hint": "Copiar enlace", + "share.create_template_link": "Crear enlace de plantilla", + "share.credentials_disabled_hint": "Activa el acceso remoto y haz clic en Guardar para reiniciar el worker y mostrar los datos de conexión en vivo de este espacio de trabajo.", + "share.field_password": "Contraseña", + "share.field_worker_url": "URL del worker", + "share.hide_password": "Ocultar contraseña", + "share.included_in_template": "Incluido en esta plantilla", + "share.option_access_desc": "Muestra los datos de conexión en vivo necesarios para acceder a este espacio de trabajo en ejecución desde otra máquina.", + "share.option_access_title": "Acceder al espacio de trabajo en remoto", + "share.option_public_desc": "Crea un enlace compartido que cualquiera pueda usar para empezar desde esta plantilla.", + "share.option_public_title": "Plantilla pública", + "share.option_team_title": "Compartir con el equipo", + "share.option_template_desc": "Empaqueta esta configuración para que otra persona pueda empezar desde el mismo entorno.", + "share.optional_collaborator": "Acceso de colaborador opcional", + "share.public_intro": "Comparte este espacio de trabajo como enlace de plantilla pública.", + "share.publishing": "Publicando...", + "share.regenerate_link": "Regenerar enlace", + "share.remote_access_desc": "Está desactivado por defecto. Actívalo solo cuando quieras permitir el acceso a este worker desde otra máquina.", + "share.remote_access_disabled": "El acceso remoto está actualmente deshabilitado.", + "share.remote_access_enabled": "El acceso remoto está actualmente habilitado.", + "share.remote_access_title": "Acceso remoto", + "share.remote_save": "Guardar", + "share.remote_save_busy": "Guardando...", + "share.reveal_password": "Revelar contraseña", + "share.save_to_team": "Guardar en equipo", + "share.saving": "Guardando...", + "share.setup": "Configuración", + "share.sign_in_to_share": "Inicia sesión para compartir con el equipo", + "share.subtitle_access": "Muestra los datos de conexión en vivo necesarios para acceder a este espacio de trabajo desde otra máquina.", + "share.team_intro": "Guarda esta plantilla en tu organización activa de OpenWork Cloud para que tu equipo pueda abrirla más tarde desde los ajustes de Cloud.", + "share.template_intro": "Comparte una configuración reutilizable sin otorgar acceso en vivo a este espacio de trabajo en ejecución.", + "share.template_item_config": "Commands y configuración", + "share.template_item_config_desc": "Commands reutilizables más configuración OpenWork/OpenCode.", + "share.template_item_settings": "Configuración del espacio de trabajo", + "share.template_item_settings_desc": "El perfil compartido del espacio de trabajo y el comportamiento predeterminado.", + "share.template_item_skills": "Skills incluidas", + "share.template_item_skills_desc": "Skills personalizadas guardadas en este espacio de trabajo.", + "share.template_name_label": "Nombre de la plantilla", + "share.title": "Compartir espacio de trabajo", + "share.view_access": "Acceder al espacio de trabajo en remoto", + "share.warning_basic": "Comparte solo con personas de confianza. Estas credenciales otorgan acceso en vivo a este espacio de trabajo.", + "share.warning_full": "Estas credenciales otorgan acceso en vivo a este espacio de trabajo. Compartirlo de forma remota puede permitir que cualquier persona con acceso a tu red controle tu worker.", + "share.workspace_fallback": "workspace", + "share.workspace_template_desc": "Comparte la configuración principal y los valores predeterminados del espacio de trabajo.", + "share.workspace_template_title": "Plantilla de espacio de trabajo", + "share_skill_destination.add_to_workspace": "Añadir al espacio de trabajo", + "share_skill_destination.adding": "Añadiendo...", + "share_skill_destination.confirm_busy": "Añadiendo Skill...", + "share_skill_destination.confirm_button": "Añadir Skill al espacio de trabajo", + "share_skill_destination.connect_remote": "Conectar espacio de trabajo remoto", + "share_skill_destination.connect_remote_desc": "Conecta un host de OpenWork y luego elígelo de la lista para importar esta Skill.", + "share_skill_destination.connect_remote_hint": "Conecta un espacio de trabajo remoto para usarlo como destino.", + "share_skill_destination.create_worker": "Crear nuevo espacio de trabajo", + "share_skill_destination.create_worker_desc": "Abre el flujo de configuración del espacio de trabajo y añade esta Skill cuando el nuevo espacio de trabajo esté listo.", + "share_skill_destination.create_worker_hint": "Crea un espacio de trabajo nuevo y luego añade esta Skill.", + "share_skill_destination.current_badge": "Actual", + "share_skill_destination.existing_workers": "Espacios de trabajo existentes", + "share_skill_destination.fallback_skill_name": "Skill compartida", + "share_skill_destination.footer_idle": "Elige un espacio de trabajo para continuar.", + "share_skill_destination.footer_selected": "Espacio de trabajo seleccionado:", + "share_skill_destination.local_badge": "Local", + "share_skill_destination.more_options": "Más opciones", + "share_skill_destination.new_destination": "Nuevo destino", + "share_skill_destination.no_workers": "Todavía no hay espacios de trabajo listos. Crea uno o conecta un espacio de trabajo remoto para instalar esta Skill.", + "share_skill_destination.remote_badge": "Remoto", + "share_skill_destination.sandbox_badge": "Sandbox", + "share_skill_destination.selected_badge": "Seleccionado", + "share_skill_destination.selected_hint": "Seleccionado. Revisa el destino de abajo y luego confirma.", + "share_skill_destination.skill_label": "Skill compartida", + "share_skill_destination.subtitle": "Elige un espacio de trabajo existente o crea uno nuevo antes de importar esta Skill compartida.", + "share_skill_destination.title": "¿A dónde debería ir esta Skill?", + "share_skill_destination.trigger_label": "Activador", + "sidebar.active": "Activo", + "sidebar.add_workspace": "Añadir nuevo espacio de trabajo", + "sidebar.collapse": "Colapsar", + "sidebar.connect_remote": "Conectar en remoto", + "sidebar.delete_session": "Eliminar sesión", + "sidebar.drag_reorder": "Arrastra para reordenar", + "sidebar.edit_connection": "Editar conexión", + "sidebar.expand": "Expandir", + "sidebar.import_config": "Importar configuración", + "sidebar.needs_attention": "necesita atención", + "sidebar.new_worker": "Nuevo worker", + "sidebar.no_workspaces": "Todavía no hay espacios de trabajo en esta sesión. Añade uno para empezar.", + "sidebar.progress": "Progreso", + "sidebar.show_fewer": "Mostrar menos", + "sidebar.show_more": "Mostrar {count} más", + "sidebar.stop_sandbox": "Detener la zona de pruebas", + "sidebar.switch": "Cambiar", + "sidebar.test_connection": "Probar conexión", + "skills.add_custom_repo": "Añadir repositorio GitHub personalizado", + "skills.add_git_repo": "Añadir repositorio Git", + "skills.add_openwork_hub": "Añadir OpenWork Hub", + "skills.available_from_hub": "Disponible desde Hub", + "skills.catalog_search_placeholder": "Buscar Skills instaladas, del equipo y de hubs", + "skills.cloud_add_skill": "Añadir Skill", + "skills.cloud_choose_org_detail": "Usa el panel de Cloud para elegir tu organización activa y luego actualiza esta lista.", + "skills.cloud_choose_org_hint": "Elige una organización en Configuración → Cloud para cargar Skills de equipo.", + "skills.cloud_footer_label": "Equipo", + "skills.cloud_hub_label": "Hub: {name}", + "skills.cloud_install_need_server": "Conéctate a un servidor de OpenWork con acceso de escritura para Skills para instalar Skills del equipo en este worker.", + "skills.cloud_installed": "Se instaló {name} en este worker.", + "skills.cloud_installed_as": "Instalado como {name}", + "skills.cloud_installing": "Instalando {title}…", + "skills.cloud_installing_short": "Instalando", + "skills.cloud_no_search_matches": "Ninguna Skill coincide con esa búsqueda.", + "skills.cloud_org_empty": "Aún no hay Skills de organización disponibles.", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "No se pudieron cargar las Skills de organización.", + "skills.cloud_refresh": "Actualizar las Skills del equipo", + "skills.cloud_section_subtitle": "Skills compartidas contigo a través de OpenWork Cloud, incluidos hubs de Skills del equipo a los que tienes acceso.", + "skills.cloud_section_title": "De tu organización", + "skills.cloud_shared_org": "organización", + "skills.cloud_shared_private": "Privado", + "skills.cloud_shared_public": "Público", + "skills.cloud_sign_in": "Iniciar sesión en Cloud", + "skills.cloud_sign_in_hint": "Inicia sesión en OpenWork Cloud para explorar las Skills de equipo y organización.", + "skills.cloud_status_installed": "Instalado", + "skills.cloud_status_update": "Actualización disponible", + "skills.cloud_update_skill": "Actualizar", + "skills.cloud_updated": "{name} actualizado en este worker.", + "skills.cloud_updating": "Actualizando {title}…", + "skills.cloud_removed": "Se eliminó la Skill de Cloud local {name}.", + "skills.copy_link_failed": "No se pudo copiar el enlace", + "skills.create_in_chat": "Crear Skill en el chat", + "skills.desktop_required": "La gestión de Skills requiere la app de escritorio.", + "skills.enter_plugin_name": "Introduce un nombre de paquete de Plugin.", + "skills.failed_load_active": "No se pudieron cargar los Plugins activos.", + "skills.failed_load_opencode": "No se pudo cargar opencode.json", + "skills.failed_parse_opencode": "No se pudo analizar opencode.json", + "skills.failed_to_load": "No se pudieron cargar las Skills", + "skills.failed_update_opencode": "No se pudo actualizar opencode.json", + "skills.filter_all": "Todo", + "skills.filter_cloud": "Equipo", + "skills.filter_hub": "Hub", + "skills.filter_installed": "Instalado", + "skills.from_repo": "Desde {owner}/{repo}", + "skills.github_repo_hint": "Introduce un repositorio de GitHub con el formato `owner/repo`.", + "skills.host_mode_only": "Solo espacio de trabajo local", + "skills.host_only_error": "La gestión del Skill requiere un espacio de trabajo local o un servidor de OpenWork conectado.", + "skills.hub_desc": "Explora Skills compartidas desde hubs respaldados por GitHub y agrégalas a este worker.", + "skills.hub_label": "Hub", + "skills.import": "Importar", + "skills.import_failed": "Error de importación ({status})", + "skills.import_local": "Importar Skill local", + "skills.import_local_hint": "Copia una carpeta de Skills existente en este espacio de trabajo.", + "skills.import_local_skill": "Importar Skill local", + "skills.imported": "Importado.", + "skills.install": "Instalar", + "skills.install_failed": "La instalación de Skill falló.", + "skills.install_name_title": "Instalar {name}", + "skills.install_skill_creator": "Instalar creador de Skills", + "skills.install_skill_creator_hint": "Esta Skill te permite crear otras Skills desde el chat.", + "skills.installed": "Skills instaladas", + "skills.installed_desc": "Las Skills instaladas están disponibles en este worker y se pueden editar o compartir.", + "skills.installed_label": "Instalado", + "skills.installed_status": "Instalado", + "skills.installing": "Añadir Skill", + "skills.installing_prefix": "Instalando {name}…", + "skills.installing_skill_creator": "Instalando creador de Skills...", + "skills.link_copied": "Enlace copiado", + "skills.loading": "Cargando…", + "skills.no_description": "Aún no hay descripción.", + "skills.no_hub_repo_label": "No se seleccionó ningún repositorio central", + "skills.no_hub_repo_selected": "No hay Skills de hub disponibles.", + "skills.no_hub_skills": "No has seleccionado ningún repositorio central. Añade un repositorio GitHub para explorar Skills.", + "skills.no_opencode_found": "Todavía no se ha encontrado opencode.json. Añade un Plugin para crear uno.", + "skills.no_opencode_workspace": "Aún no hay opencode.json en este espacio de trabajo.", + "skills.no_skills": "No se detectaron Skills en `.opencode/skills`, `.claude/skills` o `~/.agents/skills`.", + "skills.no_skills_found": "Aún no se han encontrado Skills.", + "skills.owner_label": "Dueño", + "skills.owner_repo_required": "Hacen falta propietario y repositorio.", + "skills.pick_project_first": "Elige primero una carpeta de proyecto.", + "skills.pick_project_for_active": "Elige una carpeta de proyecto para cargar Plugins activos.", + "skills.pick_project_for_plugins": "Elige una carpeta de proyecto para administrar los Plugins del proyecto.", + "skills.pick_workspace_first": "Elige primero una carpeta del espacio de trabajo.", + "skills.plugin_already_listed": "Plugin ya figura en opencode.json.", + "skills.plugin_management_host_only": "La administración de Plugin requiere la app de escritorio.", + "skills.plugins_host_only": "Plugins solo están disponibles en la app de escritorio.", + "skills.ref_label": "Ref (sucursal/tag/commit)", + "skills.refresh": "Actualizar", + "skills.refresh_hub": "Actualizar centro", + "skills.refresh_hub_title": "Actualizar el catálogo central", + "skills.remove_saved_repo": "Eliminar repositorio guardado", + "skills.repo_label": "Repositorio", + "skills.reveal_failed": "No se pudo abrir la carpeta de Skills.", + "skills.reveal_folder": "Abrir carpeta de Skills", + "skills.reveal_folder_hint": "Abre el directorio de Skills en Finder.", + "skills.save_and_load": "Guardar y cargar", + "skills.save_failed": "No se pudo guardar la Skill.", + "skills.select_skill_folder": "Seleccionar carpeta de Skills", + "skills.share_back": "Atrás", + "skills.share_chooser_subtitle": "Guárdala en tu organización de OpenWork Cloud o publica un enlace público de instalación.", + "skills.share_close": "Cerrar", + "skills.share_copy_link": "Copiar", + "skills.share_done": "Hecho", + "skills.share_option_public_desc": "Crea un enlace que cualquiera pueda usar para instalar esta Skill.", + "skills.share_option_public_title": "Enlace público", + "skills.share_option_team_desc": "Añade esta Skill a tu organización activa de OpenWork Cloud.", + "skills.share_option_team_title": "Compartir con el equipo", + "skills.share_public_create": "Crear enlace", + "skills.share_public_creating": "Publicación…", + "skills.share_public_intro": "Publica un enlace público. Cualquiera que tenga la URL podrá instalar esta Skill.", + "skills.share_public_regenerate": "Regenerar enlace", + "skills.share_publisher_label": "Editor", + "skills.share_subtitle_public": "Cualquiera que tenga el enlace puede instalar esta Skill.", + "skills.share_subtitle_team": "Se guarda en tu organización para que tu equipo pueda instalarla.", + "skills.share_team_choose_org": "Elige una organización en Configuración → Cloud antes de compartirla con tu equipo.", + "skills.share_team_permissions_intro": "Sube esta Skill a tu organización OpenWork Cloud activa y decide quién puede verla.", + "skills.share_team_permissions_label": "Compartir permisos", + "skills.share_team_permission_org": "Solo organización: no en el centro", + "skills.share_team_permission_private": "Privado solo para mi", + "skills.share_team_hub_label": "Añadir al hub de Skills (opcional)", + "skills.share_team_hub_none": "Solo organización, no en un centro", + "skills.share_team_hubs_loading": "Cargando centros…", + "skills.share_team_intro": "Guarda esta Skill en tu organización activa para que tu equipo pueda instalarla desde Cloud.", + "skills.share_team_org_fallback": "Organización activa Cloud", + "skills.share_team_save": "Guardar en equipo", + "skills.share_team_saving": "Guardando…", + "skills.share_team_upload_and_save": "Subir y guardar", + "skills.share_team_uploading": "Subiendo…", + "skills.share_team_sign_in": "Inicia sesión para compartir con el equipo", + "skills.share_team_sign_in_hint": "OpenWork Cloud se abre en tu navegador. Vuelve aquí después de iniciar sesión.", + "skills.share_team_success": "Guardado en {org}. Tu equipo ya puede instalarlo desde las Skills de la organización.", + "skills.share_team_uploaded_success": "Subido a {org}. Las Skills de Cloud se actualizarán para tu cuenta.", + "skills.share_title": "Compartir Skill", + "skills.shown_count": "Se muestra {count}", + "skills.skill_creator_already_installed": "La Skill para crear Skills ya está instalada.", + "skills.skill_creator_installed": "Se instaló la Skill para crear Skills.", + "skills.skill_load_failed": "No se pudo cargar la Skill.", + "skills.source_label": "Fuente", + "skills.subtitle": "Gestiona las Skills para este espacio de trabajo.", + "skills.title": "Skills", + "skills.trigger_label": "Activador: {trigger}", + "skills.uninstall": "Desinstalar", + "skills.uninstall_failed": "No se pudo desinstalar la Skill.", + "skills.uninstall_title": "¿Desinstalar Skill?", + "skills.uninstall_warning": "Esto eliminará permanentemente la Skill `{name}` de tu espacio de trabajo.", + "skills.uninstalled": "Skill desinstalada.", + "skills.unknown_error": "Error desconocido", + "skills.worker_profile_desc": "Las Skills son las capacidades principales de este worker. Descúbrelas desde un Hub, gestiona las instaladas y crea nuevas directamente en el chat.", + "status.back": "Volver a la pantalla anterior", + "status.connected": "Conectado", + "status.connecting": "Conectando", + "status.creating_task": "Creando nueva tarea", + "status.creating_workspace": "Creando espacio de trabajo", + "status.developer_mode": "Modo desarrollador", + "status.disconnected": "Desconectado", + "status.disconnected_hint": "Abre la configuración para volver a conectarte.", + "status.disconnected_label": "Desconectado", + "status.disconnecting": "Desconectando", + "status.docs": "Docs", + "status.feedback": "Comentario", + "status.idle": "Inactivo", + "status.installing_opencode": "Instalación de OpenCode", + "status.limited_hint": "Vuelve a conectarte para recuperar todas las funciones de OpenWork.", + "status.limited_mcp_hint": "{count} MCP conectado · vuelve a conectarte para tenerlo todo", + "status.limited_mode": "Modo limitado", + "status.live": "En vivo", + "status.loading_session": "Cargando sesión", + "status.mcp_connected": "{count} MCP conectado", + "status.open_docs": "Abrir documentación", + "status.openwork_ready": "OpenWork listo", + "status.providers_connected": "Proveedor {count}{plural} conectado", + "status.ready_for_tasks": "Listo para nuevas tareas", + "status.reloading_engine": "Recarga del motor", + "status.restarting_engine": "Reiniciando el motor", + "status.running": "En ejecución", + "status.send_feedback": "Enviar comentarios", + "status.settings": "Ajustes", + "status.starting_engine": "Iniciando el motor", + "system.cache_repair_requires_desktop": "La reparación de caché requiere la app de escritorio.", + "system.docker_cleanup_requires_desktop": "La limpieza de Docker requiere la app de escritorio.", + "system.reload_body_agents": "OpenCode carga agentes al inicio. Vuelve a cargar el motor para que los agentes actualizados estén disponibles.", + "system.reload_body_commands": "OpenCode carga Commands al inicio. Vuelve a cargar el motor para que los Commands actualizados estén disponibles.", + "system.reload_body_config": "OpenCode lee opencode.json al inicio. Vuelve a cargar el motor para aplicar los cambios de configuración.", + "system.reload_body_default": "OpenWork detectó cambios que requieren recargar la instancia OpenCode.", + "system.reload_body_mcp": "OpenCode carga los servidores MCP al inicio. Vuelve a cargar el motor para activar la nueva conexión.", + "system.reload_body_mixed": "OpenWork detectó cambios de configuración de OpenCode. Vuelve a cargar el motor para aplicarlos.", + "system.reload_body_plugins": "OpenCode carga Plugins npm al inicio. Vuelve a cargar el motor para aplicar los cambios opencode.json.", + "system.reload_body_skills": "OpenCode puede almacenar en caché el descubrimiento y estado de Skills. Vuelve a cargar el motor para que las Skills recién instaladas estén disponibles.", + "system.reload_failed": "No se pudo recargar el motor.", + "system.reload_required": "Hace falta recargar", + "system.reload_unavailable": "La recarga no está disponible para este worker.", + "system.stop_active_runs_before_reset": "Detenga las ejecuciones activas antes de reiniciar.", + "system.stop_runs_before_update": "Detenga las ejecuciones activas antes de instalar una actualización.", + "system.updates_not_supported": "Las actualizaciones no son compatibles en este entorno.", + "time.hours_ago": "Hace {count}h", + "time.just_now": "En este momento", + "time.minutes_ago": "Hace {count}m", + "time.seconds_ago": "Hace {count}s", + "workspace.loading_tasks": "Cargando tareas...", + "workspace.local_badge": "Local", + "workspace.new_task_inline": "+ Nueva tarea", + "workspace.no_tasks": "Aún no hay tareas.", + "workspace.remote_badge": "Remoto", + "workspace.rename_description": "Actualiza el nombre que se muestra en la barra lateral.", + "workspace.rename_label": "Nombre del espacio de trabajo", + "workspace.rename_placeholder": "Equipo de diseño espacio de trabajo", + "workspace.rename_title": "Editar nombre espacio de trabajo", + "workspace.sandbox_badge": "Sandbox", + "workspace.selected": "Seleccionado", + "workspace.switch": "Cambiar", + "workspace.switching_status_connecting": "Comprobando tu conexión", + "workspace.switching_status_loading": "Cargando tareas recientes", + "workspace.switching_status_preparing": "Preparando las cosas", + "workspace.switching_subtitle": "Vamos a traerte tu trabajo reciente.", + "workspace.switching_title": "Abriendo {name}", + "workspace.switching_title_unknown": "Abriendo espacio de trabajo", + "workspace_list.add_workspace": "Añadir espacio de trabajo", + "workspace_list.connect_remote": "Conectar espacio de trabajo remoto", + "workspace_list.connecting": "Conectando...", + "workspace_list.delete_session": "Eliminar sesión", + "workspace_list.desktop_only_hint": "Crea espacios de trabajo locales en la app de escritorio.", + "workspace_list.edit_connection": "Editar conexión", + "workspace_list.edit_name": "Editar nombre", + "workspace_list.hide_child_sessions": "Ocultar sesiones secundarias", + "workspace_list.import_config": "Importar configuración", + "workspace_list.new_workspace": "Nuevo espacio de trabajo", + "workspace_list.recover": "Recuperar", + "workspace_list.remove_workspace": "Quitar espacio de trabajo", + "workspace_list.rename_session": "Cambiar nombre de la sesión", + "workspace_list.reveal_explorer": "Revelar en Explorar", + "workspace_list.reveal_finder": "Revelar en Finder", + "workspace_list.session_actions": "Acciones de sesión", + "workspace_list.share": "Compartir...", + "workspace_list.show_child_sessions": "Mostrar sesiones secundarias", + "workspace_list.show_more": "Mostrar {count} más", + "workspace_list.show_more_fallback": "Mostrar más", + "workspace_list.test_connection": "Probar conexión", + "workspace_list.workspace_fallback": "workspace", + "workspace_list.workspace_options": "Opciones de espacio de trabajo", + "workspace_sidebar.close_sidebar": "Cerrar barra lateral", + "workspace_sidebar.collapse_sidebar": "Contraer barra lateral", + "workspace_sidebar.configuration": "configuración", + "workspace_sidebar.expand_sidebar": "Expandir barra lateral", + "workspace_sidebar.extensions": "Extensiones", + "workspace_sidebar.messaging": "Mensajería", +} as const; diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts new file mode 100644 index 0000000000..f79a39fc0e --- /dev/null +++ b/apps/app/src/i18n/locales/fr.ts @@ -0,0 +1,2021 @@ +/** + * French translations (Français) + * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) are NOT translated + */ + +export default { + "app.compact_command_desc": "Résumez cette session pour réduire la taille du contexte.", + "app.connection_lost": "Connexion au serveur perdue. Veuillez recharger.", + "app.deep_link_auth_queued": "Lien profond d'authentification Cloud mis en file d'attente pour OpenWork.", + "app.deep_link_remote_queued": "Lien du worker distant mis en file d'attente. OpenWork devrait passer au flux de connexion.", + "app.error.choose_folder": "Choisissez un dossier pour continuer.", + "app.error.host_requires_local": "Sélectionnez un espace de travail local pour démarrer le moteur.", + "app.error.install_failed": "L'installation d'OpenCode a échoué. Voir les journaux ci-dessus.", + "app.error.pick_workspace_folder": "Choisissez d'abord un dossier d'espace de travail.", + "app.error.remote_base_url_required": "Ajoutez une URL de serveur pour continuer.", + "app.error.tauri_required": "Cette action nécessite l'environnement d'exécution de l'application de bureau OpenWork.", + "app.error_audit_load": "Échec du chargement du journal d'audit.", + "app.error_auth_failed": "Échec de l'authentification", + "app.error_auto_compact_scope": "La compaction automatique du contexte ne peut être modifiée que pour un espace de travail local ou un espace de travail de serveur OpenWork accessible en écriture.", + "app.error_cloud_signin": "Impossible de terminer la connexion à OpenWork Cloud.", + "app.error_command_not_resolved": "La commande n'a pas été résolue.", + "app.error_compact_empty": "Rien à compacter pour le moment.", + "app.error_compact_no_session": "Sélectionnez une session avec des messages avant d'exécuter /compact.", + "app.error_compact_no_session_id": "Sélectionnez une session avant de compacter.", + "app.error_connect_first": "Connectez-vous d'abord à ce worker avant d'appliquer des changements d'exécution.", + "app.error_connection_failed": "Échec de la connexion", + "app.error_connection_failed_url": "Échec de la connexion. Vérifiez l'URL et le jeton.", + "app.error_deep_link_unrecognized": "Ce lien n'est pas un lien profond OpenWork ou une URL de partage reconnu.", + "app.error_desktop_signin": "La connexion desktop a été terminée, mais OpenWork Cloud n'a pas renvoyé de jeton de session.", + "app.error_not_connected": "Non connecté à un serveur", + "app.error_pick_local_folder": "Choisissez un dossier de worker local avant de redémarrer le serveur local.", + "app.error_rate_limit": "Limite de débit dépassée", + "app.error_remote_access": "Échec de la mise à jour de l'accès distant.", + "app.error_request_failed": "Échec de la requête", + "app.error_reset_config": "Échec de la réinitialisation des valeurs par défaut de la configuration de l'application.", + "app.error_restart_local_worker": "Échec du redémarrage du worker local avec le paramètre de partage mis à jour.", + "app.error_runtime_changes": "Échec de l'application des changements d'exécution.", + "app.error_session_name_required": "Le nom de la session est requis", + "app.error_update_opencode_json": "Échec de la mise à jour de opencode.json", + "app.import_bundle_desc": "Choisissez comment importer ce bundle.", + "app.import_shared_bundle": "Importer le bundle partagé", + "app.local_disabled_reason": "Créez des espaces de travail locaux dans l'application desktop. Les espaces de travail distants et partagés fonctionnent toujours ici.", + "app.local_worker_detail": "Worker local", + "app.model_behavior_desc": "Choisissez d'abord le modèle pour voir les contrôles de comportement spécifiques au fournisseur.", + "app.model_behavior_title": "Comportement du modèle", + "app.plugins_hint_disconnected": "Serveur OpenWork indisponible. Les Plugins sont en lecture seule.", + "app.plugins_hint_limited": "Le serveur OpenWork a besoin d'un jeton pour modifier les Plugins.", + "app.plugins_hint_readonly": "Le serveur OpenWork est en lecture seule pour les Plugins.", + "app.reload_later": "Plus tard", + "app.reload_now": "Recharger maintenant", + "app.reload_stop_tasks": "Recharger et arrêter les tâches", + "app.remote_worker_detail": "Worker distant", + "app.reset_config_ok": "Valeurs par défaut de la configuration de l'application réinitialisées. Redémarrez OpenWork s'il reste des paramètres obsolètes.", + "app.shared_setup": "Configuration partagée", + "app.skill_added": "Skill ajouté", + "app.skills_hint_disconnected": "Serveur OpenWork indisponible. Ajoutez l'URL/le jeton du serveur dans Avancé pour gérer les Skills.", + "app.skills_hint_limited": "Le serveur OpenWork a besoin d'un jeton hôte pour installer/mettre à jour les Skills. Ajoutez-le dans Avancé et reconnectez-vous.", + "app.skills_hint_readonly": "Le serveur OpenWork est en lecture seule pour les Skills. Ajoutez un jeton hôte dans Avancé pour activer les installations.", + "app.unknown_error": "Erreur inconnue", + "app.worker_fallback": "Worker", + "blueprint.automation_body": "Commencez à partir d'un workflow réutilisable ou saisissez votre propre tâche ci-dessous.", + "blueprint.automation_title": "Que voulez-vous automatiser ?", + "blueprint.csv_session_assistant": "Je peux vous aider à générer, nettoyer, fusionner et résumer des fichiers CSV. Quel type de travail CSV voulez-vous automatiser ?", + "blueprint.csv_session_title": "Idées de workflow CSV", + "blueprint.csv_session_user": "Je veux combiner des exports de plusieurs outils en un seul CSV propre.", + "blueprint.empty_body": "Choisissez un point de départ ou saisissez simplement ci-dessous.", + "blueprint.empty_title": "Que voulez-vous faire ?", + "blueprint.minimal_body": "Posez une question sur cet espace de travail ou utilisez un prompt de départ.", + "blueprint.minimal_title": "Commencer par une tâche", + "blueprint.starter_blueprint_desc": "Concevez un workflow répétable avec des Skills, des Commands et des étapes de transfert.", + "blueprint.starter_blueprint_prompt": "Aidez-moi à concevoir un blueprint d'automatisation réutilisable pour cet espace de travail. Demandez ce qui doit être standardisé, puis proposez le workflow.", + "blueprint.starter_blueprint_title": "Planifier un blueprint d'automatisation", + "blueprint.starter_chrome_desc": "Démarrez immédiatement une conversation d'automatisation du navigateur.", + "blueprint.starter_chrome_prompt": "Aidez-moi à me connecter à Chrome et à automatiser une tâche répétitive.", + "blueprint.starter_chrome_title": "Automatiser Chrome", + "blueprint.starter_command_desc": "Transformez un workflow répété en commande slash pour cet espace de travail.", + "blueprint.starter_command_prompt": "Aidez-moi à créer une /command réutilisable pour cet espace de travail. Demandez quel workflow je veux automatiser, puis rédigez la commande.", + "blueprint.starter_command_title": "Créer une commande réutilisable", + "blueprint.starter_connect_openai_desc": "Ajoutez votre fournisseur OpenAI pour que les modèles ChatGPT soient prêts dans les nouvelles sessions.", + "blueprint.starter_connect_openai_title": "Connecter ChatGPT", + "blueprint.starter_csv_desc": "Nettoyez ou générez des données de feuille de calcul.", + "blueprint.starter_csv_prompt": "Aidez-moi à créer ou modifier des fichiers CSV sur cet ordinateur.", + "blueprint.starter_csv_title": "Travailler sur un CSV", + "blueprint.starter_explore_desc": "Résumez les fichiers et suggérez la meilleure première tâche à traiter.", + "blueprint.starter_explore_prompt": "Résumez cet espace de travail, indiquez les fichiers les plus importants et suggérez la meilleure première tâche.", + "blueprint.starter_explore_title": "Explorer cet espace de travail", + "blueprint.welcome_message": "Bonjour, bienvenue sur OpenWork !\n\nLes gens nous utilisent pour écrire des fichiers .csv sur leur ordinateur, se connecter à Chrome et automatiser des tâches répétitives, et synchroniser des contacts avec Notion.\n\nMais la seule limite est votre imagination.\n\nQue voudriez-vous faire ?", + "blueprint.welcome_title": "Bienvenue sur OpenWork", + "common.add": "Ajouter", + "common.cancel": "Annuler", + "common.choose": "Choisir", + "common.close": "Fermer", + "common.default_parens": "(par défaut)", + "common.done": "Terminé", + "common.edit": "Modifier", + "common.hide": "Masquer", + "common.install": "Installer", + "common.navigate": "naviguer", + "common.next": "Suivant", + "common.off": "Désactivé", + "common.on": "Activé", + "common.path": "Chemin", + "common.question": "Question", + "common.refresh": "Actualiser", + "common.remove": "Supprimer", + "common.reset": "Réinitialiser", + "common.retry": "Réessayer", + "common.save": "Enregistrer", + "common.select": "sélectionner", + "common.show": "Afficher", + "common.something_went_wrong": "Un problème est survenu", + "common.submit": "Envoyer", + "common.unknown": "Inconnu", + "composer.agent_label": "Agent", + "composer.attach_files": "Joindre des fichiers", + "composer.attachments_unavailable": "Les pièces jointes ne sont pas disponibles.", + "composer.behavior_label": "Comportement", + "composer.configure": "Configurer", + "composer.default_agent": "Agent par défaut", + "composer.expand_pasted": "Cliquez pour développer le texte collé", + "composer.failed_read_attachment": "Échec de la lecture de la pièce jointe", + "composer.file_exceeds_limit": "{name} dépasse la limite de 8 Mo.", + "composer.file_kind": "Fichier", + "composer.file_too_large_encoding": "{name} est trop grand après encodage. Essayez une image plus petite.", + "composer.image_kind": "Image", + "composer.inserted_links_unsupported": "Liens insérés pour des fichiers non pris en charge.", + "composer.loading_agents": "Chargement des agents...", + "composer.loading_commands": "Chargement des Commands...", + "composer.mcps_label": "MCPs", + "composer.no_commands": "Aucune commande trouvée.", + "composer.no_matches": "Aucun résultat trouvé.", + "composer.placeholder": "Décrivez votre tâche...", + "composer.remote_worker_paste_warning": "Ceci est un worker distant. Les sandboxes sont aussi distantes. Pour partager des fichiers avec lui, téléversez-les dans le dossier Shared dans la barre latérale.", + "composer.run_task": "Exécuter la tâche", + "composer.skill_source": "Skill", + "composer.stop": "Arrêter", + "composer.tools_label": "Commands, Skills et MCPs", + "composer.unsupported_attachment_type": "Type de pièce jointe non pris en charge.", + "composer.upload_failed_local_links": "Impossible de téléverser vers le dossier partagé. Des liens locaux ont été insérés à la place.", + "composer.upload_to_shared_folder": "Téléverser vers le dossier partagé", + "composer.uploaded_multiple_files": "{count} fichiers téléversés vers le dossier partagé et liens insérés.", + "composer.uploaded_single_file": "{name} a été téléversé vers le dossier partagé et un lien a été inséré.", + "config.auto_reload_desc": "Recharge automatiquement après un changement d'agents/Skills/Commands/configuration (uniquement au repos).", + "config.auto_reload_title": "Rechargement automatique (local)", + "config.auto_reload_unavailable": "Disponible pour les espaces de travail locaux dans l'application desktop.", + "config.collaborator_token_disabled_hint": "Stocké à l'avance pour le partage distant, mais l'accès distant est actuellement désactivé.", + "config.collaborator_token_label": "Jeton collaborateur", + "config.collaborator_token_remote_hint": "Accès distant courant pour les téléphones ou ordinateurs portables se connectant à ce serveur.", + "config.connection_failed": "Échec de la connexion.", + "config.connection_failed_check": "Échec de la connexion. Vérifiez l'URL de l'hôte et le jeton.", + "config.connection_status_updated": "État de connexion mis à jour.", + "config.connection_successful": "Connexion réussie.", + "config.copied": "Copié", + "config.copy": "Copier", + "config.desktop_only_hint": "Certaines fonctions de configuration (partage du serveur local + bridge de messagerie) nécessitent l'application desktop.", + "config.diagnostics_desc": "Copiez un état d'exécution nettoyé pour le débogage.", + "config.diagnostics_title": "Bundle de diagnostic", + "config.enable_auto_reload_first": "Activez d'abord le rechargement automatique", + "config.engine_reload_desc": "Redémarrez le serveur OpenCode pour cet espace de travail.", + "config.engine_reload_title": "Rechargement du moteur", + "config.host_admin_token_hint": "Jeton interne réservé à l'hôte pour la CLI des approbations et les API d'administration. Ne l'utilisez pas dans le flux de connexion d'application distante.", + "config.host_admin_token_label": "Jeton admin hôte", + "config.host_local_only": "Local uniquement", + "config.host_offline": "Hors ligne", + "config.host_remote_enabled": "Distant activé", + "config.local_ip_hint": "Utilisez votre IP locale sur le même Wi‑Fi pour la connexion la plus rapide.", + "config.mdns_hint": "Les noms .local sont plus faciles à retenir, mais peuvent ne pas être résolus sur tous les réseaux.", + "config.messaging_identities_desc": "Gérez les identités Telegram/Slack et le routage dans l'onglet Identities.", + "config.messaging_identities_title": "Identités de messagerie", + "config.not_set": "Non défini", + "config.owner_token_disabled_hint": "Pertinent seulement après avoir activé l'accès distant pour ce worker.", + "config.owner_token_label": "Jeton propriétaire", + "config.owner_token_remote_hint": "Utilisez-le lorsqu'un client distant doit répondre aux demandes d'autorisation ou effectuer des actions réservées au propriétaire.", + "config.reload_active_tasks_warning": "Le rechargement arrêtera les tâches actives.", + "config.reload_availability_hint": "Le rechargement n'est disponible que pour les workers locaux ou les serveurs OpenWork connectés.", + "config.reload_connect_hint": "Connectez-vous à ce worker pour recharger.", + "config.reload_engine": "Recharger le moteur", + "config.reload_now_desc": "Applique les mises à jour de configuration et reconnecte votre session.", + "config.reload_now_title": "Recharger maintenant", + "config.reloading": "Rechargement...", + "config.remote_access_off_hint": "L'accès distant est désactivé. Utilisez Partager l'espace de travail pour l'activer avant de vous connecter depuis une autre machine.", + "config.resolved_worker_url": "URL du worker résolue :", + "config.resume_sessions_desc": "Si un rechargement a été mis en file d'attente pendant l'exécution de tâches, envoyer ensuite un message de reprise.", + "config.resume_sessions_title": "Reprendre les Sessions après rechargement automatique", + "config.server_needed_hint": "Connexion au serveur OpenWork nécessaire pour synchroniser Skills, Plugins et Commands.", + "config.server_section_desc": "Connectez-vous à un serveur OpenWork. Utilisez l'URL plus un jeton collaborateur ou propriétaire fourni par l'administrateur de votre serveur.", + "config.server_section_title": "Serveur OpenWork", + "config.server_sharing_desc": "Partagez ces détails avec un appareil de confiance. Gardez le serveur sur le même réseau pour une configuration plus rapide.", + "config.server_sharing_menu_hint": "Pour des liens de partage par espace de travail, utilisez Partager... dans le menu de l'espace de travail.", + "config.server_sharing_title": "Partage du serveur OpenWork", + "config.server_url_hint": "Utilisez l'URL partagée par votre serveur OpenWork. Les workers desktop locaux réutilisent un port élevé persistant dans la plage 48000-51000.", + "config.server_url_input_label": "URL du serveur OpenWork", + "config.server_url_label": "URL du serveur OpenWork", + "config.starting_server": "Démarrage du serveur…", + "config.status_connected": "Connecté", + "config.status_limited": "Limité", + "config.status_not_connected": "Non connecté", + "config.test_connection": "Tester la connexion", + "config.testing": "Test...", + "config.testing_connection": "Test de la connexion...", + "config.token_hint": "Optionnel. Collez un jeton collaborateur pour un accès courant ou un jeton propriétaire si ce client doit répondre aux demandes d'autorisation.", + "config.token_label": "Jeton collaborateur ou propriétaire", + "config.token_placeholder": "Collez votre jeton", + "config.unavailable": "Indisponible", + "config.worker_id": "ID du worker :", + "config.workspace_config_desc": "Ces paramètres affectent l'espace de travail sélectionné. Les actions limitées à l'exécution s'appliquent à l'espace de travail actuellement connecté.", + "config.workspace_config_title": "Configuration de l'espace de travail", + "config.workspace_id_prefix": "Espace de travail :", + "context_panel.add_button": "Ajouter", + "context_panel.add_folder_hint": "Ajoutez un dossier pour permettre à cet espace de travail de lire et modifier des fichiers en dehors de son répertoire racine.", + "context_panel.adding_button": "Ajout...", + "context_panel.always_available": "Toujours disponible", + "context_panel.authorized_folders": "Dossiers autorisés", + "context_panel.authorized_folders_desc": "Accordez à cet espace de travail l'accès en lecture et écriture à des répertoires situés hors de sa racine.", + "context_panel.authorized_folders_no_access": "Connectez-vous à un espace de travail de serveur OpenWork accessible en écriture pour modifier les dossiers autorisés.", + "context_panel.browse_button": "Parcourir", + "context_panel.config_access_unavailable": "L'accès à la configuration du serveur OpenWork est indisponible pour cet espace de travail.", + "context_panel.config_read_only": "Le serveur OpenWork est connecté en lecture seule pour la configuration de l'espace de travail.", + "context_panel.context": "Contexte", + "context_panel.folder_already_authorized": "Le dossier est déjà autorisé.", + "context_panel.folders_updated": "Dossiers autorisés mis à jour.", + "context_panel.input_placeholder": "Saisissez un chemin de dossier à autoriser...", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "Connecté", + "context_panel.mcp_disabled": "Désactivé", + "context_panel.mcp_disconnected": "Déconnecté", + "context_panel.mcp_failed": "Échec", + "context_panel.mcp_needs_auth": "Authentification requise", + "context_panel.mcp_register_client": "Enregistrer le client", + "context_panel.no_external_folders": "Aucun dossier externe autorisé", + "context_panel.no_mcp": "Aucun serveur MCP chargé.", + "context_panel.no_plugins": "Aucun Plugin chargé.", + "context_panel.no_server_workspace": "Aucun espace de travail serveur actif sélectionné.", + "context_panel.no_skills": "Aucun Skill chargé.", + "context_panel.none_yet": "Aucun pour le moment.", + "context_panel.plugins": "Plugins", + "context_panel.preserving_entries": "Conservation de {count} entrées d'autorisation non liées à des dossiers.", + "context_panel.preserving_entry": "Conservation d'une entrée d'autorisation non liée à un dossier.", + "context_panel.remove_folder": "Supprimer {name}", + "context_panel.saving_folders": "Enregistrement des dossiers autorisés...", + "context_panel.server_disconnected": "Le serveur OpenWork est déconnecté.", + "context_panel.skills": "Skills", + "context_panel.working_files": "Fichiers de travail", + "context_panel.workspace_root_available": "La racine de l'espace de travail est déjà disponible.", + "context_panel.workspace_root_badge": "Racine de l'espace de travail", + "context_panel.writable_workspace_required": "Un espace de travail de serveur OpenWork accessible en écriture est requis pour mettre à jour les dossiers autorisés.", + "dashboard.access_token": "Jeton d'accès", + "dashboard.access_token_optional_hint": "Ajoutez un jeton uniquement si le worker en a besoin.", + "dashboard.blueprints_workspace": "Blueprints", + "dashboard.blueprints_workspace_desc": "Commencez avec un espace de travail prêt pour l'automatisation avec des Skills, Commands et flux partagés réutilisables.", + "dashboard.change": "Modifier", + "dashboard.choose_folder": "Choisir un dossier", + "dashboard.choose_folder_continue": "Choisissez un dossier pour continuer.", + "dashboard.choose_folder_next": "Partagez des fichiers avec votre espace de travail.", + "dashboard.choose_preset": "Choisir un preset", + "dashboard.chooser_local_desc": "Créez un espace de travail sur cet appareil et démarrez éventuellement à partir d'un modèle d'équipe.", + "dashboard.chooser_remote_desc": "Attachez-vous à un worker OpenWork auto-hébergé en utilisant une URL et un jeton d'accès.", + "dashboard.chooser_shared_desc": "Parcourez les workers cloud partagés avec votre organisation et connectez-vous en une étape.", + "dashboard.close_settings": "Fermer les paramètres", + "dashboard.cloud_signin_button": "Continuer avec Cloud", + "dashboard.cloud_signin_hint": "Accédez aux workers distants partagés avec votre organisation.", + "dashboard.cloud_signin_next": "Vous choisirez ensuite une équipe et vous vous connecterez à un espace de travail existant.", + "dashboard.cloud_signin_title": "Se connecter à OpenWork Cloud", + "dashboard.cloud_worker": "Worker cloud", + "dashboard.commands": "Commands", + "dashboard.connect_remote_button": "Connecter un accès distant", + "dashboard.connected": "Connecté", + "dashboard.connecting": "Connexion...", + "dashboard.create_local_workspace_subtitle": "Créez un espace de travail sur cet appareil et démarrez éventuellement à partir d'un modèle d'équipe.", + "dashboard.create_local_workspace_title": "Espace de travail local", + "dashboard.create_remote_custom_subtitle": "Attachez-vous à un worker OpenWork auto-hébergé.", + "dashboard.create_remote_custom_title": "Connecter un accès distant personnalisé", + "dashboard.create_remote_workspace_confirm": "Ajouter l'espace de travail", + "dashboard.create_remote_workspace_subtitle": "Enregistrez un serveur OpenWork comme espace de travail.", + "dashboard.create_remote_workspace_title": "Ajouter un espace de travail distant", + "dashboard.create_sandbox_confirm": "Créer comme sandbox", + "dashboard.create_shared_subtitle_signed_in": "Parcourez les workers cloud partagés avec votre organisation et connectez-vous en une étape.", + "dashboard.create_shared_subtitle_signed_out": "Connectez-vous à OpenWork Cloud pour accéder aux workers partagés avec votre organisation.", + "dashboard.create_shared_title": "Espaces de travail partagés", + "dashboard.create_workspace_confirm": "Créer l'espace de travail", + "dashboard.create_workspace_subtitle": "Initialiser un nouvel espace de travail basé sur un dossier.", + "dashboard.create_workspace_title": "Créer un espace de travail", + "dashboard.creating": "Création...", + "dashboard.desktop_badge": "Desktop", + "dashboard.display_name_label": "Nom d'affichage", + "dashboard.display_name_optional": "(optionnel)", + "dashboard.docker_debug_details": "Détails de débogage Docker", + "dashboard.edit_remote_workspace_confirm": "Enregistrer la connexion", + "dashboard.edit_remote_workspace_subtitle": "Mettez à jour les détails du serveur OpenWork pour cet espace de travail.", + "dashboard.edit_remote_workspace_title": "Modifier la connexion distante", + "dashboard.empty_workspace": "Espace de travail vide", + "dashboard.empty_workspace_desc": "Commencez avec un dossier vide et ajoutez ce dont vous avez besoin.", + "dashboard.error_choose_org": "Choisissez une organisation avant d'ouvrir un espace de travail.", + "dashboard.error_connect_worker": "Échec de la connexion à {name}.", + "dashboard.error_create_template": "Échec de la création de {name}.", + "dashboard.error_load_orgs": "Échec du chargement des organisations.", + "dashboard.error_load_shared_workspaces": "Échec du chargement des espaces de travail partagés.", + "dashboard.error_workspace_not_ready": "L'espace de travail n'est pas encore prêt à être connecté. Réessayez dans un instant.", + "dashboard.import_config": "Importer la configuration", + "dashboard.importing": "Importation…", + "dashboard.modal_back": "Retour", + "dashboard.modal_close": "Fermer la fenêtre Ajouter un espace de travail", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "Aucun dossier sélectionné pour le moment.", + "dashboard.open_cloud_dashboard": "Ouvrir le tableau de bord Cloud", + "dashboard.opening": "Ouverture...", + "dashboard.openwork_host_hint": "Utilisez l'URL partagée par votre serveur OpenWork.", + "dashboard.openwork_host_label": "URL du serveur OpenWork", + "dashboard.openwork_host_placeholder": "https://votre-serveur.openwork.app", + "dashboard.openwork_host_token_hint": "Optionnel. Collez un jeton collaborateur pour un accès courant ou un jeton propriétaire si ce client doit répondre aux demandes d'autorisation.", + "dashboard.openwork_host_token_label": "Jeton collaborateur ou propriétaire", + "dashboard.openwork_host_token_placeholder": "Collez votre jeton", + "dashboard.recently_updated": "Récemment mis à jour", + "dashboard.remote": "Distant", + "dashboard.remote_base_url_required": "Ajoutez une URL de serveur pour continuer.", + "dashboard.remote_connection_direct": "Direct", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "Laissez vide pour utiliser la valeur par défaut du serveur.", + "dashboard.remote_directory_label": "Répertoire de l'espace de travail (optionnel)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "Nom d'affichage (optionnel)", + "dashboard.remote_display_name_placeholder": "Espace de travail de l'équipe design", + "dashboard.remote_server_details_hint": "Attachez-vous à un worker OpenWork auto-hébergé.", + "dashboard.remote_server_details_title": "Détails du serveur distant", + "dashboard.remote_workspace_hint": "Suivez un serveur OpenWork et reconnectez-vous à tout moment.", + "dashboard.remote_workspace_title": "Espace de travail distant", + "dashboard.repair_cache": "Réparer le cache", + "dashboard.repairing_cache": "Réparation du cache", + "dashboard.sandbox_checking_docker": "Vérification de Docker...", + "dashboard.sandbox_get_ready_action": "Préparer votre système", + "dashboard.sandbox_get_ready_desc": "Exécutez cet espace de travail dans un conteneur Docker isolé pour des exécutions plus sûres et plus reproductibles.", + "dashboard.sandbox_get_ready_title": "Les sandboxes nécessitent Docker", + "dashboard.sandbox_hide_logs": "Masquer les journaux", + "dashboard.sandbox_live_logs": "Journaux en direct", + "dashboard.sandbox_setup": "Configuration de la sandbox", + "dashboard.sandbox_show_logs": "Afficher les journaux", + "dashboard.search_shared_workspaces": "Rechercher des espaces de travail partagés", + "dashboard.select_folder": "Sélectionner un dossier", + "dashboard.settings": "Paramètres", + "dashboard.shared_workspaces_loading": "Chargement des espaces de travail partagés…", + "dashboard.shared_workspaces_no_match": "Aucun espace de travail partagé ne correspond à cette recherche.", + "dashboard.shared_workspaces_none": "Aucun espace de travail partagé n'est encore disponible.", + "dashboard.shared_workspaces_refreshing": "Actualisation des espaces de travail…", + "dashboard.skills": "Skills", + "dashboard.starter_workspace": "Espace de travail de démarrage", + "dashboard.starter_workspace_desc": "Préconfiguré pour vous montrer comment utiliser les Plugins, Commands et Skills.", + "dashboard.unknown_creator": "Créateur inconnu", + "dashboard.worker_status_attention": "Attention", + "dashboard.worker_status_ready": "Prêt", + "dashboard.worker_status_starting": "Démarrage", + "dashboard.worker_status_stopped": "Arrêté", + "dashboard.worker_status_unknown": "Inconnu", + "dashboard.worker_url_hint": "Collez l'URL du worker OpenWork auquel vous voulez vous connecter.", + "dashboard.worker_url_label": "URL du worker", + "dashboard.workspace_connect": "Connecter", + "dashboard.workspace_connect_unavailable": "La connexion aux espaces de travail partagés n'est pas disponible ici.", + "dashboard.workspace_connecting": "Connexion", + "dashboard.workspace_folder_hint": "Choisissez où cet espace de travail doit se trouver sur votre appareil.", + "dashboard.workspace_folder_title": "Dossier de l'espace de travail", + "dashboard.workspace_not_ready_title": "Cet espace de travail n'est pas encore prêt à être connecté.", + "dashboard.workspaces": "Espaces de travail", + "den.active_org_hint": "Les workers cloud et les modèles d'équipe sont limités à l'organisation sélectionnée.", + "den.active_org_title": "Organisation active", + "den.auto_reconnect_hint": "Terminez l'authentification dans votre navigateur et OpenWork se reconnectera automatiquement ici.", + "den.checking_session": "Vérification de la session", + "den.choose_org_for_providers": "Choisissez une organisation pour voir les fournisseurs cloud.", + "den.choose_org_for_skills": "Choisissez une organisation pour voir les Skills cloud.", + "den.choose_org_for_skill_hubs": "Choisissez une organisation pour voir les hubs de Skills cloud.", + "den.cloud_account_hint": "Gérez votre compte connecté et votre organisation.", + "den.cloud_account_title": "Compte cloud", + "den.cloud_control_plane_open": "Ouvrir dans le navigateur", + "den.cloud_control_plane_reset": "Réinitialiser", + "den.cloud_control_plane_save": "Enregistrer l'URL", + "den.cloud_control_plane_url_hint": "Mode développeur uniquement. Utilisez ceci pour cibler un plan de contrôle Cloud local ou auto-hébergé. Le modifier vous déconnecte afin que l'application puisse se réhydrater avec le nouveau plan de contrôle.", + "den.cloud_control_plane_url_label": "URL du plan de contrôle Cloud", + "den.cloud_provider_detail": "{count} modèles · fournisseur {source}", + "den.cloud_provider_removed_detail": "Ce fournisseur importé n'est plus dans le cloud. Désinstallez la configuration locale {providerId}.", + "den.cloud_provider_sync_detail": "Le fournisseur cloud a changé. Synchronisez la configuration {source} du modèle {count} dans opencode.jsonc.", + "den.cloud_skill_detail": "Installez ce Skill cloud dans .opencode/skills.", + "den.cloud_skill_imported_detail": "Installé localement sous {name}.", + "den.cloud_skill_removed_detail": "Ce Skill cloud a été supprimé en amont. Désinstallez la copie locale {name}.", + "den.cloud_skill_sync_detail": "Une version cloud plus récente est disponible pour {name}. Mettez à jour la copie locale pour rester synchronisé.", + "den.cloud_skills_hint": "Parcourez les Skills cloud individuels auxquels vous avez accès, installez-les localement et mettez-les à jour lorsque la version distante change.", + "den.cloud_skills_title": "Skills", + "den.cloud_providers_hint": "Importez des fournisseurs LLM gérés dans opencode.jsonc et utilisez l'identifiant de l'organisation dans cet espace de travail.", + "den.cloud_providers_title": "Fournisseurs cloud", + "den.cloud_section_desc": "Connectez-vous, choisissez une organisation et ouvrez des workers Cloud ou des modèles d'équipe.", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "Connectez-vous à OpenWork Cloud pour garder vos tâches actives même lorsque votre ordinateur est en veille.", + "den.cloud_workers_hint": "Ouvrez directement les workers dans OpenWork en utilisant le même flux de connexion distante que l'application utilise ailleurs.", + "den.cloud_workers_title": "Workers cloud", + "den.create_account": "Créer un compte", + "den.credentials_ready_badge": "Identifiant prêt", + "den.error_base_url": "Entrez une URL de plan de contrôle Cloud valide en http:// ou https://.", + "den.error_choose_org": "Choisissez une organisation avant d'ouvrir un worker.", + "den.error_load_orgs": "Échec du chargement des organisations.", + "den.error_load_skills": "Échec du chargement des Skills cloud.", + "den.error_load_workers": "Échec du chargement des workers.", + "den.error_no_session": "Aucune session Cloud active trouvée.", + "den.error_no_token": "La connexion desktop a été terminée, mais OpenWork Cloud n'a pas renvoyé de jeton de session.", + "den.error_open_worker": "Échec de l'ouverture de {name} dans OpenWork.", + "den.error_open_worker_fallback": "Échec de l'ouverture de {name}.", + "den.error_paste_valid_code": "Collez un lien de connexion OpenWork valide ou un code de connexion à usage unique.", + "den.error_signin_failed": "Impossible de terminer la connexion à OpenWork Cloud.", + "den.error_worker_not_ready": "Le worker n'est pas encore prêt à être ouvert. Réessayez une fois le provisionnement terminé.", + "den.finish_signin": "Terminer la connexion", + "den.finishing": "Finalisation...", + "den.hide_signin_code": "Masquer le code de connexion", + "den.import_all": "Tout importer", + "den.import_skill": "Installer", + "den.import_skill_failed": "Échec de l'installation de {name}.", + "den.import_provider": "Importer", + "den.import_provider_failed": "Échec de l'import de {name}.", + "den.imported_badge": "Importé", + "den.imported_provider": "{name} importé.", + "den.importing": "Importation...", + "den.needs_attention": "Nécessite une attention", + "den.no_cloud_providers": "Aucun fournisseur cloud n'est encore disponible pour cette organisation.", + "den.no_cloud_skills": "Aucun Skill cloud n'est encore disponible pour cette organisation.", + "den.no_cloud_workers": "Aucun worker cloud n'est encore visible pour cette organisation. Créez-en un dans Cloud, puis actualisez cet onglet.", + "den.no_org_selected": "Aucune organisation sélectionnée", + "den.no_skill_hubs": "Aucun hub de Skills cloud n'est encore disponible pour cette organisation.", + "den.open": "Ouvrir", + "den.opening": "Ouverture...", + "den.org_member_suffix": "(Membre)", + "den.org_owner_suffix": "(Propriétaire)", + "den.org_switched": "Basculé vers {name}.", + "den.out_of_sync_badge": "Désynchronisé", + "den.private_badge": "Privé", + "den.paste_signin_code": "Coller le code de connexion", + "den.refresh": "Actualiser", + "den.reload_workspace": "Rechargez l'espace de travail pour appliquer les changements de configuration.", + "den.remove_provider_failed": "Échec de la suppression de {name}.", + "den.remove_skill_failed": "Échec de la désinstallation de {name}.", + "den.removed_from_cloud_badge": "Supprimé du cloud", + "den.removed_provider": "{name} supprimé.", + "den.removing": "Suppression...", + "den.sign_out": "Se déconnecter", + "den.signed_out": "Déconnecté", + "den.signin_button": "Se connecter", + "den.signin_code_note": "Accepte un lien openwork://den-auth ou l'autorisation brute à usage unique.", + "den.signin_link_hint": "Si votre navigateur ne revient pas automatiquement dans OpenWork, collez ici le lien de connexion ou le code à usage unique depuis OpenWork Cloud.", + "den.signin_link_label": "Lien de connexion ou code à usage unique", + "den.signin_link_placeholder": "openwork://den-auth?... ou code collé", + "den.signin_title": "Se connecter à OpenWork Cloud", + "den.signing_in": "Finalisation de la connexion OpenWork Cloud...", + "den.signing_out": "Déconnexion...", + "den.skill_hub_detail": "Importez {count} Skills partagés dans .opencode/skills.", + "den.skill_hub_imported_detail": "{count} Skills importés dans cet espace de travail.", + "den.skill_hub_removed_detail": "Ce hub a été supprimé du cloud. Désinstallez les {importedCount} Skills importés de cet espace de travail.", + "den.skill_hub_skills_badge": "{count} Skills", + "den.skill_hub_sync_detail": "Le cloud contient désormais {liveCount} Skills ; cet espace de travail en a importé {importedCount}. Synchronisez pour mettre à jour l'ensemble installé.", + "den.skill_hubs_hint": "Importez chaque Skill d'un hub cloud partagé dans cet espace de travail en une seule étape.", + "den.skill_hubs_title": "Hubs de Skills", + "den.status_base_url_updated": "URL du plan de contrôle Cloud mise à jour. Reconnectez-vous pour continuer.", + "den.status_browser_signin": "Terminez la connexion dans votre navigateur pour connecter OpenWork.", + "den.status_browser_signup": "Terminez la création du compte dans votre navigateur pour connecter OpenWork.", + "den.status_cloud_signed_in_as": "OpenWork Cloud connecté en tant que {email}.", + "den.status_cloud_signin_done": "OpenWork Cloud connecté.", + "den.status_loaded_orgs": "{count} organisation{plural} chargée.", + "den.status_loaded_skills": "{count} Skill{plural} cloud chargé pour {name}.", + "den.status_loaded_workers": "{count} worker{plural} chargé pour {name}.", + "den.status_no_skills": "Aucun Skill cloud trouvé pour {name}.", + "den.status_no_workers": "Aucun worker trouvé pour {name}.", + "den.status_opened_worker": "{name} ouvert dans OpenWork.", + "den.status_signed_in_as": "Connecté en tant que {email}.", + "den.status_signed_out": "Déconnecté et session OpenWork Cloud effacée sur cet appareil.", + "den.sync": "Synchroniser", + "den.sync_provider_failed": "Échec de la synchronisation de {name}.", + "den.sync_skill_failed": "Échec de la mise à jour de {name}.", + "den.synced_provider": "{name} synchronisé.", + "den.syncing": "Synchronisation...", + "den.installed_name_badge": "Local : {name}", + "den.uninstall": "Désinstaller", + "den.worker_mine_badge": "À moi", + "den.worker_not_ready_title": "Ce worker n'est pas encore prêt à être ouvert.", + "den.worker_provider_label": "worker {provider}", + "den.worker_secondary_cloud": "Worker cloud", + "extensions.app_count_one": "{count} application connectée", + "extensions.app_count_many": "{count} applications connectées", + "extensions.apps_mcp_header": "Applications (MCP)", + "extensions.filter_all": "Tout", + "extensions.filter_apps": "Applications", + "extensions.filter_plugins": "Plugins", + "extensions.plugin_count_one": "{count} plugin", + "extensions.plugin_count_many": "{count} plugins", + "extensions.plugins_opencode_header": "Plugins (OpenCode)", + "extensions.subtitle": "Les applications (MCP) et les Plugins OpenCode se trouvent au même endroit.", + "extensions.title": "Extensions", + "identities.agent_behavior_desc": "Un fichier par espace de travail. Ajoutez une première ligne optionnelle @agent pour router via un agent OpenCode spécifique.", + "identities.agent_behavior_title": "Comportement de l'agent de messagerie", + "identities.agent_created": "Fichier d'agent de messagerie par défaut créé.", + "identities.agent_file_changed": "Le fichier a changé à distance. Rechargez puis enregistrez à nouveau.", + "identities.agent_loading": "Chargement du fichier de l'agent…", + "identities.agent_none": "aucun", + "identities.agent_not_found": "Fichier d'agent introuvable dans cet espace de travail pour le moment.", + "identities.agent_saved": "Comportement de messagerie enregistré.", + "identities.agent_scope_status": "Portée active : espace de travail · état : {status} · agent sélectionné : {agent}", + "identities.agent_status_loaded": "chargé", + "identities.agent_status_missing": "manquant", + "identities.agent_worker_scope_unavailable": "Portée du worker indisponible.", + "identities.all_channels": "Tous les canaux", + "identities.app_token_label": "Jeton d'application", + "identities.auto_bind_label": "Associer automatiquement le pair au répertoire lors d'un envoi direct", + "identities.available_channels": "Canaux disponibles", + "identities.bot_token_label": "Jeton du bot", + "identities.bot_token_placeholder": "Collez le jeton du bot Telegram de @BotFather", + "identities.botfather_step1_open": "1. Ouvrez @BotFather dans Telegram", + "identities.botfather_step1_run": "et exécutez /newbot", + "identities.botfather_step3_choose": "3. Choisissez un nom et un nom d'utilisateur pour votre bot", + "identities.botfather_step3_or_private": "pour une boîte de réception ouverte ou", + "identities.botfather_step3_private": "Privé", + "identities.botfather_step3_public": "Public", + "identities.botfather_step3_to_require": "pour exiger", + "identities.channel_label": "Canal", + "identities.channels_connected": "connecté", + "identities.channels_label": "Canaux", + "identities.configured_suffix": "configuré", + "identities.connect_server_desc": "Les identités sont disponibles lorsque vous êtes connecté à un hôte OpenWork.", + "identities.connect_server_title": "Se connecter à un serveur OpenWork", + "identities.connect_slack": "Connecter Slack", + "identities.connected_badge": "Connecté", + "identities.connecting": "Connexion...", + "identities.copy_bot_token_hint": "Copiez le jeton du bot et collez-le ci-dessous.", + "identities.copy_code": "Copier le code", + "identities.create_default_file": "Créer le fichier par défaut", + "identities.create_private_bot": "Créer un bot privé", + "identities.create_public_bot": "Créer un bot public", + "identities.days_ago": "il y a {days} j", + "identities.default_routing": "Routage par défaut", + "identities.directory_label": "Répertoire (optionnel)", + "identities.disable_messaging": "Désactiver la messagerie", + "identities.disable_messaging_message": "Cela désactivera la messagerie pour cet espace de travail. La configuration Telegram et Slack sera masquée jusqu'à ce que la messagerie soit réactivée, et vous devrez redémarrer le worker pour arrêter complètement le sidecar de messagerie.", + "identities.disable_messaging_title": "Désactiver la messagerie pour ce worker ?", + "identities.disabled_label": "Désactivé", + "identities.disabling": "Désactivation...", + "identities.disconnect": "Déconnecter", + "identities.dispatched_messages": "{sent}/{attempted} messages envoyés.", + "identities.enable_messaging": "Activer la messagerie", + "identities.enable_messaging_risk": "La messagerie peut exposer ce worker à des commandes distantes. Si un bot est public ou compromis, il peut accéder aux fichiers, identifiants et clés API disponibles pour ce worker.", + "identities.enable_messaging_title": "Activer la messagerie pour ce worker ?", + "identities.enabled_label": "Activé", + "identities.enabling": "Activation...", + "identities.health_offline": "Hors ligne", + "identities.health_running": "En cours d'exécution", + "identities.health_unavailable": "Indisponible", + "identities.health_unknown": "Inconnu", + "identities.hours_ago": "il y a {hours} h", + "identities.identities_label": "Identités", + "identities.just_now": "À l'instant", + "identities.last_activity": "Dernière activité", + "identities.later": "Plus tard", + "identities.message_label": "Message", + "identities.message_routing_desc": "Contrôlez quelles conversations vont dans quel dossier d'espace de travail. Les messages sont routés vers le dossier par défaut du worker, sauf si vous configurez des règles ici.", + "identities.message_routing_title": "Routage des messages", + "identities.messages_today": "Messages aujourd'hui", + "identities.messaging_disabled_hint": "N'activez la messagerie que si vous comprenez le risque et prévoyez de sécuriser l'accès (par exemple, appairage Telegram privé).", + "identities.messaging_disabled_restart": "Messagerie désactivée. Redémarrez ce worker pour arrêter le sidecar de messagerie.", + "identities.messaging_disabled_risk": "Les bots de messagerie peuvent exécuter des actions sur votre worker local. S'ils sont exposés publiquement, ils peuvent permettre l'accès aux fichiers, identifiants et clés API disponibles pour ce worker.", + "identities.messaging_disabled_title": "La messagerie est désactivée par défaut", + "identities.messaging_enabled_restart": "Messagerie activée. Redémarrez ce worker pour appliquer avant de configurer les canaux.", + "identities.messaging_sidecar_not_running": "La messagerie est activée dans cet espace de travail, mais le sidecar de messagerie n'est pas encore en cours d'exécution. Redémarrez ce worker, puis revenez dans les paramètres Messagerie pour connecter Telegram ou Slack.", + "identities.minutes_ago": "il y a {minutes} min", + "identities.not_set": "Non défini", + "identities.open_bot_link": "Ouvrir @{username} dans Telegram", + "identities.pairing_code_copied": "Code d'appairage copié.", + "identities.pairing_code_copy_failed": "Impossible de copier le code d'appairage. Copiez-le manuellement.", + "identities.pairing_code_instruction_prefix": "Envoyer", + "identities.peer_id_label": "ID du pair (optionnel)", + "identities.peer_id_placeholder_slack": "ex. slack:U12345678", + "identities.peer_id_placeholder_telegram": "ex. telegram:123456789", + "identities.private_label": "Privé", + "identities.private_pairing_code": "Code d'appairage privé", + "identities.public_bot_confirm": "Oui, je comprends le risque", + "identities.public_bot_warning_message": "Votre bot sera accessible au public et toute personne qui y a accès pourra avoir un accès complet à votre worker local, y compris aux fichiers ou clés API que vous lui avez fournis. Si vous créez un bot privé, vous pouvez limiter l'accès en exigeant un jeton d'appairage. Êtes-vous sûr de vouloir rendre votre bot public ?", + "identities.public_bot_warning_title": "Rendre ce bot public ?", + "identities.public_label": "Public", + "identities.quick_setup": "Configuration rapide", + "identities.reconnect_failed": "Échec de la reconnexion. Vérifiez l'URL/le jeton OpenWork et réessayez.", + "identities.reconnected": "Reconnecté.", + "identities.reconnected_refreshing": "Reconnecté. Actualisation de l'état du worker...", + "identities.reload": "Recharger", + "identities.repair_reconnect": "Réparer et reconnecter", + "identities.restart_failed": "Échec du redémarrage. Veuillez redémarrer le worker depuis les Paramètres et réessayer.", + "identities.restart_to_disable_messaging": "La messagerie a été désactivée pour cet espace de travail. Redémarrez maintenant le worker pour arrêter le sidecar de messagerie.", + "identities.restart_to_enable_messaging": "La messagerie a été activée pour cet espace de travail. Redémarrez maintenant le worker pour lancer le sidecar de messagerie et débloquer la configuration de Telegram et Slack.", + "identities.restart_worker": "Redémarrer le worker", + "identities.restart_worker_title": "Redémarrer le worker maintenant ?", + "identities.restarting": "Redémarrage...", + "identities.routing_override_prefix": "Tous les messages sont routés vers", + "identities.routing_override_suffix": "(forçage actif)", + "identities.running_label": "En cours d'exécution", + "identities.save_behavior": "Enregistrer le comportement", + "identities.saving": "Enregistrement...", + "identities.send_test_button": "Envoyer un message de test", + "identities.send_test_desc": "Validez le câblage sortant. Utilisez un ID de pair pour un envoi direct, ou laissez l'ID vide pour distribuer selon les associations dans un répertoire.", + "identities.send_test_title": "Envoyer un message de test", + "identities.sending": "Envoi...", + "identities.slack_desc": "Votre worker apparaît comme un bot dans les canaux Slack. Les membres de l'équipe peuvent lui envoyer des messages directement ou le mentionner dans des fils.", + "identities.slack_intro": "Connectez votre espace de travail Slack pour permettre aux membres de l'équipe d'interagir avec ce worker dans les canaux et les messages directs.", + "identities.slack_unavailable": "Identités Slack indisponibles.", + "identities.status_active": "Actif", + "identities.status_label": "État", + "identities.status_stopped": "Arrêté", + "identities.stopped_label": "Arrêté", + "identities.subtitle": "Permettez aux personnes de joindre votre worker via des applications de messagerie. Connectez un canal et votre worker lira et répondra automatiquement aux messages.", + "identities.tab_general": "Général", + "identities.telegram_bot_access_desc": "Bot public : le premier chat Telegram se lie automatiquement. Bot privé : exige un code d'appairage avant que des messages n'exécutent des outils.", + "identities.telegram_delete_failed": "Échec de la suppression.", + "identities.telegram_deleted": "Supprimé.", + "identities.telegram_deleted_pending": "Supprimé (application en attente).", + "identities.telegram_desc": "Connectez un bot Telegram en mode public (boîte de réception ouverte) ou privé (code d'appairage requis).", + "identities.telegram_private_saved_pair": "Bot privé enregistré. Associez via /pair {code}", + "identities.telegram_save_failed": "Échec de l'enregistrement.", + "identities.telegram_saved": "Enregistré.", + "identities.telegram_saved_pending": "Enregistré (application en attente).", + "identities.telegram_saved_username": "Enregistré (@{username})", + "identities.telegram_unavailable": "Identités Telegram indisponibles.", + "identities.title": "Canaux de messagerie", + "identities.unsaved_changes": "Modifications non enregistrées", + "identities.worker_offline": "Worker hors ligne", + "identities.worker_online": "Worker en ligne", + "identities.worker_restarted": "Worker redémarré.", + "identities.worker_restarted_refreshing": "Worker redémarré. Actualisation de l'état de la messagerie...", + "identities.worker_scope_unavailable": "Portée du worker indisponible.", + "identities.worker_scope_unavailable_detail": "Portée du worker indisponible. Reconnectez-vous avec une URL de worker ou basculez vers un worker connu.", + "identities.worker_unavailable": "Worker indisponible", + "identities.workspace_id_required": "L'ID de l'espace de travail est requis pour gérer les identités. Reconnectez-vous avec une URL d'espace de travail ou sélectionnez un espace de travail mappé sur cet hôte.", + "identities.workspace_scope_prefix": "Portée de l'espace de travail :", + "inbox_panel.connect_to_download": "Connectez-vous à un worker pour télécharger des fichiers partagés.", + "inbox_panel.connect_to_see": "Connectez-vous pour voir les fichiers partagés.", + "inbox_panel.connect_to_upload": "Connectez-vous à un worker pour téléverser", + "inbox_panel.copy_failed": "Échec de la copie. Votre navigateur peut bloquer l'accès au presse-papiers.", + "inbox_panel.download": "Télécharger", + "inbox_panel.drop_to_upload": "Déposez des fichiers ici pour les téléverser", + "inbox_panel.helper_text": "Partagez des fichiers avec ce worker depuis l'application.", + "inbox_panel.load_failed": "Échec du chargement du dossier partagé", + "inbox_panel.missing_file_id": "ID de fichier partagé manquant.", + "inbox_panel.no_files": "Aucun fichier partagé pour le moment.", + "inbox_panel.refresh_tooltip": "Actualiser le dossier partagé", + "inbox_panel.shared_folder": "Dossier partagé", + "inbox_panel.showing_first": "Affichage des {count} premiers.", + "inbox_panel.upload_failed": "Échec du téléversement dans le dossier partagé", + "inbox_panel.upload_needs_worker": "Connectez-vous à un worker pour téléverser des fichiers dans le dossier partagé.", + "inbox_panel.upload_prompt": "Déposez des fichiers ou cliquez pour téléverser", + "inbox_panel.upload_success": "Téléversé dans le dossier partagé.", + "inbox_panel.uploading": "Téléversement...", + "inbox_panel.uploading_label": "Téléversement de {label}...", + "mcp.activate_button": "Activer", + "mcp.add_modal_subtitle": "Connectez un serveur MCP personnalisé par URL ou commande locale.", + "mcp.add_modal_title": "Ajouter une application personnalisée", + "mcp.add_server_button": "Ajouter une application", + "mcp.advanced": "Avancé", + "mcp.advanced_settings": "Paramètres avancés", + "mcp.advanced_settings_hint": "Modifiez les fichiers de configuration et gérez les connexions manuellement.", + "mcp.app_connected": "application connectée", + "mcp.apps_connected": "applications connectées", + "mcp.apps_subtitle": "Connectez vos outils préférés pour qu'OpenWork puisse les utiliser en votre nom.", + "mcp.apps_title": "Applications", + "mcp.auth.already_connected": "Déjà connecté", + "mcp.auth.already_connected_description": "{server} est déjà authentifié et prêt à être utilisé.", + "mcp.auth.applying_changes_body": "Nous redémarrons le worker pour que le nouveau MCP soit prêt à s'authentifier.", + "mcp.auth.applying_changes_title": "Application des changements avant connexion", + "mcp.auth.authorization_link": "Lien d'autorisation", + "mcp.auth.authorization_still_required": "L'autorisation est toujours requise. Réessayez pour redémarrer le flux.", + "mcp.auth.callback_invalid": "Collez l'URL de rappel ou le paramètre code pour terminer OAuth.", + "mcp.auth.callback_label": "URL de rappel ou code", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "Annuler", + "mcp.auth.client_registration_required": "L'enregistrement du client est requis avant que OAuth puisse continuer.", + "mcp.auth.complete_connection": "Terminer la connexion", + "mcp.auth.configured_previously": "Le MCP a peut-être été configuré globalement ou dans une session précédente. Vous pouvez fermer cette fenêtre et commencer à utiliser les outils MCP tout de suite.", + "mcp.auth.connect_server": "Connecter {server}", + "mcp.auth.copied": "Copié", + "mcp.auth.copy_link": "Copier le lien", + "mcp.auth.done": "Terminé", + "mcp.auth.failed_to_start_oauth": "Échec du démarrage du flux OAuth", + "mcp.auth.follow_browser_steps": "Suivez les étapes d'autorisation dans le navigateur.", + "mcp.auth.force_stop": "Forcer l'arrêt", + "mcp.auth.force_stopping": "Arrêt...", + "mcp.auth.im_done": "J'ai terminé", + "mcp.auth.invalid_refresh_token": "Le jeton de rafraîchissement OAuth est invalide ou expiré. Réautorisez pour continuer.", + "mcp.auth.manual_finish_hint": "Collez l'URL de rappel (localhost:19876) ou simplement le code pour terminer la connexion.", + "mcp.auth.manual_finish_title": "Serveur distant ?", + "mcp.auth.oauth_completed_reload": "OAuth terminé. Rechargez le moteur pour activer le MCP.", + "mcp.auth.oauth_failed": "Échec de l'authentification OAuth.", + "mcp.auth.oauth_not_supported_hint": "Cela peut vouloir dire :\n• Le serveur MCP n'annonce pas de capacités OAuth\n• Le moteur doit être rechargé pour découvrir les capacités du serveur\n• Essayez : opencode mcp auth {server} depuis la CLI", + "mcp.auth.open_browser_signin": "Nous ouvrirons votre navigateur pour terminer la connexion.", + "mcp.auth.port_forward_hint": "Astuce : transférez le port de rappel si nécessaire : ssh -L 19876:127.0.0.1:19876 user@host", + "mcp.auth.reauth_action": "Réautoriser OAuth", + "mcp.auth.reauth_cli_hint": "Exécutez : opencode mcp auth {server}", + "mcp.auth.reauth_failed": "Échec de la réautorisation.", + "mcp.auth.reauth_remote_hint": "Réautorisez depuis la machine qui exécute ce worker.", + "mcp.auth.reauth_running": "Réautorisation...", + "mcp.auth.reload_blocked": "Le rechargement est en pause pendant qu'une session est en cours. Arrêtez l'exécution pour terminer la configuration.", + "mcp.auth.reload_engine_retry": "Appliquer les changements et réessayer", + "mcp.auth.reload_failed": "Échec du rechargement du worker avant la connexion.", + "mcp.auth.reload_notice": "Pour que cela prenne effet, OpenWork doit actualiser le service du worker. Cela peut interrompre une session en cours.", + "mcp.auth.reload_remote_confirm": "Pour que cela prenne effet, OpenWork doit actualiser le service du worker. Cela pourrait arrêter votre session en cours. Continuer ?", + "mcp.auth.reopen_browser_link": "Cliquez ici pour rouvrir le navigateur", + "mcp.auth.request_timed_out": "La requête a expiré.", + "mcp.auth.retry": "Réessayer", + "mcp.auth.retry_now": "Réessayer maintenant", + "mcp.auth.server_disabled": "Ce serveur MCP est désactivé. Activez-le et réessayez.", + "mcp.auth.step1_description": "Nous lancerons automatiquement le flux de connexion de {server}.", + "mcp.auth.step1_title": "Ouverture de votre navigateur", + "mcp.auth.step2_description": "Connectez-vous et approuvez l'accès lorsqu'on vous le demande.", + "mcp.auth.step2_title": "Autoriser OpenWork", + "mcp.auth.step3_description": "Nous terminerons la connexion dès que l'autorisation sera terminée.", + "mcp.auth.step3_title": "Revenez ici quand vous avez terminé", + "mcp.auth.try_reload_engine": "{message}. Essayez d'abord de recharger le moteur.", + "mcp.auth.waiting_authorization": "En attente de la fin de l'autorisation dans votre navigateur...", + "mcp.auth.waiting_for_conversation_body": "Nous vous redirigerons vers l'authentification dès que possible.", + "mcp.auth.waiting_for_conversation_title": "En attente de la fin de la conversation", + "mcp.auth.waiting_for_session": "En attente que {session} termine son travail", + "mcp.available_apps": "Applications disponibles", + "mcp.cap_signin": "Connexion au compte", + "mcp.cap_tools": "Outils IA", + "mcp.config_file": "Fichier de configuration", + "mcp.config_load_failed": "Impossible de charger le fichier de configuration", + "mcp.config_not_loaded": "Pas encore chargé", + "mcp.config_source": "Depuis la configuration", + "mcp.configured": "configuré", + "mcp.connect": "Connecter", + "mcp.connect_failed": "Impossible de se connecter. Réessayez.", + "mcp.connect_server_first": "Connectez-vous d'abord au serveur.", + "mcp.connected": "Connecté", + "mcp.connected_badge": "Connecté", + "mcp.connecting": "Connexion...", + "mcp.connection_failed": "Problème de connexion — réessayez", + "mcp.connection_type": "Connexion", + "mcp.control_chrome_browser_hint": "Dans Chrome 144 ou version ultérieure, faites d'abord ceci :", + "mcp.control_chrome_browser_step_one": "Ouvrez chrome://inspect/#remote-debugging.", + "mcp.control_chrome_browser_step_two": "Activez le débogage à distance.", + "mcp.control_chrome_browser_step_three": "Autorisez les connexions de débogage entrantes lorsque Chrome le demande.", + "mcp.control_chrome_browser_title": "1. Activer l'accès à Chrome", + "mcp.control_chrome_connect": "Ajouter Control Chrome", + "mcp.control_chrome_docs": "Guide MCP officiel", + "mcp.control_chrome_edit": "Modifier les paramètres", + "mcp.control_chrome_profile_hint": "Control Chrome ouvre normalement un profil Chrome séparé. Activez ceci si vous voulez qu'OpenWork réutilise la fenêtre Chrome déjà ouverte.", + "mcp.control_chrome_profile_title": "2. Choisir quel Chrome utiliser", + "mcp.control_chrome_save": "Enregistrer les paramètres", + "mcp.control_chrome_setup_subtitle": "Activez l'accès à Chrome, puis choisissez si OpenWork doit utiliser son propre profil propre ou se rattacher au Chrome que vous utilisez déjà.", + "mcp.control_chrome_setup_title": "Configurer Control Chrome", + "mcp.control_chrome_toggle_hint": "Lorsque cette option est activée, OpenWork ajoute --autoConnect pour que le MCP se rattache à une instance Chrome que vous avez déjà démarrée.", + "mcp.control_chrome_toggle_label": "Utiliser mon profil Chrome existant", + "mcp.control_chrome_toggle_off": "OpenWork lancera un profil Chrome séparé uniquement pour l'automatisation.", + "mcp.control_chrome_toggle_on": "OpenWork réutilisera vos onglets, cookies et connexions actuels.", + "mcp.custom_app_cta_hint": "Connectez votre propre serveur MCP, outil interne ou application hébergée.", + "mcp.desktop_required": "Les applications nécessitent l'application desktop.", + "mcp.docs_link": "En savoir plus", + "mcp.file_not_found": "Fichier de configuration pas encore créé", + "mcp.finish_setup": "On y est presque", + "mcp.finish_setup_hint": "Appuyez sur Activer pour terminer la connexion de votre application.", + "mcp.friendly_status_issue": "Problème", + "mcp.friendly_status_needs_signin": "Connexion requise", + "mcp.friendly_status_offline": "Hors ligne", + "mcp.friendly_status_paused": "En pause", + "mcp.friendly_status_ready": "Prêt", + "mcp.last_synced": "Synchronisé", + "mcp.login_action": "Se connecter", + "mcp.login_hint": "Connectez votre compte pour terminer la configuration de cette application.", + "mcp.login_unavailable": "Cette application ne prend pas en charge la connexion depuis OpenWork.", + "mcp.logout_action": "Se déconnecter", + "mcp.logout_failed": "Échec de la déconnexion.", + "mcp.logout_hint": "Supprime les identifiants OAuth stockés. Vous devrez vous reconnecter.", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "Cela supprimera les identifiants OAuth stockés pour {server}. Vous devrez vous reconnecter pour utiliser cette application.", + "mcp.logout_modal_title": "Se déconnecter de cette application ?", + "mcp.logout_success": "Déconnecté de {server}.", + "mcp.logout_working": "Déconnexion...", + "mcp.name_required": "Entrez un nom de serveur.", + "mcp.no_apps_hint": "Connectez-en une ci-dessus pour commencer.", + "mcp.no_apps_yet": "Aucune application connectée pour le moment", + "mcp.oauth": "Se connecter", + "mcp.oauth_optional_hint": "Utilise OAuth dans le navigateur pour connecter votre compte.", + "mcp.oauth_optional_label": "Cette application nécessite une connexion", + "mcp.one_click_connect": "Connexion en un clic", + "mcp.open_file": "Ouvrir le fichier", + "mcp.opening_label": "Ouverture...", + "mcp.pick_workspace_error": "Choisissez d'abord un dossier d'espace de travail.", + "mcp.pick_workspace_first": "Choisissez d'abord un dossier d'espace de travail.", + "mcp.quick_connect_chrome_desc": "Pilotez les onglets Chrome avec l'automatisation du navigateur.", + "mcp.quick_connect_chrome_title": "Control Chrome", + "mcp.quick_connect_context7_desc": "Recherchez dans la documentation produit avec un contexte plus riche.", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "Planifiez les sprints et livrez les tickets plus vite.", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "Pages, bases de données et docs projet synchronisés.", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "Suivez les releases et résolvez les erreurs de production.", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "Inspectez paiements, factures et abonnements.", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "Arrêtez la tâche en cours pour activer.", + "mcp.reload_banner_description": "Appuyez sur Activer pour terminer la connexion de votre application.", + "mcp.reload_banner_description_blocked": "Une tâche est en cours. Arrêtez-la d'abord, puis activez.", + "mcp.remote_workspace_url_hint": "Les workers distants se connectent plus rapidement avec des serveurs MCP basés sur une URL.", + "mcp.remove_app": "Supprimer", + "mcp.remove_failed": "Impossible de supprimer l'application.", + "mcp.remove_modal_message": "Êtes-vous sûr de vouloir supprimer {server} ? Vous pourrez toujours l'ajouter de nouveau plus tard.", + "mcp.remove_modal_title": "Supprimer l'application", + "mcp.reveal_config_failed": "Impossible d'ouvrir le fichier de configuration", + "mcp.reveal_in_finder": "Afficher dans le Finder", + "mcp.scope_global": "Tous les espaces de travail", + "mcp.scope_project": "Cet espace de travail", + "mcp.server_command": "Commande", + "mcp.server_command_hint": "La commande shell pour démarrer le serveur.", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "Nom de l'application", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "Type", + "mcp.server_url": "URL du serveur", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "Connexion", + "mcp.tap_to_connect": "Appuyez pour connecter", + "mcp.technical_details": "Détails techniques", + "mcp.type_cloud": "Cloud (connectez-vous avec votre compte)", + "mcp.type_local": "Local (s'exécute sur cet appareil)", + "mcp.type_local_cmd": "Local (commande)", + "mcp.type_remote": "Distant (URL)", + "mcp.url_or_command_required": "Entrez une URL pour un serveur distant ou une commande pour un serveur local.", + "mcp.your_apps": "Vos applications", + "message.tool_request_label": "Requête", + "message.tool_result_label": "Résultat", + "message.waiting_subagent": "En attente de l'arrivée de la transcription du sous-agent.", + "message_list.copy_message": "Copier le message", + "message_list.open_session": "Ouvrir la session", + "message_list.step_updates_progress": "Met à jour la progression", + "message_list.subagent_loading_transcript": "Chargement de la transcription", + "message_list.subagent_message_count": "{count} message{plural}", + "message_list.subagent_running": "En cours d'exécution", + "message_list.subagent_session_fallback": "Session du sous-agent", + "message_list.subagent_type_task": "tâche {agentType}", + "message_list.subagent_waiting_transcript": "En attente de la transcription", + "message_list.tool_checked_url": "{url} vérifié", + "message_list.tool_checked_web_fallback": "Page web vérifiée", + "message_list.tool_delegate_agent": "Déléguer {agent}", + "message_list.tool_delegate_task_fallback": "Déléguer la tâche", + "message_list.tool_load_skill_fallback": "Charger le Skill", + "message_list.tool_load_skill_named": "Charger le Skill {name}", + "message_list.tool_read_todo": "Lire la liste de tâches", + "message_list.tool_reviewed_file": "{file} relu", + "message_list.tool_reviewed_file_fallback": "Fichier relu", + "message_list.tool_reviewed_files_fallback": "Fichiers relus", + "message_list.tool_reviewed_path": "{path} relu", + "message_list.tool_run_command": "Exécuter {command}", + "message_list.tool_run_command_fallback": "Exécuter la commande", + "message_list.tool_searched_code_fallback": "Code recherché", + "message_list.tool_searched_pattern": "Recherche de {pattern}", + "message_list.tool_update_file": "Mettre à jour {file}", + "message_list.tool_update_file_fallback": "Mettre à jour le fichier", + "message_list.tool_update_todo": "Mettre à jour la liste de tâches", + "message_list.tool_updated_file": "{file} mis à jour", + "message_list.tool_updated_file_fallback": "Fichier mis à jour", + "model_behavior.desc_builtin": "Ce modèle décide lui-même de son chemin de raisonnement et n'expose pas ici de profils.", + "model_behavior.desc_generic": "Utiliser le profil {label}.", + "model_behavior.desc_high": "Passe plus de temps à raisonner avant de répondre.", + "model_behavior.desc_high_anthropic": "Utiliser le budget de réflexion étendue standard.", + "model_behavior.desc_low": "Utiliser un raisonnement plus léger avant de répondre.", + "model_behavior.desc_low_google": "Utiliser un budget de raisonnement plus léger pour des réponses plus rapides.", + "model_behavior.desc_max": "Utiliser le profil de raisonnement le plus profond du fournisseur.", + "model_behavior.desc_max_anthropic": "Utiliser le plus grand budget de réflexion étendue disponible.", + "model_behavior.desc_medium": "Équilibrer vitesse et profondeur de raisonnement.", + "model_behavior.desc_minimal": "Utiliser une très petite quantité de raisonnement.", + "model_behavior.desc_none": "Favoriser la vitesse avec le chemin de raisonnement le plus léger.", + "model_behavior.desc_standard": "Ce modèle n'expose pas de contrôles supplémentaires de raisonnement.", + "model_behavior.label_balanced": "Équilibré", + "model_behavior.label_builtin": "Intégré", + "model_behavior.label_deep": "Profond", + "model_behavior.label_extended": "Étendu", + "model_behavior.label_fast": "Rapide", + "model_behavior.label_light": "Léger", + "model_behavior.label_maximum": "Maximum", + "model_behavior.label_quick": "Express", + "model_behavior.label_standard": "Standard", + "model_behavior.title_builtin_reasoning": "Raisonnement intégré", + "model_behavior.title_extended_thinking": "Réflexion étendue", + "model_behavior.title_reasoning_budget": "Budget de raisonnement", + "model_behavior.title_reasoning_effort": "Effort de raisonnement", + "model_behavior.title_standard_generation": "Génération standard", + "model_picker.chat_model_desc": "Choisissez le modèle pour ce chat. Si un modèle prend en charge des profils de raisonnement, configurez-les sur sa carte.", + "model_picker.chat_model_title": "Modèle de chat", + "model_picker.connect_provider_hint": "Connectez ce fournisseur pour parcourir et enregistrer des modèles", + "model_picker.default_model_desc": "Choisissez le modèle par défaut pour les nouveaux chats, puis ajustez les profils de raisonnement sur sa carte avant d'appuyer sur Terminé.", + "model_picker.default_model_title": "Modèle par défaut", + "model_picker.model_count": "{count} modèles", + "model_picker.model_count_one": "1 modèle", + "model_picker.more_providers": "Plus de fournisseurs", + "model_picker.no_results": "Aucun modèle ne correspond à votre recherche.", + "model_picker.other_connected_models": "Autres modèles connectés", + "model_picker.recommended": "Recommandé", + "onboarding.access_label": "Accès", + "onboarding.add": "Ajouter", + "onboarding.add_folder_path": "Ajouter le chemin d'un dossier", + "onboarding.advanced_settings": "Paramètres avancés", + "onboarding.attach": "Attacher", + "onboarding.attach_description": "Attachez-vous à la session existante sur cet appareil.", + "onboarding.authorize_folder": "Autoriser le dossier", + "onboarding.back": "Retour", + "onboarding.checking_cli": "Vérification de la CLI OpenCode...", + "onboarding.choose_workspace_folder": "Choisir le dossier de l'espace de travail", + "onboarding.cli_checking": "Vérification de l'installation...", + "onboarding.cli_install_commands": "Installez OpenCode avec l'une des commandes ci-dessous, puis redémarrez OpenWork.", + "onboarding.cli_label": "CLI OpenCode", + "onboarding.cli_needs_update": "La CLI OpenCode doit être mise à jour pour serve.", + "onboarding.cli_not_found": "CLI OpenCode introuvable.", + "onboarding.cli_not_found_hint": "Introuvable. Installez-la pour exécuter le serveur local.", + "onboarding.cli_ready": "CLI OpenCode prête.", + "onboarding.cli_recheck": "Revérifier", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "Installé", + "onboarding.create_first_workspace": "Créer votre premier espace de travail", + "onboarding.create_workspace": "Créer un espace de travail", + "onboarding.engine_running": "Moteur déjà en cours d'exécution", + "onboarding.folders_allowed": "{count} dossier{plural} autorisé", + "onboarding.getting_ready": "Préparation en cours", + "onboarding.install": "Installer OpenCode", + "onboarding.install_instruction": "Installez OpenCode pour activer le serveur local (aucun terminal requis).", + "onboarding.last_checked": "Dernière vérification {time}", + "onboarding.manage_access_hint": "Vous pouvez gérer l'accès dans les paramètres avancés.", + "onboarding.open_settings": "Ouvrir les paramètres", + "onboarding.open_settings_hint": "Besoin d'options de moteur ou d'accès ? Ouvrez les paramètres.", + "onboarding.pick": "Choisir", + "onboarding.ready_message": "OpenCode est prêt à démarrer le serveur local.", + "onboarding.remember_choice": "Mémoriser mon choix pour la prochaine fois", + "onboarding.remote_workspace_action": "Connecter", + "onboarding.remote_workspace_card_description": "Connectez-vous à un serveur OpenWork pour accéder à un espace de travail partagé.", + "onboarding.remote_workspace_card_title": "Connecter un espace de travail distant", + "onboarding.remote_workspace_description": "Connectez-vous à un serveur OpenWork pour accéder à un espace de travail depuis n'importe où.", + "onboarding.remote_workspace_title": "Se connecter au serveur OpenWork", + "onboarding.remove": "Supprimer", + "onboarding.resolved_path": "Chemin résolu", + "onboarding.run_local": "Exécuter en local", + "onboarding.run_local_description": "OpenWork exécute OpenCode localement et garde votre travail privé.", + "onboarding.search_notes": "Rechercher des notes", + "onboarding.searching_host": "Connexion au serveur OpenWork...", + "onboarding.serve_help": "sortie de serve --help", + "onboarding.show_search_notes": "Afficher les notes de recherche", + "onboarding.start": "Démarrer OpenWork", + "onboarding.starting_host": "Démarrage du serveur OpenWork...", + "onboarding.theme_current": "Actuel : {mode}", + "onboarding.theme_dark": "Sombre", + "onboarding.theme_label": "Thème", + "onboarding.theme_light": "Clair", + "onboarding.theme_system": "Système", + "onboarding.verifying": "Vérification de l'échange sécurisé", + "onboarding.version": "Version", + "onboarding.welcome_title": "Comment souhaitez-vous exécuter OpenWork aujourd'hui ?", + "onboarding.windows_install_instruction": "Installez OpenCode pour Windows, puis redémarrez OpenWork. Assurez-vous que opencode.exe est dans le PATH.", + "onboarding.workspace_folder_label": "Un espace de travail est un dossier avec ses propres Skills, Plugins et Commands.", + "plugins.add": "Ajouter", + "plugins.add_hint": "Ajoutez des noms de packages npm, par ex. opencode-wakatime", + "plugins.add_label": "Ajouter un plugin", + "plugins.added": "Ajouté", + "plugins.config": "Config", + "plugins.config_label": "Config", + "plugins.desc": "Gérez `opencode.json` pour votre projet ou les Plugins OpenCode globaux.", + "plugins.empty": "Aucun Plugin configuré pour le moment.", + "plugins.enabled": "Activé", + "plugins.hide_setup": "Masquer la configuration", + "plugins.not_loaded": "Pas encore chargé", + "plugins.not_loaded_yet": "Pas encore chargé", + "plugins.remove": "Supprimer", + "plugins.scope_global": "Global", + "plugins.scope_project": "Projet", + "plugins.setup": "Configuration", + "plugins.suggested": "Plugins suggérés", + "plugins.suggested_heading": "Plugins suggérés", + "plugins.title": "Plugins OpenCode", + "providers.api_key_label": "Clé API", + "providers.api_key_required": "La clé API est requise", + "providers.auth_failed": "Échec de l'authentification", + "providers.connect_failed": "Échec de la connexion au fournisseur", + "providers.disabled_in_config_suffix": "et l'a désactivé dans la configuration OpenCode.", + "providers.disconnect_failed": "Échec de la déconnexion du fournisseur", + "providers.disconnected_prefix": "Déconnecté", + "providers.load_failed": "Échec du chargement des fournisseurs", + "providers.no_oauth_prefix": "Aucun flux OAuth disponible pour", + "providers.no_providers_available": "Aucun fournisseur disponible", + "providers.not_connected": "Non connecté à un serveur", + "providers.not_oauth_flow_prefix": "La méthode d'authentification sélectionnée n'est pas un flux OAuth pour", + "providers.oauth_failed": "Impossible de terminer OAuth", + "providers.oauth_method_required": "La méthode OAuth est requise", + "providers.provider_error": "Erreur du fournisseur ({provider})", + "providers.provider_id_required": "L'ID du fournisseur est requis", + "providers.rate_limit_exceeded": "Limite de débit dépassée", + "providers.removal_unsupported": "La suppression de l'authentification du fournisseur n'est pas prise en charge par ce client.", + "providers.request_failed": "Échec de la requête", + "providers.save_api_key_failed": "Échec de l'enregistrement de la clé API", + "providers.still_connected_suffix": ", mais le worker le signale toujours comme connecté. Effacez toute clé API ou identifiant OAuth restant et redémarrez le worker pour le déconnecter complètement.", + "providers.unknown_provider": "Fournisseur inconnu", + "providers.use_api_key_suffix": "Utilisez plutôt une clé API.", + "question_modal.custom_answer_label": "Ou saisissez une réponse personnalisée", + "question_modal.custom_answer_placeholder": "Tapez votre réponse ici...", + "question_modal.question_counter": "Question {current} sur {total}", + "session.allow_for_session": "Autoriser pour la session", + "session.allow_once": "Autoriser une fois", + "session.api_key_saved": "Clé API enregistrée", + "session.attachments_add_token": "Ajoutez un jeton de serveur pour joindre des fichiers.", + "session.attachments_connect_server": "Connectez-vous au serveur OpenWork pour joindre des fichiers.", + "session.back": "Retour", + "session.close_quick_actions": "Fermer les actions rapides", + "session.close_search": "Fermer la recherche", + "session.cmd_compact_detail": "Envoyer une instruction de compaction à OpenCode pour cette session", + "session.cmd_compact_detail_empty": "Aucun message utilisateur à compacter pour le moment", + "session.cmd_compact_meta": "Compacter", + "session.cmd_compact_title": "Compacter la conversation", + "session.cmd_current_workspace": "Espace de travail actuel", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "Modèle", + "session.cmd_model_meta": "Ouvrir", + "session.cmd_model_title": "Changer le modèle", + "session.cmd_new_session_detail": "Démarrer une nouvelle tâche dans l'espace de travail actuel", + "session.cmd_new_session_meta": "Créer", + "session.cmd_new_session_title": "Créer une nouvelle session", + "session.cmd_provider_detail": "Ouvrir le flux de connexion du fournisseur", + "session.cmd_provider_meta": "Ouvrir", + "session.cmd_provider_title": "Connecter un fournisseur", + "session.cmd_rename_detail_fallback": "Donnez un nom plus clair à la session sélectionnée", + "session.cmd_rename_meta": "Renommer", + "session.cmd_rename_title": "Renommer la session actuelle", + "session.cmd_sessions_detail": "{count} disponibles dans tous les espaces de travail", + "session.cmd_sessions_meta": "Aller", + "session.cmd_sessions_title": "Rechercher des Sessions", + "session.cmd_switch": "Basculer", + "session.compacted": "Session compactée.", + "session.compacting": "Compaction du contexte de la session...", + "session.compacting_auto": "OpenCode compacte automatiquement cette session", + "session.compacting_manual": "OpenCode compacte cette session", + "session.compaction_finished": "OpenCode a terminé la compaction du contexte de la session.", + "session.compaction_started": "OpenCode a commencé la compaction du contexte de la session.", + "session.conflict_sync_toast": "Conflit de synchronisation de {path}. Les modifications locales ont été enregistrées dans {conflictPath}.", + "session.connect_failed": "Échec de la connexion", + "session.connect_to_sync": "Connectez-vous au serveur OpenWork pour synchroniser les fichiers distants.", + "session.create_or_connect_workspace": "Créer ou connecter un espace de travail", + "session.create_workspace_desc": "Ouvrez le créateur d'espace de travail et choisissez comment vous souhaitez démarrer.", + "session.create_workspace_title": "Créer un espace de travail", + "session.default_agent": "Agent par défaut", + "session.default_title": "Nouvelle session", + "session.delete": "Supprimer", + "session.delete_named_session_message": "Cela supprimera définitivement \"{title}\" et ses messages.", + "session.delete_session_generic": "Cela supprimera définitivement la session sélectionnée et ses messages.", + "session.delete_session_title": "Supprimer la session ?", + "session.deleted": "Session supprimée", + "session.deleting": "Suppression...", + "session.deny": "Refuser", + "session.details": "Détails", + "session.details_label": "Détails", + "session.doom_loop_label": "Boucle infinie", + "session.doom_loop_message": "OpenCode a détecté des appels d'outils répétés avec une entrée identique et demande s'il doit continuer après des échecs répétés.", + "session.doom_loop_note": "Refusez pour arrêter la boucle, ou autorisez si vous voulez que l'agent continue d'essayer.", + "session.doom_loop_repeated_call_label": "Appel répété", + "session.doom_loop_repeated_tool_call": "Appel d'outil répété", + "session.doom_loop_title": "Boucle infinie détectée", + "session.doom_loop_tool_label": "Outil", + "session.downloading": "Téléchargement", + "session.downloading_percent": "Téléchargement {percent}%", + "session.downloading_update_title": "Téléchargement de la mise à jour {version}", + "session.export_already_running": "L'export est déjà en cours.", + "session.export_desktop_only": "L'export est disponible dans l'application desktop.", + "session.export_desktop_only_local": "L'export est disponible pour les workers locaux dans l'application desktop.", + "session.export_local_only": "L'export n'est pris en charge que pour les workers locaux.", + "session.failed_to_compact": "Échec de la compaction de la session", + "session.failed_to_create_session": "Échec de la création de la session", + "session.failed_to_delete": "Échec de la suppression de la session", + "session.failed_to_load_agents": "Échec du chargement des agents", + "session.failed_to_load_providers": "Échec du chargement des fournisseurs", + "session.failed_to_redo": "Échec du rétablissement", + "session.failed_to_save_api_key": "Échec de l'enregistrement de la clé API", + "session.failed_to_stop": "Échec de l'arrêt", + "session.failed_to_undo": "Échec de l'annulation", + "session.file_open_desktop_only": "L'ouverture de fichier est disponible dans l'application desktop.", + "session.file_open_failed": "Échec de l'ouverture du fichier", + "session.file_open_remote_unavailable": "L'ouverture de fichier n'est pas disponible pour les espaces de travail distants.", + "session.flyout_file_modified": "Fichier modifié", + "session.flyout_new_task": "Nouvelle tâche", + "session.install_update": "Installer la mise à jour", + "session.jump_to_latest": "Aller au plus récent", + "session.jump_to_start": "Aller au début du message", + "session.load_earlier": "Charger les messages précédents", + "session.loading_detail": "Récupération des derniers messages pour cette tâche.", + "session.loading_earlier": "Chargement des messages précédents...", + "session.loading_session": "Chargement de la session", + "session.loading_title": "Chargement de la session", + "session.menu_label": "Menu", + "session.model": "Modèle", + "session.model_fallback": "Modèle", + "session.new_task": "Nouvelle tâche", + "session.next_match": "Résultat suivant", + "session.no_matches": "Aucun résultat", + "session.no_matches_command": "Aucun résultat.", + "session.no_session_selected": "Aucune session sélectionnée", + "session.nothing_to_compact": "Rien à compacter pour le moment.", + "session.nothing_to_redo": "Rien à rétablir.", + "session.nothing_to_retry": "Rien à relancer pour le moment", + "session.nothing_to_undo": "Rien à annuler pour le moment.", + "session.oauth_failed": "Échec d'OAuth", + "session.obsidian_worker_relative_only": "Seuls les fichiers relatifs au worker peuvent être ouverts dans Obsidian.", + "session.open": "Ouvrir", + "session.palette_hint_navigate": "Touches fléchées pour naviguer", + "session.palette_hint_run": "Entrée pour exécuter · Échap pour fermer", + "session.palette_placeholder_actions": "Rechercher des actions", + "session.palette_placeholder_sessions": "Trouver par titre de session ou espace de travail", + "session.palette_title_actions": "Actions rapides", + "session.palette_title_sessions": "Rechercher des Sessions", + "session.permission_detail_command": "Commande", + "session.permission_detail_cwd": "Répertoire de travail", + "session.permission_detail_description": "Description", + "session.permission_detail_diff": "Diff", + "session.permission_detail_file": "Fichier", + "session.permission_detail_files": "Fichiers", + "session.permission_detail_agent": "Agent", + "session.permission_detail_parent_directory": "Répertoire parent", + "session.permission_detail_path": "Chemin", + "session.permission_detail_query": "Requête", + "session.permission_detail_target": "Cible", + "session.permission_detail_tool": "Outil", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "Modification de fichier", + "session.permission_kind_external_directory": "Répertoire externe", + "session.permission_kind_question": "Question", + "session.permission_kind_read": "Lecture de fichier", + "session.permission_kind_skill": "Skill", + "session.permission_kind_task": "Sous-tâche", + "session.permission_kind_todowrite": "Écriture de tâches", + "session.permission_label": "Autorisation", + "session.permission_message": "OpenCode demande une autorisation pour continuer.", + "session.permission_message_bash": "Vérifiez la portée de la commande avant d'autoriser OpenCode à continuer.", + "session.permission_message_edit": "Vérifiez le fichier et le diff avant d'autoriser OpenCode à modifier des fichiers.", + "session.permission_message_external_directory": "Vérifiez le dossier avant d'autoriser l'accès en dehors du workspace.", + "session.permission_message_read": "Vérifiez la portée des fichiers demandés avant d'autoriser l'accès.", + "session.permission_message_task": "Vérifiez la sous-tâche demandée avant d'autoriser son démarrage.", + "session.permission_metadata_unavailable": "Impossible d'afficher les métadonnées.", + "session.permission_required": "Autorisation requise", + "session.permission_review_label": "Revue", + "session.permission_scope_empty": "Aucune portée spécifique fournie.", + "session.permission_decision_hint": "Autorisez une fois pour cette demande, ou pour la session lorsque vous faites confiance à cette portée.", + "session.permission_title_bash": "Exécuter une commande shell ?", + "session.permission_title_edit": "Modifier des fichiers ?", + "session.permission_title_external_directory": "Accéder à un dossier externe ?", + "session.permission_title_generic": "Approuver {permission} ?", + "session.permission_title_read": "Lire des fichiers ?", + "session.permission_title_task": "Démarrer une sous-tâche ?", + "session.phase_responding": "Réponse en cours", + "session.phase_retrying": "Nouvelle tentative", + "session.phase_run_failed": "Exécution échouée", + "session.phase_sending": "Envoi", + "session.pick_folder_desc": "Choisissez un dossier de projet ou de notes existant et OpenWork l'utilisera comme espace de travail.", + "session.pick_folder_title": "Choisissez un dossier dans lequel vous voulez travailler", + "session.pick_workspace_to_open": "Choisissez un espace de travail pour ouvrir des fichiers.", + "session.prev_match": "Résultat précédent", + "session.provider_auth_in_progress": "L'authentification du fournisseur est déjà en cours.", + "session.provider_connected": "Fournisseur connecté", + "session.quick_actions_label": "Actions rapides", + "session.quick_actions_title": "Actions rapides (Ctrl/Cmd+K)", + "session.redo_aria_label": "Rétablir le dernier message annulé", + "session.redo_label": "Rétablir", + "session.redo_title": "Rétablir le dernier message annulé", + "session.remote_sync_failed": "Échec de la synchronisation du fichier distant", + "session.rename_description": "Mettez à jour le nom de cette session.", + "session.rename_label": "Nom de la session", + "session.rename_placeholder": "Entrez un nouveau nom", + "session.rename_title": "Renommer la session", + "session.resize_workspace_column": "Redimensionner la colonne de l'espace de travail", + "session.restart_update_title": "Redémarrer pour appliquer la mise à jour {version}", + "session.restored_message": "Le message annulé a été restauré.", + "session.reveal": "Révéler", + "session.reveal_desktop_only": "Révéler est disponible dans l'application desktop.", + "session.revert_label": "Annuler", + "session.reverted_last_message": "Le dernier message utilisateur a été annulé.", + "session.run": "Exécuter", + "session.scope_label": "Portée", + "session.search_conversation_label": "Rechercher dans la conversation", + "session.search_conversation_title": "Rechercher dans la conversation (Ctrl/Cmd+F)", + "session.search_next": "Suivant", + "session.search_placeholder": "Rechercher dans ce chat", + "session.search_position": "{current} sur {total}", + "session.search_prev": "Précédent", + "session.share_active_cloud_org": "Organisation Cloud active", + "session.share_choose_org": "Choisissez une organisation dans Paramètres -> Cloud avant de partager avec votre équipe.", + "session.share_collaborator_hint": "Accès distant courant lorsque vous n'avez pas besoin d'actions réservées au propriétaire.", + "session.share_collaborator_host_hint": "Accès distant courant à cet hôte sans actions réservées au propriétaire.", + "session.share_collaborator_label": "Jeton collaborateur", + "session.share_collaborator_token": "Jeton collaborateur", + "session.share_connected_with_hint": "Cet espace de travail est actuellement connecté avec ce mot de passe.", + "session.share_desktop_app_required": "Application desktop requise", + "session.share_desktop_required": "Application desktop requise", + "session.share_host_url_and_token_required": "L'URL et le jeton de l'hôte OpenWork sont requis.", + "session.share_local_host_not_ready": "L'hôte OpenWork local n'est pas encore prêt.", + "session.share_missing_host_url": "URL d'hôte OpenWork manquante.", + "session.share_missing_token": "Jeton OpenWork manquant.", + "session.share_no_skills": "Aucun Skill trouvé dans cet espace de travail.", + "session.share_note_direct_runtime": "Le runtime du moteur est réglé sur Direct. Le changement de workers locaux peut redémarrer l'hôte et déconnecter les clients. Le jeton peut changer après un redémarrage.", + "session.share_opencode_base_url": "URL de base OpenCode", + "session.share_openwork_workers_only": "Les liens de service de partage ne sont disponibles que pour les workers OpenWork.", + "session.share_owner_permission_hint": "À utiliser lorsque le client distant doit répondre aux demandes d'autorisation.", + "session.share_password": "Mot de passe", + "session.share_password_owner_hint": "À utiliser lorsque le client distant doit répondre aux demandes d'autorisation.", + "session.share_publish_skills_failed": "Échec de la publication de l'ensemble de Skills", + "session.share_publish_workspace_failed": "Échec de la publication du profil d'espace de travail", + "session.share_resolve_local_workspace_failed": "Impossible de résoudre cet espace de travail sur l'hôte OpenWork local.", + "session.share_resolve_remote_workspace_failed": "Impossible de résoudre cet espace de travail sur l'hôte OpenWork.", + "session.share_save_team_template_failed": "Échec de l'enregistrement du modèle d'équipe", + "session.share_saved_to_org": "{name} enregistré dans {org}.", + "session.share_select_workspace": "Sélectionnez d'abord un espace de travail.", + "session.share_set_token_hint": "Définir le jeton dans les paramètres de l'espace de travail", + "session.share_sign_in_required": "Connectez-vous à OpenWork Cloud dans les Paramètres pour partager avec votre équipe.", + "session.share_skills_set_desc": "Ensemble complet de Skills depuis un espace de travail OpenWork.", + "session.share_starting_server": "Démarrage du serveur...", + "session.share_team_fallback_name": "vos modèles d'équipe", + "session.share_url_resolving_hint": "L'URL du worker est en cours de résolution ; l'URL de l'hôte est affichée en secours.", + "session.share_url_worker_hint": "À utiliser sur les téléphones ou ordinateurs portables se connectant à ce worker.", + "session.share_worker_url": "URL du worker", + "session.share_worker_url_phones_hint": "À utiliser sur les téléphones ou ordinateurs portables se connectant à ce worker.", + "session.share_worker_url_resolving_hint": "L'URL du worker est en cours de résolution ; l'URL de l'hôte est affichée en secours.", + "session.shared_folder_upload_failed": "Échec du téléversement dans le dossier partagé", + "session.show_earlier": "Afficher {count} message{plural} précédent", + "session.status_active": "Session active", + "session.status_compacting": "Compaction du contexte", + "session.status_delegating": "Délégation", + "session.status_gathering_context": "Collecte du contexte", + "session.status_planning": "Planification", + "session.status_ready": "Prêt", + "session.status_ready_session": "Session prête", + "session.status_running_shell": "Exécution du shell", + "session.status_searching_codebase": "Recherche dans la base de code", + "session.status_searching_web": "Recherche sur le web", + "session.status_thinking": "Réflexion", + "session.status_working": "Travail en cours", + "session.status_writing_file": "Écriture du fichier", + "session.stopped": "Arrêté.", + "session.stopping_run": "Arrêt de l'exécution...", + "session.todo_progress": "{completed} tâches terminées sur {total}", + "session.trying_again": "Nouvelle tentative...", + "session.unable_to_open_file": "Impossible d'ouvrir le fichier", + "session.unable_to_open_obsidian": "Impossible d'ouvrir le fichier dans Obsidian", + "session.unable_to_reveal": "Impossible de révéler l'espace de travail", + "session.undo_label": "Annuler", + "session.undo_title": "Annuler le dernier message", + "session.update_available": "Mise à jour disponible", + "session.update_available_title": "Mise à jour disponible {version}", + "session.update_ready": "Mise à jour prête", + "session.update_ready_stop_runs_title": "Mise à jour {version} prête. Arrêtez les exécutions actives pour redémarrer.", + "session.upload_connect_server": "Connectez-vous au serveur OpenWork pour téléverser des fichiers dans le dossier partagé.", + "session.uploaded_to_shared_folder": "Téléversé dans le dossier partagé.", + "session.uploaded_with_summary": "Téléversé dans le dossier partagé : {summary}", + "session.uploading_to_shared_folder": "Téléversement de {label} dans le dossier partagé...", + "session.workspace_fallback": "Espace de travail", + "session.workspace_label": "Espace de travail", + "session.workspace_path_unavailable": "Le chemin de l'espace de travail est indisponible.", + "session.workspace_setup_desc": "Commencez avec un espace de travail OpenWork guidé, ou choisissez un dossier existant dans lequel vous voulez travailler.", + "session.workspace_setup_label": "Configuration de l'espace de travail", + "session.workspace_setup_title": "Configurer votre premier espace de travail", + "settings.action_download": "Télécharger", + "settings.action_install": "Installer", + "settings.actor_host": "hôte", + "settings.actor_remote": "distant", + "settings.actor_unknown": "inconnu", + "settings.advanced": "Avancé", + "settings.advanced_title": "Avancé", + "settings.api_keys_info": "Les clés API sont stockées localement par OpenCode. Les fournisseurs basés sur l'environnement doivent être modifiés dans l'environnement du worker puis rechargés.", + "settings.appearance_hint": "Correspondre au système ou forcer le mode clair/sombre.", + "settings.appearance_title": "Apparence", + "settings.audit_error": "Erreur", + "settings.audit_loading": "Chargement", + "settings.audit_log_title": "Journal d'audit", + "settings.audit_ready": "Prêt", + "settings.auto_compact": "Compaction automatique du contexte", + "settings.auto_compact_desc": "Contrôle compaction.auto d'OpenCode pour cet espace de travail. Rechargez le moteur après modification.", + "settings.auto_update_desc": "Télécharger les mises à jour automatiquement (invite à", + "settings.auto_update_title": "Mise à jour automatique", + "settings.available_count": "{count} disponibles", + "settings.background_checks_desc": "OpenWork vérifie toujours au lancement. Vérifie aussi une fois", + "settings.background_checks_title": "Vérifications en arrière-plan", + "settings.base_url_unavailable": "URL de base indisponible", + "settings.binary_unavailable": "Binaire indisponible", + "settings.cache_nothing_to_repair": "Aucun cache OpenCode trouvé. Rien à réparer.", + "settings.cache_repair_requires_desktop": "La réparation du cache nécessite l'application desktop", + "settings.cache_repaired": "Cache OpenCode réparé. Redémarrez le moteur s'il était en cours d'exécution.", + "settings.cap_browser_tools": "Outils navigateur : {value}", + "settings.cap_commands": "Commands : {value}", + "settings.cap_config": "Config : {value}", + "settings.cap_file_tools": "Outils fichiers : {value}", + "settings.cap_inbox_off": "boîte de réception désactivée", + "settings.cap_inbox_on": "boîte de réception activée", + "settings.cap_mcp": "MCP : {value}", + "settings.cap_outbox_off": "boîte d'envoi désactivée", + "settings.cap_outbox_on": "boîte d'envoi activée", + "settings.cap_plugins": "Plugins : {value}", + "settings.cap_read": "lecture", + "settings.cap_sandbox": "Sandbox : {value}", + "settings.cap_skills": "Skills : {value}", + "settings.cap_write": "écriture", + "settings.capabilities_title": "Capacités du serveur OpenWork", + "settings.capabilities_unavailable": "Capacités indisponibles. Connectez-vous avec un jeton client.", + "settings.change": "Modifier", + "settings.check_update": "Vérifier", + "settings.checking_for_updates": "Vérification des mises à jour", + "settings.choose": "Choisir", + "settings.clear": "Effacer", + "settings.clipboard_unavailable": "Le presse-papiers est indisponible dans cet environnement.", + "settings.configure": "Configurer", + "settings.connect_opencode_hint": "Connectez-vous à OpenCode pour charger les fournisseurs.", + "settings.connect_provider": "Connecter un fournisseur", + "settings.connected_count": "{count} connectés", + "settings.connection": "Connexion", + "settings.connection_failed": "Échec de la connexion", + "settings.connection_title": "Connexion", + "settings.copied_debug_report": "Rapport d'exécution JSON copié.", + "settings.copy_failed": "Échec de la copie du rapport d'exécution.", + "settings.copy_json": "Copier le JSON", + "settings.custom_binary_hint": "Utilisez ceci pour pointer OpenWork vers une build locale d'OpenCode", + "settings.custom_binary_label": "Binaire OpenCode personnalisé", + "settings.data_dir_unavailable": "Répertoire de données indisponible", + "settings.debug_commit": "Commit : {sha}", + "settings.debug_desktop_app": "Application desktop : {version}", + "settings.debug_opencode_version": "OpenCode : {version}", + "settings.debug_openwork_server_version": "Serveur OpenWork : {version}", + "settings.debug_section_title": "Développeur", + "settings.deeplink_failed": "Échec de l'ouverture du lien profond.", + "settings.deeplink_hint": "Accepte openwork://, openwork-dev:// ou une URL brute prise en charge https://share.openworklabs.com/b/... .", + "settings.default_model": "Modèle par défaut", + "settings.delete_containers": "Suppression des conteneurs...", + "settings.delete_local_config": "Suppression de l'état local...", + "settings.desktop_only_hint": "Disponible dans l'application desktop.", + "settings.dev_mode_badge": "Mode dev", + "settings.developer": "Développeur", + "settings.developer_mode_desc": "Active les outils de débogage, diagnostics et l'onglet Développeur.", + "settings.developer_mode_title": "Mode développeur", + "settings.developer_panel_disabled": "Panneau développeur activé.", + "settings.developer_panel_enabled": "Panneau développeur activé.", + "settings.devlog_cleared": "Sortie du journal développeur effacée.", + "settings.devlog_clipboard_unavailable": "Le presse-papiers est indisponible dans cet environnement.", + "settings.devlog_copied": "Sortie du journal développeur copiée.", + "settings.devlog_copy_failed": "Échec de la copie de la sortie du journal développeur.", + "settings.devlog_export_failed": "Échec de l'export de la sortie du journal développeur.", + "settings.devlog_export_unavailable": "L'export est indisponible dans cet environnement.", + "settings.devlog_exported": "Sortie du journal développeur exportée.", + "settings.devtools_desc": "Santé des sidecars, capacités et piste d'audit.", + "settings.devtools_title": "Outils de dev", + "settings.diag_approval": "Approbation : {mode} ({ms}ms)", + "settings.diag_config_path": "Chemin de configuration : {path}", + "settings.diag_daemon_url": "Daemon : {url}", + "settings.diag_default": "par défaut", + "settings.diag_health_port": "Port de santé : {port}", + "settings.diag_healthy_ms": "Sain : {ms}ms", + "settings.diag_host_token_source": "Source du jeton hôte : {source}", + "settings.diag_last_attempt": "Dernière tentative : {time}", + "settings.diag_load_sessions_ms": "Chargement des sessions : {ms}ms", + "settings.diag_opencode_binary": "Binaire OpenCode : {binary}", + "settings.diag_opencode_url": "OpenCode : {url}", + "settings.diag_pending_permissions_ms": "Autorisations en attente : {ms}ms", + "settings.diag_pid": "PID : {pid}", + "settings.diag_providers_ms": "Fournisseurs : {ms}ms", + "settings.diag_read_only": "Lecture seule : {value}", + "settings.diag_reason": "Raison : {reason}", + "settings.diag_runtime_workspace": "Espace de travail runtime : {id}", + "settings.diag_selected_workspace": "Espace de travail sélectionné : {id}", + "settings.diag_sidecar": "Sidecar : {info}", + "settings.diag_started": "Démarré : {time}", + "settings.diag_token_source": "Source du jeton : {source}", + "settings.diag_total_ms": "Total : {ms}ms", + "settings.diag_version": "Version : {version}", + "settings.diag_workspaces": "Espaces de travail : {count}", + "settings.diagnostics_unavailable": "Diagnostics indisponibles.", + "settings.disable_developer_mode": "Désactiver le mode développeur", + "settings.disabled": "Désactivé", + "settings.disconnect": "Déconnecter", + "settings.disconnect_confirm_suffix": "Déconnecter {resolved} ? Cela supprimera les clés API ou identifiants OAuth stockés pour ce fournisseur.", + "settings.disconnect_server": "Déconnecter le serveur", + "settings.disconnected_prefix": "{resolved} déconnecté.", + "settings.disconnecting": "Déconnexion...", + "settings.docker_containers_desc": "Supprimer de force les conteneurs Docker lancés par OpenWork", + "settings.docker_containers_title": "Conteneurs Docker OpenWork", + "settings.docker_requires_desktop": "Le nettoyage Docker nécessite l'application desktop", + "settings.done": "Terminé", + "settings.downloading_bytes": "Téléchargement {downloaded}", + "settings.downloading_progress": "Téléchargement {downloaded} / {total} ({percent}%)", + "settings.enable_developer_mode": "Activer le mode développeur", + "settings.enable_exa": "Activer la recherche web Exa", + "settings.enable_exa_desc": "S'applique lorsque OpenWork Orchestrator lance OpenCode. Désactivé par", + "settings.enabled": "Activé", + "settings.engine_bundled": "Intégré (recommandé)", + "settings.engine_bundled_hint": "Le moteur intégré est l'option la plus fiable. Utilisez Système", + "settings.engine_custom_binary": "Binaire personnalisé", + "settings.engine_desc": "Choisissez comment OpenCode s'exécute localement.", + "settings.engine_runtime_label": "Runtime du moteur", + "settings.engine_source": "Source du moteur", + "settings.engine_source_debug": "Source du moteur", + "settings.engine_system_path": "Installation système (PATH)", + "settings.engine_title": "Moteur", + "settings.environment.add_button": "Add variable", + "settings.environment.add_title": "Add environment variable", + "settings.environment.apply_button": "Apply changes", + "settings.environment.apply_blocked_active_tasks": "Stop running tasks before applying environment changes.", + "settings.environment.apply_confirm_body": "OpenWork will restart local agents so they can use the latest environment. Running local tasks may stop.", + "settings.environment.apply_no_local_workspace": "OpenWork is not connected to a local workspace.", + "settings.environment.apply_pending_body": "Apply changes to restart local agents and make the latest values available.", + "settings.environment.apply_pending_body_manual": "Restart local agents to make the latest values available.", + "settings.environment.apply_pending_title": "Changes are saved, not active yet", + "settings.environment.apply_refresh_failed": "Changes are active, but OpenWork status did not refresh. Reopen the app if it looks stale.", + "settings.environment.apply_success": "Environment changes are active.", + "settings.environment.apply_title": "Apply environment changes?", + "settings.environment.apply_unavailable": "Apply changes is only available in the desktop app.", + "settings.environment.applying": "Applying…", + "settings.environment.cancel": "Cancel", + "settings.environment.click_to_edit": "Click to edit", + "settings.environment.close_editor": "Close editor", + "settings.environment.confirm_delete": "Delete {key}? Agents stop seeing this key after you apply changes.", + "settings.environment.delete": "Delete", + "settings.environment.delete_title": "Delete environment variable", + "settings.environment.delete_variable": "Delete {key}", + "settings.environment.deleting": "Deleting…", + "settings.environment.description": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device; changes become available after you apply them.", + "settings.environment.edit_title": "Edit environment variable", + "settings.environment.empty_body": "Add keys like ANTHROPIC_API_KEY, GOOGLE_API_KEY, ELEVENLABS_API_KEY, or GITHUB_TOKEN for services your agents and MCP servers need.", + "settings.environment.empty_title": "No environment variables yet", + "settings.environment.empty_value": "(empty)", + "settings.environment.footer_hint": "OPENWORK_ and OPENCODE_ keys are reserved for app/runtime wiring. Configure OpenCode runtime settings from your shell.", + "settings.environment.hide": "Hide", + "settings.environment.hide_value": "Hide value for {key}", + "settings.environment.key_hint": "Letters, digits, and underscores. Cannot start with a digit.", + "settings.environment.key_label": "Key", + "settings.environment.loading": "Loading…", + "settings.environment.override_hint": "Environment variables set before OpenWork starts take precedence over values saved here.", + "settings.environment.remote_workspace_hint": "This workspace is remote. Local environment variables are hidden here; use cloud LLM Providers or configure the worker host directly.", + "settings.environment.restart_required": "Saved. Apply changes to make the update available.", + "settings.environment.reveal": "Reveal", + "settings.environment.reveal_value": "Reveal value for {key}", + "settings.environment.save": "Save", + "settings.environment.saving": "Saving…", + "settings.environment.title": "Environment variables", + "settings.environment.validation_duplicate": "A variable with this name already exists.", + "settings.environment.validation_empty": "Name is required.", + "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", + "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", + "settings.environment.value_label": "Value", + "settings.exa_restart_hint": "Redémarrez OpenCode ou l'orchestrateur après avoir modifié ce paramètre.", + "settings.export": "Exporter", + "settings.export_failed": "Échec de l'export du rapport d'exécution.", + "settings.export_unavailable": "L'export est indisponible dans cet environnement.", + "settings.exported_debug_report": "Rapport d'exécution JSON exporté.", + "settings.failed": "Échec", + "settings.failed_open_providers": "Échec de l'ouverture des fournisseurs", + "settings.feedback_badge": "Nous lisons chaque message", + "settings.feedback_desc": "Dites-nous ce qui vous plaît et ce qui vous semble difficile. Les retours vont directement à l'équipe et nous aident à prioriser ce qui sera livré ensuite.", + "settings.feedback_title": "Aidez à façonner OpenWork", + "settings.group_global": "Global", + "settings.group_workspace": "Espace de travail", + "settings.hide_titlebar": "Masquer la barre de titre", + "settings.hide_titlebar_desc": "Masquer la barre de titre de la fenêtre. Utile pour les gestionnaires de fenêtres en mosaïque", + "settings.join_discord": "Rejoindre Discord", + "settings.language": "Langue", + "settings.language.description": "Choisissez votre langue préférée", + "settings.last_error": "Dernière erreur", + "settings.last_stderr": "Dernier stderr", + "settings.last_stdout": "Dernier stdout", + "settings.loading_providers": "Chargement des fournisseurs...", + "settings.logs_on_host": "Les journaux sont disponibles sur l'hôte.", + "settings.managed_by_env": "Géré par l'environnement", + "settings.messaging_bridge_service": "Service bridge de messagerie.", + "settings.messaging_section_desc": "Gérez les identités Telegram/Slack et les liaisons dans l'onglet Identities.", + "settings.messaging_section_title": "Messagerie", + "settings.model": "Modèle", + "settings.model_behavior": "Comportement du modèle", + "settings.model_behavior_desc": "Ouvrez le sélecteur du modèle par défaut pour choisir des profils de raisonnement lorsqu'ils sont disponibles.", + "settings.model_default": "Par défaut", + "settings.model_description": "Valeurs par défaut + contrôles de réflexion pour les exécutions.", + "settings.model_description_default": "Choisissez parmi vos fournisseurs configurés. Cette sélection sera utilisée pour les nouvelles sessions.", + "settings.model_description_session": "Choisissez parmi vos fournisseurs configurés. Cette sélection s'applique à votre prochain message.", + "settings.model_fallback": "Secours", + "settings.model_reasoning": "Raisonnement", + "settings.model_section_desc": "Choisissez le modèle de chat par défaut et examinez sa manière de raisonner.", + "settings.model_title": "Modèle", + "settings.no_access": "aucun accès", + "settings.no_active_workspace": "Aucun espace de travail local actif.", + "settings.no_audit_entries": "Aucune entrée d'audit pour le moment.", + "settings.no_binary_selected": "Aucun binaire sélectionné.", + "settings.no_custom_path_set": "Aucun chemin personnalisé défini", + "settings.no_project_directory": "Aucun répertoire de projet", + "settings.no_stderr": "Aucun stderr capturé pour le moment.", + "settings.no_stdout": "Aucun stdout capturé pour le moment.", + "settings.no_worker_directory": "Aucun répertoire de projet", + "settings.no_worker_path": "Aucun chemin de worker disponible", + "settings.nuke_confirm_dev": "Ceci est irréversible. Cela SUPPRIMERA toutes les données OpenWork de cette build de dev ainsi que toute la configuration, l'authentification, le cache, les données et l'état de développement isolés d'OpenCode, puis quittera OpenWork. Continuer ?", + "settings.nuke_confirm_prod": "Ceci est irréversible. Cela SUPPRIMERA toutes les données OpenWork de cette build de dev ainsi que toute la configuration, l'authentification, le cache, les données et l'état de développement isolés d'OpenCode, puis quittera OpenWork. Continuer ?", + "settings.nuke_failed": "Échec de la suppression de l'état d'OpenWork et d'OpenCode.", + "settings.nuke_hint": "Utilisez ceci uniquement si vous voulez réinitialiser complètement l'application desktop et son état d'exécution OpenCode.", + "settings.nuke_success": "État d'OpenWork et d'OpenCode supprimé. OpenWork se ferme...", + "settings.off": "Désactivé", + "settings.offline": "Hors ligne", + "settings.on": "Activé", + "settings.open_deeplink_action": "Ouverture...", + "settings.open_deeplink_button": "Masquer", + "settings.open_deeplink_desc": "Collez un lien profond OpenWork ou une URL de partage pour l'ouvrir.", + "settings.open_deeplink_title": "Ouvrir un lien profond", + "settings.opencode_cache": "Cache OpenCode", + "settings.opencode_cache_description": "Répare les données mises en cache utilisées pour démarrer le moteur. Sûr à exécuter.", + "settings.opencode_engine_desc": "Runtime local pour les agents, outils et fournisseurs de modèles.", + "settings.opencode_engine_label": "Moteur OpenCode", + "settings.opencode_engine_sidecar_desc": "Sidecar d'exécution locale.", + "settings.opencode_sdk_desc": "Diagnostics de connexion de l'UI.", + "settings.opencode_sdk_title": "Moteur OpenCode", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "URL de base indisponible", + "settings.opening": "Ouvrir le lien profond", + "settings.openwork_config_sidecar_desc": "Sidecar de configuration et d'approbations.", + "settings.openwork_diagnostics_title": "Diagnostics du serveur OpenWork", + "settings.openwork_server_desc": "Plan de contrôle des sessions pour la synchronisation de l'application, les workers et l'accès distant", + "settings.openwork_server_label": "Serveur OpenWork", + "settings.pending_permissions": "Autorisations en attente", + "settings.production_mode_badge": "Production", + "settings.provider_default_desc": "Utiliser le comportement de raisonnement par défaut intégré du modèle.", + "settings.provider_default_label": "Valeur par défaut du fournisseur", + "settings.provider_source_config": "Config", + "settings.provider_source_custom": "Personnalisé", + "settings.provider_source_env": "Environnement", + "settings.providers_desc": "Connectez des services pour les modèles et les outils.", + "settings.providers_title": "Fournisseurs", + "settings.quit_hint": "OpenWork se ferme immédiatement après le nettoyage afin que le prochain lancement démarre avec un état local vierge pour ce mode.", + "settings.recent_events": "Événements récents", + "settings.reconnect_failed": "Échec de la reconnexion. Vérifiez l'URL/le jeton du serveur et réessayez.", + "settings.reconnect_server": "Reconnexion...", + "settings.reconnect_server_failed": "Échec de la reconnexion au serveur OpenWork.", + "settings.reconnected": "Reconnecté au serveur OpenWork.", + "settings.reconnecting": "Reconnexion...", + "settings.removing_containers": "Suppression des conteneurs...", + "settings.removing_local_state": "Suppression de l'état local...", + "settings.repair_cache": "Réparer le cache", + "settings.repairing_cache": "Réparation du cache", + "settings.report_issue": "Signaler un problème", + "settings.reset": "Réinitialiser", + "settings.reset_app_data": "Réinitialiser les données de l'application", + "settings.reset_app_data_description": "Plus agressif. Efface le cache OpenWork et les données de l'application.", + "settings.reset_app_data_title": "Réinitialiser les données de l'application", + "settings.reset_app_data_warning": "Efface le cache OpenWork et les données de l'application sur cet appareil.", + "settings.reset_button": "Réinitialiser", + "settings.reset_cancel": "Annuler", + "settings.reset_config_defaults": "Réinitialisation...", + "settings.reset_config_failed": "Échec de la réinitialisation de la configuration de l'application.", + "settings.reset_confirm_button": "Réinitialiser et redémarrer", + "settings.reset_confirmation_hint": "Tapez {resetWord} pour confirmer. OpenWork redémarrera.", + "settings.reset_confirmation_label": "Confirmation", + "settings.reset_confirmation_placeholder": "Tapez RESET", + "settings.reset_onboarding": "Réinitialiser l'onboarding", + "settings.reset_onboarding_description": "Efface les préférences OpenWork et redémarre l'application.", + "settings.reset_onboarding_title": "Réinitialiser l'onboarding", + "settings.reset_onboarding_warning": "Efface les préférences locales OpenWork et les marqueurs d'onboarding des espaces de travail.", + "settings.reset_openwork_desc_dev": "Avec le mode dev actif, cela n'efface que l'état de développement isolé d'OpenCode dans openwork-dev-data.", + "settings.reset_openwork_desc_prod": "Avec le mode dev actif, cela n'efface que l'état de développement isolé d'OpenCode dans openwork-dev-data.", + "settings.reset_openwork_title": "Réinitialiser l'état d'OpenWork et d'OpenCode", + "settings.reset_recovery_desc": "Effacer les données ou redémarrer le flux de configuration.", + "settings.reset_recovery_title": "Réinitialisation et récupération", + "settings.reset_requires_confirm": "Nécessite de taper RESET et redémarrera l'application.", + "settings.reset_startup": "Réinitialiser le mode de démarrage par défaut", + "settings.reset_startup_pref": "Réinitialiser la préférence de démarrage", + "settings.reset_stop_active_runs": "Arrêtez les exécutions actives avant de réinitialiser.", + "settings.resetting": "Réinitialisation...", + "settings.restart_blocked_message": "OpenWork doit redémarrer pour terminer cette mise à jour. Pour éviter d'interrompre votre travail en cours, l'installation est mise en pause jusqu'à la fin de vos exécutions actives ou jusqu'à ce que vous les arrêtiez.", + "settings.restart_failed": "Échec du redémarrage. Vérifiez les journaux et réessayez.", + "settings.restart_opencode": "Redémarrage...", + "settings.restart_openwork_server": "Redémarrage...", + "settings.restart_server_failed": "Échec du redémarrage du serveur local.", + "settings.restarted": "Serveur local redémarré.", + "settings.restarting": "Redémarrage...", + "settings.reveal_config": "Révéler la configuration", + "settings.reveal_config_failed": "Échec de la révélation de la configuration de l'espace de travail.", + "settings.reveal_config_requires_desktop": "Révéler la configuration nécessite l'application desktop", + "settings.revealed_workspace_config": "Configuration de l'espace de travail révélée.", + "settings.run_sandbox_probe": "Exécution de la sonde...", + "settings.running_probe": "Exécution de la sonde...", + "settings.runtime_applies_hint": "S'applique au prochain démarrage ou rechargement du moteur.", + "settings.runtime_debug_desc": "Instantané de diagnostic lisible avec export en un clic.", + "settings.runtime_debug_title": "Rapport de débogage runtime", + "settings.runtime_desc": "État de votre moteur local et du serveur OpenWork.", + "settings.runtime_direct": "Direct (OpenCode)", + "settings.runtime_title": "Runtime", + "settings.sandbox_error": "Erreur", + "settings.sandbox_export_hint": "Utilisez Exporter dans le rapport de débogage runtime ci-dessus pour", + "settings.sandbox_probe_desc": "Exécute une vérification temporaire de démarrage de sandbox Docker et", + "settings.sandbox_probe_errors": "La sonde de sandbox s'est terminée avec des erreurs.", + "settings.sandbox_probe_failed": "Échec de la sonde de sandbox.", + "settings.sandbox_probe_success": "Sonde de sandbox réussie. Exportez le rapport de débogage pour le support.", + "settings.sandbox_probe_title": "Sonde de sandbox", + "settings.sandbox_ready": "Prêt", + "settings.sandbox_requires_desktop": "La sonde de sandbox nécessite l'application desktop", + "settings.sandbox_result": "Résultat : {status}", + "settings.sandbox_run_id": "ID d'exécution : {id}", + "settings.sandbox_stop_runs_hint": "Arrêtez les exécutions actives avant de sonder", + "settings.search_models": "Rechercher des modèles…", + "settings.select_binary": "Sélectionner le binaire OpenCode", + "settings.select_workspace_first": "Sélectionnez un espace de travail local avant de révéler la configuration.", + "settings.send_feedback": "Envoyer un retour", + "settings.service_restarts_desc": "Redémarrez des services hôtes spécifiques sans quitter ceci", + "settings.service_restarts_title": "Redémarrages de services", + "settings.session_model": "Modèle", + "settings.show_model_reasoning": "Afficher le raisonnement du modèle", + "settings.show_model_reasoning_desc": "Développe les traces de raisonnement dans l'UI lorsqu'un modèle les expose.", + "settings.showing_models": "Affichage de {count} sur {total}", + "settings.sidecar_config_unavailable": "Configuration du sidecar indisponible", + "settings.startup": "Démarrage", + "settings.startup_local": "Démarrer le serveur local", + "settings.startup_not_set": "Se connecter au serveur", + "settings.startup_remote_warning": "La préférence de démarrage est actuellement distante. Paramètres du moteur", + "settings.startup_reset_hint": "Cela efface votre préférence enregistrée et affiche la connexion", + "settings.startup_server": "Se connecter au serveur", + "settings.startup_title": "Démarrage", + "settings.stop_local_server": "Arrêter le serveur local", + "settings.stop_runs_before_cleanup": "Arrêtez les exécutions actives avant le nettoyage", + "settings.stop_runs_before_reset_config": "Arrêtez les exécutions actives avant de réinitialiser la configuration", + "settings.stop_runs_to_reset": "Arrêtez les exécutions actives pour réinitialiser", + "settings.switch": "Basculer", + "settings.tab_advanced": "Avancé", + "settings.tab_appearance": "Apparence", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "Débogage", + "settings.tab_description_advanced": "Inspectez la santé du runtime, l'état de connexion et les contrôles destinés aux développeurs.", + "settings.tab_description_appearance": "Ajustez l'apparence d'OpenWork sur desktop, thème système et chrome de l'application.", + "settings.tab_description_debug": "Consultez les diagnostics du runtime, les journaux et les utilitaires de débogage bas niveau.", + "settings.tab_description_den": "Gérez votre connexion OpenWork Cloud, les workers hébergés et l'accès à l'espace de travail.", + "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", + "settings.tab_description_extensions": "Gérez les applications MCP et les Plugins OpenCode pour cet espace de travail.", + "settings.tab_description_general": "Connectez des fournisseurs, choisissez le modèle par défaut, autorisez des dossiers et contrôlez l'espace de travail OpenWork sélectionné ainsi que sa connexion runtime.", + "settings.tab_description_messaging": "Configurez les identités du routeur et le comportement de la boîte de réception depuis les paramètres de l'espace de travail.", + "settings.tab_description_model": "Ajustez le modèle par défaut, le comportement d'exécution et les paramètres de sortie de l'assistant.", + "settings.tab_description_recovery": "Réparez l'état des migrations, réinitialisez les valeurs par défaut de l'espace de travail et récupérez les paramètres locaux.", + "settings.tab_description_skills": "Parcourez, modifiez et installez des Skills sans quitter les paramètres.", + "settings.tab_description_updates": "Gardez l'application à jour avec des vérifications discrètes en arrière-plan et des contrôles d'installation.", + "settings.tab_environment": "Environment", + "settings.tab_extensions": "Extensions", + "settings.tab_general": "Paramètres", + "settings.tab_messaging": "Messagerie", + "settings.tab_model": "Modèle", + "settings.tab_recovery": "Récupération", + "settings.tab_skills": "Skills", + "settings.tab_updates": "Mises à jour", + "settings.theme_dark": "Sombre", + "settings.theme_light": "Clair", + "settings.theme_system": "Système", + "settings.theme_system_hint": "Le mode système suit automatiquement la préférence de votre OS.", + "settings.toolbar_ready_to_install": "Prêt à installer", + "settings.update": "Mettre à jour", + "settings.update_available": "Mise à jour disponible : v", + "settings.update_available_version": "Mise à jour disponible : v{version}", + "settings.update_check_button": "Vérifier", + "settings.update_check_failed": "Échec de la vérification des mises à jour", + "settings.update_checking": "Vérification...", + "settings.update_download_button": "Télécharger", + "settings.update_downloading": "Téléchargement...", + "settings.update_error": "Échec de la vérification des mises à jour", + "settings.update_install_button": "Installer et redémarrer", + "settings.update_last_checked": "Dernière vérification {time}", + "settings.update_published": "Publié le {date}", + "settings.update_ready": "Prêt à installer : v", + "settings.update_ready_version": "Prêt à installer : v{version}", + "settings.update_uptodate": "À jour", + "settings.updates": "Mises à jour", + "settings.updates_desc": "Gardez OpenWork à jour.", + "settings.updates_desktop_only": "Les mises à jour ne sont disponibles que dans l'application desktop.", + "settings.updates_not_supported": "Les mises à jour ne sont pas prises en charge dans cet environnement.", + "settings.updates_title": "Mises à jour", + "settings.version": "Version", + "settings.versions_desc": "Infos de build sidecar + desktop.", + "settings.versions_title": "Versions", + "settings.window_appearance_desc": "Personnalisez l'apparence de la fenêtre.", + "settings.worker_id_label": "Worker {id}", + "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "Configuration de l'espace de travail", + "settings.workspace_debug_events_label": "Événements de débogage de l'espace de travail", + "settings.workspace_fallback_name": "Espace de travail", + "share.active_cloud_org": "Organisation Cloud active", + "share.back_hint": "Retour aux options de partage", + "share.chooser_subtitle": "Choisissez comment vous voulez partager cet espace de travail.", + "share.close_hint": "Fermer", + "share.cloud_signin_note": "OpenWork Cloud s'ouvre dans votre navigateur et revient ici après la connexion.", + "share.collaborator_hint": "Accès courant sans approbations d'autorisation.", + "share.connect_messaging_desc": "Utilisez cet espace de travail depuis Slack, Telegram et d'autres services.", + "share.connect_messaging_title": "Connecter la messagerie", + "share.connection_details_label": "Détails de connexion", + "share.copy_hint": "Copier", + "share.copy_link_hint": "Copier le lien", + "share.create_template_link": "Créer un lien de modèle", + "share.credentials_disabled_hint": "Activez l'accès distant et cliquez sur Enregistrer pour redémarrer le worker et révéler les détails de connexion en direct de cet espace de travail.", + "share.field_password": "Mot de passe", + "share.field_worker_url": "URL du worker", + "share.hide_password": "Masquer le mot de passe", + "share.included_in_template": "Inclus dans ce modèle", + "share.option_access_desc": "Révélez les détails de connexion en direct nécessaires pour atteindre cet espace de travail en cours d'exécution depuis une autre machine.", + "share.option_access_title": "Accéder à l'espace de travail à distance", + "share.option_public_desc": "Créez un lien de partage que n'importe qui peut utiliser pour démarrer à partir de ce modèle.", + "share.option_public_title": "Modèle public", + "share.option_team_title": "Partager avec l'équipe", + "share.option_template_desc": "Emballez cette configuration pour que quelqu'un d'autre puisse démarrer depuis le même environnement.", + "share.optional_collaborator": "Accès collaborateur optionnel", + "share.public_intro": "Partagez cet espace de travail comme lien de modèle public.", + "share.publishing": "Publication...", + "share.regenerate_link": "Régénérer le lien", + "share.remote_access_desc": "Désactivé par défaut. N'activez cette option que lorsque vous voulez que ce worker soit joignable depuis une autre machine.", + "share.remote_access_disabled": "L'accès distant est actuellement désactivé.", + "share.remote_access_enabled": "L'accès distant est actuellement activé.", + "share.remote_access_title": "Accès distant", + "share.remote_save": "Enregistrer", + "share.remote_save_busy": "Enregistrement...", + "share.reveal_password": "Révéler le mot de passe", + "share.save_to_team": "Enregistrer dans l'équipe", + "share.saving": "Enregistrement...", + "share.setup": "Configuration", + "share.sign_in_to_share": "Connectez-vous pour partager avec l'équipe", + "share.subtitle_access": "Révélez les détails de connexion en direct nécessaires pour atteindre cet espace de travail depuis une autre machine.", + "share.team_intro": "Enregistrez ce modèle dans votre organisation OpenWork Cloud active pour que les coéquipiers puissent l'ouvrir plus tard depuis les paramètres Cloud.", + "share.template_intro": "Partagez une configuration réutilisable sans accorder d'accès en direct à cet espace de travail en cours d'exécution.", + "share.template_item_config": "Commands et configuration", + "share.template_item_config_desc": "Commands réutilisables et configuration OpenWork/OpenCode.", + "share.template_item_settings": "Paramètres de l'espace de travail", + "share.template_item_settings_desc": "Le profil d'espace de travail partagé et le comportement par défaut.", + "share.template_item_skills": "Skills inclus", + "share.template_item_skills_desc": "Skills personnalisés enregistrés dans cet espace de travail.", + "share.template_name_label": "Nom du modèle", + "share.title": "Partager l'espace de travail", + "share.view_access": "Accéder à l'espace de travail à distance", + "share.warning_basic": "Partagez uniquement avec des personnes de confiance. Ces identifiants donnent un accès en direct à cet espace de travail.", + "share.warning_full": "Ces identifiants donnent un accès en direct à cet espace de travail. Le partage distant de cet espace de travail peut permettre à toute personne ayant accès à votre réseau de contrôler votre worker.", + "share.workspace_fallback": "Espace de travail", + "share.workspace_template_desc": "Partagez la configuration de base et les valeurs par défaut de l'espace de travail.", + "share.workspace_template_title": "Modèle d'espace de travail", + "share_skill_destination.add_to_workspace": "Ajouter le Skill à l'espace de travail", + "share_skill_destination.adding": "Ajout du Skill...", + "share_skill_destination.confirm_busy": "Ajout du Skill...", + "share_skill_destination.confirm_button": "Ajouter le Skill à l'espace de travail", + "share_skill_destination.connect_remote": "Connecter un espace de travail distant", + "share_skill_destination.connect_remote_desc": "Attachez un hôte OpenWork, puis choisissez-le dans la liste pour importer ce Skill.", + "share_skill_destination.connect_remote_hint": "Attachez un hôte OpenWork, puis choisissez-le dans la liste pour importer ce Skill.", + "share_skill_destination.create_worker": "Créer un nouvel espace de travail", + "share_skill_destination.create_worker_desc": "Ouvrez le flux de configuration de l'espace de travail, puis ajoutez ce Skill une fois que le nouvel espace de travail est prêt.", + "share_skill_destination.create_worker_hint": "Ouvrez le flux de configuration de l'espace de travail, puis ajoutez ce Skill une fois que le nouvel espace de travail est prêt.", + "share_skill_destination.current_badge": "Actuel", + "share_skill_destination.existing_workers": "Espaces de travail existants", + "share_skill_destination.fallback_skill_name": "Skill partagé", + "share_skill_destination.footer_idle": "Choisissez un espace de travail pour continuer.", + "share_skill_destination.footer_selected": "Espace de travail sélectionné :", + "share_skill_destination.local_badge": "Local", + "share_skill_destination.more_options": "Plus d'options", + "share_skill_destination.new_destination": "Nouvelle destination", + "share_skill_destination.no_workers": "Aucun espace de travail n'est encore prêt. Créez-en un ou connectez un espace de travail distant pour installer ce Skill.", + "share_skill_destination.remote_badge": "Distant", + "share_skill_destination.sandbox_badge": "Sandbox", + "share_skill_destination.selected_badge": "Sélectionné", + "share_skill_destination.selected_hint": "Sélectionné. Vérifiez la destination ci-dessous, puis confirmez.", + "share_skill_destination.skill_label": "Skill partagé", + "share_skill_destination.subtitle": "Choisissez un espace de travail existant ou créez-en un nouveau avant d'importer ce Skill partagé.", + "share_skill_destination.title": "Où ce Skill doit-il aller ?", + "share_skill_destination.trigger_label": "Déclencheur", + "sidebar.active": "Actif", + "sidebar.add_workspace": "Ajouter un nouvel espace de travail", + "sidebar.collapse": "Réduire", + "sidebar.connect_remote": "Connecter un accès distant", + "sidebar.delete_session": "Supprimer la session", + "sidebar.drag_reorder": "Faire glisser pour réorganiser", + "sidebar.edit_connection": "Modifier la connexion", + "sidebar.expand": "Développer", + "sidebar.import_config": "Importer la configuration", + "sidebar.needs_attention": "Nécessite une attention", + "sidebar.new_worker": "Nouveau worker", + "sidebar.no_workspaces": "Aucun espace de travail dans cette session pour le moment. Ajoutez-en un pour commencer.", + "sidebar.progress": "Progression", + "sidebar.show_fewer": "Afficher moins", + "sidebar.show_more": "Afficher {count} de plus", + "sidebar.stop_sandbox": "Arrêter la sandbox", + "sidebar.switch": "Basculer", + "sidebar.test_connection": "Tester la connexion", + "skills.add_custom_repo": "Ajouter un dépôt GitHub personnalisé", + "skills.add_git_repo": "Ajouter un dépôt git", + "skills.add_openwork_hub": "Ajouter un Hub OpenWork", + "skills.available_from_hub": "Disponible depuis le Hub", + "skills.catalog_search_placeholder": "Rechercher parmi les Skills installés, d'équipe et du hub", + "skills.cloud_add_skill": "Ajouter un Skill", + "skills.cloud_choose_org_detail": "Utilisez le panneau Cloud pour choisir votre organisation active, puis actualisez cette liste.", + "skills.cloud_choose_org_hint": "Choisissez une organisation dans Paramètres → Cloud pour charger les Skills d'équipe.", + "skills.cloud_footer_label": "Équipe", + "skills.cloud_hub_label": "Hub : {name}", + "skills.cloud_install_need_server": "Connectez-vous à un serveur OpenWork avec un accès en écriture aux Skills pour installer les Skills d'équipe sur ce worker.", + "skills.cloud_installed": "{name} installé sur ce worker.", + "skills.cloud_installed_as": "Installé sous {name}", + "skills.cloud_installing": "Installation de {title}…", + "skills.cloud_installing_short": "Installation", + "skills.cloud_no_search_matches": "Aucun Skill ne correspond à cette recherche.", + "skills.cloud_org_empty": "Aucun Skill d'organisation n'est encore disponible.", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "Échec du chargement des Skills de l'organisation.", + "skills.cloud_refresh": "Actualiser les Skills d'équipe", + "skills.cloud_section_subtitle": "Skills partagés avec vous via OpenWork Cloud — y compris les hubs de Skills d'équipe auxquels vous avez accès.", + "skills.cloud_section_title": "Depuis votre organisation", + "skills.cloud_shared_org": "Organisation", + "skills.cloud_shared_private": "Privé", + "skills.cloud_shared_public": "Public", + "skills.cloud_sign_in": "Se connecter à Cloud", + "skills.cloud_sign_in_hint": "Connectez-vous à OpenWork Cloud pour parcourir les Skills d'équipe et d'organisation.", + "skills.cloud_status_installed": "Installé", + "skills.cloud_status_update": "Mise à jour disponible", + "skills.cloud_update_skill": "Mettre à jour", + "skills.cloud_updated": "{name} mis à jour sur ce worker.", + "skills.cloud_updating": "Mise à jour de {title}…", + "skills.cloud_removed": "Skill cloud local {name} supprimé.", + "skills.copy_link_failed": "Échec de la copie du lien", + "skills.create_in_chat": "Créer un Skill dans le chat", + "skills.desktop_required": "La gestion des Skills nécessite l'application desktop.", + "skills.enter_plugin_name": "Entrez un nom de package plugin.", + "skills.failed_load_active": "Échec du chargement des Plugins actifs.", + "skills.failed_load_opencode": "Échec du chargement de opencode.json", + "skills.failed_parse_opencode": "Échec de l'analyse de opencode.json", + "skills.failed_to_load": "Échec du chargement des Skills", + "skills.failed_update_opencode": "Échec de la mise à jour de opencode.json", + "skills.filter_all": "Tout", + "skills.filter_cloud": "Équipe", + "skills.filter_hub": "Hub", + "skills.filter_installed": "Installé", + "skills.from_repo": "Depuis {owner}/{repo}", + "skills.github_repo_hint": "Entrez un dépôt GitHub au format owner/repo.", + "skills.host_mode_only": "Espace de travail local uniquement", + "skills.host_only_error": "La gestion des Skills nécessite un espace de travail local ou un serveur OpenWork connecté.", + "skills.hub_desc": "Parcourez les Skills partagés depuis des hubs adossés à GitHub et ajoutez-les à ce worker.", + "skills.hub_label": "Hub", + "skills.import": "Importer", + "skills.import_failed": "Échec de l'import ({status})", + "skills.import_local": "Importer un Skill local", + "skills.import_local_hint": "Copiez un dossier de Skill existant dans cet espace de travail.", + "skills.import_local_skill": "Importer un Skill local", + "skills.imported": "Importé.", + "skills.install": "Installer", + "skills.install_failed": "Échec de l'installation du Skill.", + "skills.install_name_title": "Installer {name}", + "skills.install_skill_creator": "Installer le créateur de Skills", + "skills.install_skill_creator_hint": "Ce Skill vous permet de créer d'autres Skills depuis le chat.", + "skills.installed": "Skills installés", + "skills.installed_desc": "Les Skills installés vivent sur ce worker et peuvent être modifiés ou partagés.", + "skills.installed_label": "Installé", + "skills.installed_status": "Installé", + "skills.installing": "Ajouter un Skill", + "skills.installing_prefix": "Installation de {name}…", + "skills.installing_skill_creator": "Installation du créateur de Skills...", + "skills.link_copied": "Lien copié", + "skills.loading": "Chargement…", + "skills.no_description": "Aucune description pour le moment.", + "skills.no_hub_repo_label": "Aucun dépôt de hub sélectionné", + "skills.no_hub_repo_selected": "Aucun Skill de hub disponible.", + "skills.no_hub_skills": "Aucun dépôt de hub sélectionné. Ajoutez un dépôt GitHub pour parcourir les Skills.", + "skills.no_opencode_found": "Aucun opencode.json trouvé pour le moment. Ajoutez un plugin pour en créer un.", + "skills.no_opencode_workspace": "Aucun opencode.json dans cet espace de travail pour le moment.", + "skills.no_skills": "Aucun Skill détecté dans `.opencode/skills`, `.claude/skills` ou `~/.agents/skills`.", + "skills.no_skills_found": "Aucun Skill trouvé pour le moment.", + "skills.owner_label": "Propriétaire", + "skills.owner_repo_required": "Le propriétaire et le dépôt sont requis.", + "skills.pick_project_first": "Choisissez d'abord un dossier de projet.", + "skills.pick_project_for_active": "Choisissez un dossier de projet pour charger les Plugins actifs.", + "skills.pick_project_for_plugins": "Choisissez un dossier de projet pour gérer les Plugins du projet.", + "skills.pick_workspace_first": "Choisissez d'abord un dossier d'espace de travail.", + "skills.plugin_already_listed": "Le plugin est déjà listé dans opencode.json.", + "skills.plugin_management_host_only": "La gestion des Plugins nécessite l'application desktop.", + "skills.plugins_host_only": "Les Plugins ne sont disponibles que dans l'application desktop.", + "skills.ref_label": "Ref (branche/tag/commit)", + "skills.refresh": "Actualiser", + "skills.refresh_hub": "Actualiser le hub", + "skills.refresh_hub_title": "Actualiser le catalogue du hub", + "skills.remove_saved_repo": "Supprimer le dépôt enregistré", + "skills.repo_label": "Dépôt", + "skills.reveal_failed": "Échec de l'ouverture du dossier des Skills.", + "skills.reveal_folder": "Ouvrir le dossier des Skills", + "skills.reveal_folder_hint": "Ouvrir le répertoire des Skills dans le Finder.", + "skills.save_and_load": "Enregistrer et charger", + "skills.save_failed": "Échec de l'enregistrement du Skill.", + "skills.select_skill_folder": "Sélectionner le dossier du Skill", + "skills.share_back": "Retour", + "skills.share_chooser_subtitle": "Enregistrez dans votre organisation OpenWork Cloud ou publiez un lien d'installation public.", + "skills.share_close": "Fermer", + "skills.share_copy_link": "Copier", + "skills.share_done": "Terminé", + "skills.share_option_public_desc": "Créez un lien que tout le monde peut utiliser pour installer ce Skill.", + "skills.share_option_public_title": "Lien public", + "skills.share_option_team_desc": "Ajoutez ce Skill à votre organisation OpenWork Cloud active.", + "skills.share_option_team_title": "Partager avec l'équipe", + "skills.share_public_create": "Créer le lien", + "skills.share_public_creating": "Publication…", + "skills.share_public_intro": "Publiez un lien public. Toute personne disposant de l'URL peut installer ce Skill.", + "skills.share_public_regenerate": "Régénérer le lien", + "skills.share_publisher_label": "Éditeur", + "skills.share_subtitle_public": "Toute personne ayant le lien peut installer ce Skill.", + "skills.share_subtitle_team": "Stocké dans votre organisation pour les coéquipiers.", + "skills.share_team_choose_org": "Choisissez une organisation dans Paramètres → Cloud avant de partager avec votre équipe.", + "skills.share_team_permissions_intro": "Téléversez ce Skill dans votre organisation OpenWork Cloud active et décidez qui peut le voir.", + "skills.share_team_permissions_label": "Autorisations de partage", + "skills.share_team_permission_org": "Organisation uniquement - pas dans le hub", + "skills.share_team_permission_private": "Privé pour moi uniquement", + "skills.share_team_hub_label": "Ajouter au hub de Skills (optionnel)", + "skills.share_team_hub_none": "Organisation uniquement — pas dans un hub", + "skills.share_team_hubs_loading": "Chargement des hubs…", + "skills.share_team_intro": "Enregistrez ce Skill dans votre organisation active afin que les coéquipiers puissent l'installer depuis Cloud.", + "skills.share_team_org_fallback": "Organisation Cloud active", + "skills.share_team_save": "Enregistrer dans l'équipe", + "skills.share_team_saving": "Enregistrement…", + "skills.share_team_upload_and_save": "Téléverser et enregistrer", + "skills.share_team_uploading": "Téléversement…", + "skills.share_team_sign_in": "Connectez-vous pour partager avec l'équipe", + "skills.share_team_sign_in_hint": "OpenWork Cloud s'ouvre dans votre navigateur. Revenez ici après vous être connecté.", + "skills.share_team_success": "Enregistré dans {org}. Les coéquipiers peuvent l'installer depuis les Skills de votre organisation.", + "skills.share_team_uploaded_success": "Téléversé dans {org}. Les Skills cloud seront actualisés pour votre compte.", + "skills.share_title": "Partager le Skill", + "skills.shown_count": "{count} affichés", + "skills.skill_creator_already_installed": "Le créateur de Skills est déjà installé.", + "skills.skill_creator_installed": "Créateur de Skills installé.", + "skills.skill_load_failed": "Échec du chargement du Skill.", + "skills.source_label": "Source", + "skills.subtitle": "Gérez les Skills de cet espace de travail.", + "skills.title": "Skills", + "skills.trigger_label": "Déclencheur : {trigger}", + "skills.uninstall": "Désinstaller", + "skills.uninstall_failed": "Échec de la désinstallation du Skill.", + "skills.uninstall_title": "Désinstaller le Skill ?", + "skills.uninstall_warning": "Cela supprimera définitivement le Skill `{name}` de votre espace de travail.", + "skills.uninstalled": "Skill supprimé.", + "skills.unknown_error": "Erreur inconnue", + "skills.worker_profile_desc": "Les Skills sont les capacités centrales de ce worker. Découvrez-les via le Hub, gérez ce qui est installé et créez-en de nouveaux directement dans le chat.", + "status.back": "Retour à l'écran précédent", + "status.connected": "Connecté", + "status.connecting": "Connexion", + "status.creating_task": "Création d'une nouvelle tâche", + "status.creating_workspace": "Création de l'espace de travail", + "status.developer_mode": "Mode développeur", + "status.disconnected": "Déconnecté", + "status.disconnected_hint": "Ouvrez les paramètres pour vous reconnecter", + "status.disconnected_label": "Déconnecté", + "status.disconnecting": "Déconnexion", + "status.docs": "Docs", + "status.feedback": "Retour", + "status.idle": "Inactif", + "status.installing_opencode": "Installation d'OpenCode", + "status.limited_hint": "Reconnectez-vous pour restaurer toutes les fonctionnalités OpenWork", + "status.limited_mcp_hint": "{count} MCP connecté · reconnectez-vous pour toutes les fonctionnalités", + "status.limited_mode": "Mode limité", + "status.live": "En direct", + "status.loading_session": "Chargement de la session", + "status.mcp_connected": "{count} MCP connecté", + "status.open_docs": "Ouvrir la documentation", + "status.openwork_ready": "OpenWork prêt", + "status.providers_connected": "{count} fournisseur{plural} connecté", + "status.ready_for_tasks": "Prêt pour de nouvelles tâches", + "status.reloading_engine": "Rechargement du moteur", + "status.restarting_engine": "Redémarrage du moteur", + "status.running": "En cours d'exécution", + "status.send_feedback": "Envoyer un retour", + "status.settings": "Paramètres", + "status.starting_engine": "Démarrage du moteur", + "system.cache_repair_requires_desktop": "La réparation du cache nécessite l'application desktop.", + "system.docker_cleanup_requires_desktop": "Le nettoyage Docker nécessite l'application desktop.", + "system.reload_body_agents": "OpenCode charge les agents au démarrage. Rechargez le moteur pour rendre les agents mis à jour disponibles.", + "system.reload_body_commands": "OpenCode charge les Commands au démarrage. Rechargez le moteur pour rendre les Commands mis à jour disponibles.", + "system.reload_body_config": "OpenCode lit opencode.json au démarrage. Rechargez le moteur pour appliquer les changements de configuration.", + "system.reload_body_default": "OpenWork a détecté des changements qui nécessitent le rechargement de l'instance OpenCode.", + "system.reload_body_mcp": "OpenCode charge les serveurs MCP au démarrage. Rechargez le moteur pour activer la nouvelle connexion.", + "system.reload_body_mixed": "OpenWork a détecté des changements de configuration OpenCode. Rechargez le moteur pour les appliquer.", + "system.reload_body_plugins": "OpenCode charge les plugins npm au démarrage. Rechargez le moteur pour appliquer les changements de opencode.json.", + "system.reload_body_skills": "OpenCode peut mettre en cache la découverte/l'état des Skills. Rechargez le moteur pour rendre les Skills nouvellement installés disponibles.", + "system.reload_failed": "Échec du rechargement du moteur.", + "system.reload_required": "Rechargement requis", + "system.reload_unavailable": "Le rechargement n'est pas disponible pour ce worker.", + "system.stop_active_runs_before_reset": "Arrêtez les exécutions actives avant de réinitialiser.", + "system.stop_runs_before_update": "Arrêtez les exécutions actives avant d'installer une mise à jour.", + "system.updates_not_supported": "Les mises à jour ne sont pas prises en charge dans cet environnement.", + "time.hours_ago": "il y a {count} h", + "time.just_now": "à l'instant", + "time.minutes_ago": "il y a {count} min", + "time.seconds_ago": "il y a {count} s", + "workspace.loading_tasks": "Chargement des tâches...", + "workspace.local_badge": "Local", + "workspace.new_task_inline": "+ Nouvelle tâche", + "workspace.no_tasks": "Aucune tâche pour le moment.", + "workspace.remote_badge": "Distant", + "workspace.rename_description": "Mettez à jour le nom affiché dans la barre latérale.", + "workspace.rename_label": "Nom de l'espace de travail", + "workspace.rename_placeholder": "Espace de travail de l'équipe design", + "workspace.rename_title": "Modifier le nom de l'espace de travail", + "workspace.sandbox_badge": "Sandbox", + "workspace.selected": "Sélectionné", + "workspace.switch": "Basculer", + "workspace.switching_status_connecting": "Vérification de votre connexion", + "workspace.switching_status_loading": "Chargement des tâches récentes", + "workspace.switching_status_preparing": "Préparation en cours", + "workspace.switching_subtitle": "Nous allons restaurer votre travail récent.", + "workspace.switching_title": "Ouverture de {name}", + "workspace.switching_title_unknown": "Ouverture de l'espace de travail", + "workspace_list.add_workspace": "Ajouter un espace de travail", + "workspace_list.connect_remote": "Connecter un espace de travail distant", + "workspace_list.connecting": "Connexion...", + "workspace_list.delete_session": "Supprimer la session", + "workspace_list.desktop_only_hint": "Créez des espaces de travail locaux dans l'application desktop.", + "workspace_list.edit_connection": "Modifier la connexion", + "workspace_list.edit_name": "Modifier le nom", + "workspace_list.hide_child_sessions": "Masquer les sessions enfants", + "workspace_list.import_config": "Importer la configuration", + "workspace_list.new_workspace": "Nouvel espace de travail", + "workspace_list.recover": "Récupérer", + "workspace_list.remove_workspace": "Supprimer l'espace de travail", + "workspace_list.rename_session": "Renommer la session", + "workspace_list.reveal_explorer": "Révéler dans l'Explorateur", + "workspace_list.reveal_finder": "Révéler dans le Finder", + "workspace_list.session_actions": "Actions de session", + "workspace_list.share": "Partager...", + "workspace_list.show_child_sessions": "Afficher les sessions enfants", + "workspace_list.show_more": "Afficher {count} de plus", + "workspace_list.show_more_fallback": "Afficher plus", + "workspace_list.test_connection": "Tester la connexion", + "workspace_list.workspace_fallback": "Espace de travail", + "workspace_list.workspace_options": "Options de l'espace de travail", + "workspace_sidebar.close_sidebar": "Fermer la barre latérale", + "workspace_sidebar.collapse_sidebar": "Réduire la barre latérale", + "workspace_sidebar.configuration": "configuration", + "workspace_sidebar.expand_sidebar": "Développer la barre latérale", + "workspace_sidebar.extensions": "Extensions", + "workspace_sidebar.messaging": "Messagerie", +} as const; diff --git a/apps/app/src/i18n/locales/index.ts b/apps/app/src/i18n/locales/index.ts new file mode 100644 index 0000000000..764fbb91d2 --- /dev/null +++ b/apps/app/src/i18n/locales/index.ts @@ -0,0 +1,11 @@ +/** + * Re-export all translation files for convenience + */ +export { default as en } from "./en"; +export { default as ja } from "./ja"; +export { default as zh } from "./zh"; +export { default as vi } from "./vi"; +export { default as ptBR } from "./pt-BR"; +export { default as fr } from "./fr"; +export { default as ca } from "./ca"; +export { default as es } from "./es"; diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts new file mode 100644 index 0000000000..f22e9cdd73 --- /dev/null +++ b/apps/app/src/i18n/locales/ja.ts @@ -0,0 +1,1988 @@ +/** + * Japanese translations (日本語) + */ + +export default { + "app.compact_command_desc": "このセッションを要約してコンテキストサイズを削減します。", + "app.connection_lost": "サーバーとの接続が失われました。リロードしてください。", + "app.deep_link_auth_queued": "OpenWorkのCloud認証ディープリンクをキューに追加しました。", + "app.deep_link_remote_queued": "リモートワーカーのリンクをキューに追加しました。OpenWorkが接続フローに移行します。", + "app.error.choose_folder": "続行するにはフォルダを選択してください。", + "app.error.host_requires_local": "エンジンを起動するにはローカルワークスペースを選択してください。", + "app.error.install_failed": "OpenCodeのインストールに失敗しました。上のログをご確認ください。", + "app.error.pick_workspace_folder": "最初にワークスペースフォルダを選択してください。", + "app.error.remote_base_url_required": "続行するにはサーバーURLを追加してください。", + "app.error.tauri_required": "この操作にはOpenWorkデスクトップアプリのランタイムが必要です。", + "app.error_audit_load": "監査ログの読み込みに失敗しました。", + "app.error_auth_failed": "認証に失敗しました", + "app.error_auto_compact_scope": "自動コンテキスト圧縮は、ローカルワークスペースまたは書き込み可能なOpenWorkサーバーワークスペースでのみ変更できます。", + "app.error_cloud_signin": "OpenWork Cloudサインインを完了できませんでした。", + "app.error_command_not_resolved": "コマンドを解決できませんでした。", + "app.error_compact_empty": "まだ圧縮するものがありません。", + "app.error_compact_no_session": "/compactを実行する前にメッセージのあるセッションを選択してください。", + "app.error_compact_no_session_id": "圧縮する前にセッションを選択してください。", + "app.error_connect_first": "ランタイム変更を適用する前にこのワークスペースに接続してください。", + "app.error_connection_failed": "接続に失敗しました", + "app.error_connection_failed_url": "接続に失敗しました。URLとトークンを確認してください。", + "app.error_deep_link_unrecognized": "そのリンクはOpenWorkのディープリンクまたは共有URLとして認識されません。", + "app.error_desktop_signin": "デスクトップサインインは完了しましたが、OpenWork Cloudがセッショントークンを返しませんでした。", + "app.error_not_connected": "サーバーに接続されていません", + "app.error_pick_local_folder": "ローカルサーバーを再起動する前にローカルワークスペースフォルダを選択してください。", + "app.error_rate_limit": "レート制限を超えました", + "app.error_remote_access": "リモートアクセスの更新に失敗しました。", + "app.error_request_failed": "リクエストに失敗しました", + "app.error_reset_config": "アプリ設定のリセットに失敗しました。", + "app.error_restart_local_worker": "共有設定の更新後にローカルワーカーの再起動に失敗しました。", + "app.error_runtime_changes": "ランタイム変更の適用に失敗しました。", + "app.error_session_name_required": "セッション名は必須です", + "app.error_update_opencode_json": "opencode.jsonの更新に失敗しました", + "app.import_bundle_desc": "このバンドルのインポート方法を選択してください。", + "app.import_shared_bundle": "共有バンドルをインポート", + "app.local_disabled_reason": "ローカルワークスペースはデスクトップアプリで作成できます。リモートおよび共有ワークスペースはここでも利用可能です。", + "app.local_worker_detail": "ローカルワーカー", + "app.model_behavior_desc": "プロバイダー固有の動作設定を表示するには、先にモデルを選択してください。", + "app.model_behavior_title": "モデル動作", + "app.plugins_hint_disconnected": "OpenWorkサーバーに接続できません。プラグインは読み取り専用です。", + "app.plugins_hint_limited": "OpenWorkサーバーでプラグインを編集するにはトークンが必要です。", + "app.plugins_hint_readonly": "OpenWorkサーバーのプラグインは読み取り専用です。", + "app.reload_later": "後で", + "app.reload_now": "今すぐリロード", + "app.reload_stop_tasks": "リロードしてタスクを停止", + "app.remote_worker_detail": "リモートワーカー", + "app.reset_config_ok": "アプリ設定をリセットしました。古い設定が残っている場合はOpenWorkを再起動してください。", + "app.shared_setup": "共有セットアップ", + "app.skill_added": "スキルを追加しました", + "app.skills_hint_disconnected": "OpenWorkサーバーに接続できません。スキルを管理するには詳細設定でサーバーURL/トークンを追加してください。", + "app.skills_hint_limited": "OpenWorkサーバーでスキルのインストール/更新を行うにはホストトークンが必要です。詳細設定で追加して再接続してください。", + "app.skills_hint_readonly": "OpenWorkサーバーのスキルは読み取り専用です。インストールを有効にするには詳細設定でホストトークンを追加してください。", + "app.unknown_error": "不明なエラー", + "app.worker_fallback": "ワーカー", + "blueprint.automation_body": "再利用可能なワークフローから始めるか、下にタスクを入力してください。", + "blueprint.automation_title": "何を自動化しますか?", + "blueprint.csv_session_assistant": "CSVファイルの生成、クリーンアップ、マージ、集計のお手伝いができます。どんなCSV作業を自動化しますか?", + "blueprint.csv_session_title": "CSVワークフローのアイデア", + "blueprint.csv_session_user": "複数のツールからのエクスポートを1つのきれいなCSVにまとめたいです。", + "blueprint.empty_body": "テンプレートを選ぶか、下に入力してください。", + "blueprint.empty_title": "何をしましょうか?", + "blueprint.minimal_body": "このワークスペースについて質問するか、スタータープロンプトを使ってください。", + "blueprint.minimal_title": "タスクから始める", + "blueprint.starter_blueprint_desc": "スキル、コマンド、ハンドオフステップを含む再利用可能なワークフローを設計します。", + "blueprint.starter_blueprint_prompt": "このワークスペースの再利用可能なオートメーション設計図の作成を手伝ってください。何を標準化すべきか聞いてから、ワークフローを提案してください。", + "blueprint.starter_blueprint_title": "オートメーション設計図を作成", + "blueprint.starter_chrome_desc": "ブラウザ自動化のセッションをすぐに開始します。", + "blueprint.starter_chrome_prompt": "Chromeに接続して、繰り返しのタスクを自動化するのを手伝ってください。", + "blueprint.starter_chrome_title": "Chromeを自動化", + "blueprint.starter_command_desc": "繰り返しのワークフローをこのワークスペースのスラッシュコマンドに変換します。", + "blueprint.starter_command_prompt": "このワークスペースで再利用可能な/コマンドの作成を手伝ってください。自動化したいワークフローを聞いてから、コマンドを作成してください。", + "blueprint.starter_command_title": "再利用可能なコマンドを作成", + "blueprint.starter_connect_openai_desc": "OpenAIプロバイダーを追加して、新しいセッションでChatGPTモデルをすぐに使えるようにします。", + "blueprint.starter_connect_openai_title": "ChatGPTに接続", + "blueprint.starter_csv_desc": "スプレッドシートデータのクリーンアップや生成。", + "blueprint.starter_csv_prompt": "このコンピュータでCSVファイルの作成や編集を手伝ってください。", + "blueprint.starter_csv_title": "CSVを操作", + "blueprint.starter_explore_desc": "ファイルを要約し、最初に取り組むべきタスクを提案します。", + "blueprint.starter_explore_prompt": "このワークスペースを要約し、最も重要なファイルを指摘し、最初に取り組むべきタスクを提案してください。", + "blueprint.starter_explore_title": "このワークスペースを探索", + "blueprint.welcome_message": "OpenWorkへようこそ!\n\nCSVファイルの作成、Chromeの自動化、Notionへの連絡先同期など、さまざまな作業に使われています。\n\n可能性は無限大です。\n\n何をしましょうか?", + "blueprint.welcome_title": "OpenWorkへようこそ", + "common.add": "追加", + "common.cancel": "キャンセル", + "common.choose": "選択", + "common.close": "閉じる", + "common.default_parens": "(デフォルト)", + "common.done": "完了", + "common.edit": "編集", + "common.hide": "隠す", + "common.install": "インストール", + "common.navigate": "移動", + "common.next": "次へ", + "common.off": "オフ", + "common.on": "オン", + "common.path": "パス", + "common.question": "質問", + "common.refresh": "更新", + "common.remove": "削除", + "common.reset": "リセット", + "common.retry": "再試行", + "common.save": "保存", + "common.select": "選択", + "common.show": "表示", + "common.something_went_wrong": "エラーが発生しました", + "common.submit": "送信", + "common.unknown": "不明", + "composer.agent_label": "エージェント", + "composer.attach_files": "ファイルを添付", + "composer.attachments_unavailable": "添付ファイルは利用できません。", + "composer.behavior_label": "動作", + "composer.configure": "設定", + "composer.default_agent": "デフォルトエージェント", + "composer.expand_pasted": "クリックで貼り付けたテキストを展開", + "composer.failed_read_attachment": "添付ファイルの読み込みに失敗しました", + "composer.file_exceeds_limit": "{name}は8MBの制限を超えています。", + "composer.file_kind": "ファイル", + "composer.file_too_large_encoding": "{name}はエンコード後のサイズが大きすぎます。小さい画像をお試しください。", + "composer.image_kind": "画像", + "composer.inserted_links_unsupported": "未対応ファイルのリンクを挿入しました。", + "composer.loading_agents": "エージェントを読み込み中…", + "composer.loading_commands": "コマンドを読み込み中…", + "composer.mcps_label": "MCP", + "composer.no_commands": "コマンドが見つかりません。", + "composer.no_matches": "一致するものが見つかりません。", + "composer.placeholder": "タスクを入力してください…", + "composer.remote_worker_paste_warning": "これはリモートワーカーです。サンドボックスもリモートです。ファイルを共有するには、サイドバーの共有フォルダにアップロードしてください。", + "composer.run_task": "タスクを実行", + "composer.skill_source": "スキル", + "composer.stop": "停止", + "composer.tools_label": "コマンド、スキル、MCP", + "composer.unsupported_attachment_type": "未対応の添付ファイル形式です。", + "composer.upload_failed_local_links": "共有フォルダにアップロードできませんでした。代わりにローカルリンクを挿入しました。", + "composer.upload_to_shared_folder": "共有フォルダにアップロード", + "composer.uploaded_multiple_files": "{count}件のファイルを共有フォルダにアップロードし、リンクを挿入しました。", + "composer.uploaded_single_file": "{name}を共有フォルダにアップロードし、リンクを挿入しました。", + "config.auto_reload_desc": "エージェント/スキル/コマンド/設定の変更後に自動リロードします(アイドル時のみ)。", + "config.auto_reload_title": "自動リロード(ローカル)", + "config.auto_reload_unavailable": "デスクトップアプリのローカルワークスペースで利用可能です。", + "config.collaborator_token_disabled_hint": "リモート共有用に事前保存されていますが、リモートアクセスは現在無効です。", + "config.collaborator_token_label": "コラボレータートークン", + "config.collaborator_token_remote_hint": "スマートフォンやノートPCからこのサーバーに接続する通常のリモートアクセス用。", + "config.connection_failed": "接続に失敗しました。", + "config.connection_failed_check": "接続に失敗しました。ホストURLとトークンを確認してください。", + "config.connection_status_updated": "接続ステータスを更新しました。", + "config.connection_successful": "接続に成功しました。", + "config.copied": "コピー済み", + "config.copy": "コピー", + "config.desktop_only_hint": "一部の設定機能(ローカルサーバー共有やメッセージングブリッジ)にはデスクトップアプリが必要です。", + "config.diagnostics_desc": "サニタイズされたランタイム状態をデバッグ用にコピーします。", + "config.diagnostics_title": "診断バンドル", + "config.enable_auto_reload_first": "先に自動リロードを有効にしてください", + "config.engine_reload_desc": "このワークスペースのOpenCodeサーバーを再起動します。", + "config.engine_reload_title": "エンジンリロード", + "config.host_admin_token_hint": "承認CLIおよび管理APIのためのホスト専用内部トークンです。リモートアプリ接続フローには使用しないでください。", + "config.host_admin_token_label": "ホスト管理トークン", + "config.host_local_only": "ローカルのみ", + "config.host_offline": "オフライン", + "config.host_remote_enabled": "リモート有効", + "config.local_ip_hint": "同じWi-Fiでは、ローカルIPを使うと最も高速に接続できます。", + "config.mdns_hint": ".localの名前は覚えやすいですが、一部のネットワークでは解決できない場合があります。", + "config.messaging_identities_desc": "Telegram/Slackのアイデンティティとルーティングはアイデンティティタブで管理できます。", + "config.messaging_identities_title": "メッセージングアイデンティティ", + "config.not_set": "未設定", + "config.owner_token_disabled_hint": "このワーカーのリモートアクセスを有効にした後にのみ関連します。", + "config.owner_token_label": "オーナートークン", + "config.owner_token_remote_hint": "リモートクライアントが許可プロンプトへの応答やオーナー専用操作が必要な場合に使用します。", + "config.reload_active_tasks_warning": "リロードするとアクティブなタスクが停止します。", + "config.reload_availability_hint": "リロードはローカルワーカーまたは接続済みOpenWorkサーバーでのみ利用可能です。", + "config.reload_connect_hint": "リロードするにはこのワーカーに接続してください。", + "config.reload_engine": "エンジンをリロード", + "config.reload_now_desc": "設定の更新を適用し、セッションを再接続します。", + "config.reload_now_title": "今すぐリロード", + "config.reloading": "リロード中…", + "config.remote_access_off_hint": "リモートアクセスはオフです。別のマシンから接続する場合は、先にワークスペースの共有を有効にしてください。", + "config.resolved_worker_url": "解決済みワーカーURL:", + "config.resume_sessions_desc": "タスク実行中にリロードがキューされた場合、リロード後にリジュームメッセージを送信します。", + "config.resume_sessions_title": "自動リロード後にセッションを再開", + "config.server_needed_hint": "スキル、プラグイン、コマンドを同期するにはOpenWorkサーバー接続が必要です。", + "config.server_section_desc": "OpenWorkサーバーに接続します。サーバー管理者から共有されたURLとコラボレーターまたはオーナートークンを使用してください。", + "config.server_section_title": "OpenWorkサーバー", + "config.server_sharing_desc": "信頼できるデバイスとこれらの詳細を共有してください。最速のセットアップには同じネットワーク上のサーバーを使用してください。", + "config.server_sharing_menu_hint": "ワークスペースごとの共有リンクは、ワークスペースメニューの「共有…」から利用できます。", + "config.server_sharing_title": "OpenWorkサーバー共有", + "config.server_url_hint": "OpenWorkサーバーから共有されたURLを使用してください。ローカルデスクトップワーカーは48000〜51000の範囲の永続ハイポートを再利用します。", + "config.server_url_input_label": "OpenWorkサーバーURL", + "config.server_url_label": "OpenWorkサーバーURL", + "config.starting_server": "サーバーを起動中…", + "config.status_connected": "接続済み", + "config.status_limited": "制限あり", + "config.status_not_connected": "未接続", + "config.test_connection": "接続をテスト", + "config.testing": "テスト中…", + "config.testing_connection": "接続をテスト中…", + "config.token_hint": "任意。通常アクセス用のコラボレータートークン、または許可プロンプトへの応答が必要な場合はオーナートークンを貼り付けてください。", + "config.token_label": "コラボレーターまたはオーナートークン", + "config.token_placeholder": "トークンを貼り付け", + "config.unavailable": "利用不可", + "config.worker_id": "ワーカーID:", + "config.workspace_config_desc": "これらの設定は選択されたワークスペースに適用されます。ランタイム操作は現在接続中のワークスペースに適用されます。", + "config.workspace_config_title": "ワークスペース設定", + "config.workspace_id_prefix": "ワークスペース:", + "context_panel.add_button": "追加", + "context_panel.add_folder_hint": "フォルダを追加して、このワークスペースがルートディレクトリ外のファイルを読み書きできるようにします。", + "context_panel.adding_button": "追加中…", + "context_panel.always_available": "常に利用可能", + "context_panel.authorized_folders": "許可されたフォルダ", + "context_panel.authorized_folders_desc": "このワークスペースにルートディレクトリ外のファイルへの読み書きアクセスを許可します。", + "context_panel.authorized_folders_no_access": "許可されたフォルダを編集するには、書き込み可能なOpenWorkサーバーワークスペースに接続してください。", + "context_panel.browse_button": "参照", + "context_panel.config_access_unavailable": "このワークスペースではOpenWorkサーバー設定へのアクセスが利用できません。", + "context_panel.config_read_only": "OpenWorkサーバーのワークスペース設定は読み取り専用です。", + "context_panel.context": "コンテキスト", + "context_panel.folder_already_authorized": "このフォルダはすでに許可されています。", + "context_panel.folders_updated": "許可されたフォルダを更新しました。", + "context_panel.input_placeholder": "許可するフォルダパスを入力…", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "接続済み", + "context_panel.mcp_disabled": "無効", + "context_panel.mcp_disconnected": "切断済み", + "context_panel.mcp_failed": "失敗", + "context_panel.mcp_needs_auth": "認証が必要", + "context_panel.mcp_register_client": "クライアント登録", + "context_panel.no_external_folders": "外部フォルダが許可されていません", + "context_panel.no_mcp": "MCPサーバーが読み込まれていません。", + "context_panel.no_plugins": "プラグインが読み込まれていません。", + "context_panel.no_server_workspace": "アクティブなサーバーワークスペースが選択されていません。", + "context_panel.no_skills": "スキルが読み込まれていません。", + "context_panel.none_yet": "まだありません。", + "context_panel.plugins": "プラグイン", + "context_panel.preserving_entries": "フォルダ以外の権限エントリ({count}件)を維持しています。", + "context_panel.preserving_entry": "フォルダ以外の権限エントリ(1件)を維持しています。", + "context_panel.remove_folder": "{name}を削除", + "context_panel.saving_folders": "許可されたフォルダを保存中…", + "context_panel.server_disconnected": "OpenWorkサーバーが接続されていません。", + "context_panel.skills": "スキル", + "context_panel.working_files": "作業ファイル", + "context_panel.workspace_root_available": "ワークスペースルートはすでに利用可能です。", + "context_panel.workspace_root_badge": "ワークスペースルート", + "context_panel.writable_workspace_required": "許可されたフォルダを更新するには、書き込み可能なOpenWorkサーバーワークスペースが必要です。", + "dashboard.access_token": "アクセストークン", + "dashboard.access_token_optional_hint": "ワーカーがトークンを必要とする場合のみ追加してください。", + "dashboard.blueprints_workspace": "ブループリント", + "dashboard.blueprints_workspace_desc": "スキル、コマンド、共有フローをすぐ活用できる自動化向けワークスペースです。", + "dashboard.change": "変更", + "dashboard.choose_folder": "フォルダを選択してください", + "dashboard.choose_folder_continue": "続行するにはフォルダを選択してください。", + "dashboard.choose_folder_next": "ワークスペースとファイルを共有できます。", + "dashboard.choose_preset": "プリセットを選択", + "dashboard.chooser_local_desc": "このデバイスにワークスペースを作成し、チームテンプレートから開始することもできます。", + "dashboard.chooser_remote_desc": "URLとアクセストークンを使ってセルフホストのOpenWorkワーカーに接続します。", + "dashboard.chooser_shared_desc": "組織で共有されたクラウドワーカーを参照し、ワンステップで接続します。", + "dashboard.close_settings": "設定を閉じる", + "dashboard.cloud_signin_button": "Cloudで続行", + "dashboard.cloud_signin_hint": "組織で共有されたリモートワークスペースにアクセスできます。", + "dashboard.cloud_signin_next": "次にチームを選択し、既存のワークスペースに接続します。", + "dashboard.cloud_signin_title": "OpenWork Cloudにサインイン", + "dashboard.cloud_worker": "クラウドワーカー", + "dashboard.commands": "コマンド", + "dashboard.connect_remote_button": "リモート接続", + "dashboard.connected": "接続済み", + "dashboard.connecting": "接続中…", + "dashboard.create_local_workspace_subtitle": "このデバイスにワークスペースを作成し、チームテンプレートから開始することもできます。", + "dashboard.create_local_workspace_title": "ローカルワークスペース", + "dashboard.create_remote_custom_subtitle": "セルフホストのOpenWorkワーカーに接続します。", + "dashboard.create_remote_custom_title": "カスタムリモートに接続", + "dashboard.create_remote_workspace_confirm": "ワークスペースを追加", + "dashboard.create_remote_workspace_subtitle": "OpenWorkサーバーをワークスペースとして保存します。", + "dashboard.create_remote_workspace_title": "リモートワークスペースを追加", + "dashboard.create_sandbox_confirm": "サンドボックスとして作成", + "dashboard.create_shared_subtitle_signed_in": "組織で共有されたクラウドワーカーを参照し、ワンステップで接続します。", + "dashboard.create_shared_subtitle_signed_out": "組織で共有されたワークスペースにアクセスするにはOpenWork Cloudにサインインしてください。", + "dashboard.create_shared_title": "共有ワークスペース", + "dashboard.create_workspace_confirm": "ワークスペースを作成", + "dashboard.create_workspace_subtitle": "新しいフォルダベースのワークスペースを作成します。", + "dashboard.create_workspace_title": "ワークスペースを作成", + "dashboard.creating": "作成中…", + "dashboard.desktop_badge": "デスクトップ", + "dashboard.display_name_label": "表示名", + "dashboard.display_name_optional": "(任意)", + "dashboard.docker_debug_details": "Dockerデバッグ詳細", + "dashboard.edit_remote_workspace_confirm": "接続を保存", + "dashboard.edit_remote_workspace_subtitle": "このワークスペースのOpenWorkサーバー詳細を更新します。", + "dashboard.edit_remote_workspace_title": "リモート接続を編集", + "dashboard.empty_workspace": "空のワークスペース", + "dashboard.empty_workspace_desc": "空のフォルダから始めて、必要なものを追加します。", + "dashboard.error_choose_org": "ワークスペースを開く前に組織を選択してください。", + "dashboard.error_connect_worker": "{name}への接続に失敗しました。", + "dashboard.error_create_template": "{name}の作成に失敗しました。", + "dashboard.error_load_orgs": "組織の読み込みに失敗しました。", + "dashboard.error_load_shared_workspaces": "共有ワークスペースの読み込みに失敗しました。", + "dashboard.error_workspace_not_ready": "ワークスペースはまだ接続できる状態ではありません。しばらくしてから再試行してください。", + "dashboard.import_config": "設定をインポート", + "dashboard.importing": "インポート中…", + "dashboard.modal_back": "戻る", + "dashboard.modal_close": "ワークスペース追加ダイアログを閉じる", + "dashboard.nav_ids": "ID", + "dashboard.no_folder_selected": "フォルダがまだ選択されていません。", + "dashboard.open_cloud_dashboard": "クラウドダッシュボードを開く", + "dashboard.opening": "開いています…", + "dashboard.openwork_host_hint": "OpenWorkサーバーから共有されたURLを使用してください。", + "dashboard.openwork_host_label": "OpenWorkサーバーURL", + "dashboard.openwork_host_placeholder": "https://your-server.openwork.app", + "dashboard.openwork_host_token_hint": "任意。通常アクセス用のコラボレータートークン、または許可プロンプトへの応答が必要な場合はオーナートークンを貼り付けてください。", + "dashboard.openwork_host_token_label": "コラボレーターまたはオーナートークン", + "dashboard.openwork_host_token_placeholder": "トークンを貼り付け", + "dashboard.recently_updated": "最近更新", + "dashboard.remote": "リモート", + "dashboard.remote_base_url_required": "続行するにはサーバーURLを追加してください。", + "dashboard.remote_connection_direct": "ダイレクト", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "空欄ならサーバーのデフォルトを使います。", + "dashboard.remote_directory_label": "ワークスペースディレクトリ(任意)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "表示名(任意)", + "dashboard.remote_display_name_placeholder": "デザインチームワークスペース", + "dashboard.remote_server_details_hint": "セルフホストのOpenWorkワーカーに接続します。", + "dashboard.remote_server_details_title": "リモートサーバー詳細", + "dashboard.remote_workspace_hint": "OpenWorkサーバーを登録して、いつでも再接続できます。", + "dashboard.remote_workspace_title": "リモートワークスペース", + "dashboard.repair_cache": "キャッシュを修復", + "dashboard.repairing_cache": "キャッシュ修復中", + "dashboard.sandbox_checking_docker": "Dockerを確認中…", + "dashboard.sandbox_get_ready_action": "システムを準備する", + "dashboard.sandbox_get_ready_desc": "このワークスペースをDockerコンテナで安全に実行できます。再現性も向上します。", + "dashboard.sandbox_get_ready_title": "サンドボックスにはDockerが必要です", + "dashboard.sandbox_hide_logs": "ログを隠す", + "dashboard.sandbox_live_logs": "ライブログ", + "dashboard.sandbox_setup": "サンドボックスセットアップ", + "dashboard.sandbox_show_logs": "ログを表示", + "dashboard.search_shared_workspaces": "共有ワークスペースを検索", + "dashboard.select_folder": "フォルダを選択", + "dashboard.settings": "設定", + "dashboard.shared_workspaces_loading": "共有ワークスペースを読み込み中…", + "dashboard.shared_workspaces_no_match": "検索に一致する共有ワークスペースがありません。", + "dashboard.shared_workspaces_none": "利用可能な共有ワークスペースがまだありません。", + "dashboard.shared_workspaces_refreshing": "ワークスペースを更新中…", + "dashboard.skills": "スキル", + "dashboard.starter_workspace": "スターターワークスペース", + "dashboard.starter_workspace_desc": "プラグイン、コマンド、スキルがすぐ使えるワークスペースです。", + "dashboard.unknown_creator": "不明な作成者", + "dashboard.worker_status_attention": "要確認", + "dashboard.worker_status_ready": "準備完了", + "dashboard.worker_status_starting": "起動中", + "dashboard.worker_status_stopped": "停止済み", + "dashboard.worker_status_unknown": "不明", + "dashboard.worker_url_hint": "接続するOpenWorkワーカーのURLを貼り付けてください。", + "dashboard.worker_url_label": "ワーカーURL", + "dashboard.workspace_connect": "接続", + "dashboard.workspace_connect_unavailable": "ここでは共有ワークスペースへの接続は利用できません。", + "dashboard.workspace_connecting": "接続中", + "dashboard.workspace_folder_hint": "このデバイスでワークスペースを配置する場所を選択してください。", + "dashboard.workspace_folder_title": "ワークスペースフォルダ", + "dashboard.workspace_not_ready_title": "このワークスペースはまだ接続できる状態ではありません。", + "dashboard.workspaces": "ワークスペース", + "den.active_org_hint": "クラウドワーカーとチームテンプレートは選択した組織にスコープされます。", + "den.active_org_title": "アクティブな組織", + "den.auto_reconnect_hint": "ブラウザで認証を完了すると、OpenWorkが自動的にここに再接続します。", + "den.checking_session": "セッションを確認中", + "den.choose_org_for_providers": "クラウドプロバイダーを表示するには組織を選択してください。", + "den.choose_org_for_skill_hubs": "クラウドスキルハブを表示するには組織を選択してください。", + "den.cloud_account_hint": "接続済みアカウントと組織を管理します。", + "den.cloud_account_title": "Cloudアカウント", + "den.cloud_control_plane_open": "ブラウザで開く", + "den.cloud_control_plane_reset": "リセット", + "den.cloud_control_plane_save": "URLを保存", + "den.cloud_control_plane_url_hint": "デベロッパーモード専用。ローカルまたはセルフホストのCloudコントロールプレーンを対象にする場合に使用します。変更するとサインアウトされ、新しいコントロールプレーンに対して再接続します。", + "den.cloud_control_plane_url_label": "Cloudコントロールプレーン URL", + "den.cloud_provider_detail": "{count}モデル · {source}プロバイダー", + "den.cloud_provider_removed_detail": "このインポート済みプロバイダーはクラウドにありません。ローカルの{providerId}設定をアンインストールしてください。", + "den.cloud_provider_sync_detail": "クラウドプロバイダーが変更されました。{count}モデルの{source}設定をopencode.jsoncに同期してください。", + "den.cloud_providers_hint": "管理対象LLMプロバイダーをopencode.jsoncにインポートし、このワークスペースで組織の認証情報を使用します。", + "den.cloud_providers_title": "クラウドプロバイダー", + "den.cloud_section_desc": "サインインして組織を選択し、Cloudワーカーやチームテンプレートを開きます。", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "コンピュータがスリープしてもタスクを維持するには、OpenWork Cloudにサインインしてください。", + "den.cloud_workers_hint": "アプリの既存のリモート接続フローを使って、ワーカーを直接OpenWorkで開きます。", + "den.cloud_workers_title": "クラウドワーカー", + "den.create_account": "アカウントを作成", + "den.credentials_ready_badge": "認証情報準備完了", + "den.error_base_url": "有効なhttp://またはhttps://のCloudコントロールプレーンURLを入力してください。", + "den.error_choose_org": "ワーカーを開く前に組織を選択してください。", + "den.error_load_orgs": "組織の読み込みに失敗しました。", + "den.error_load_workers": "ワーカーの読み込みに失敗しました。", + "den.error_no_session": "アクティブなCloudセッションが見つかりません。", + "den.error_no_token": "デスクトップサインインは完了しましたが、OpenWork Cloudがセッショントークンを返しませんでした。", + "den.error_open_worker": "OpenWorkで{name}を開けませんでした。", + "den.error_open_worker_fallback": "{name}を開けませんでした。", + "den.error_paste_valid_code": "有効なOpenWorkサインインリンクまたはワンタイムサインインコードを貼り付けてください。", + "den.error_signin_failed": "OpenWork Cloudサインインを完了できませんでした。", + "den.error_worker_not_ready": "ワーカーはまだ開ける状態ではありません。プロビジョニング完了後に再試行してください。", + "den.finish_signin": "サインインを完了", + "den.finishing": "完了中…", + "den.hide_signin_code": "サインインコードを隠す", + "den.import_all": "すべてインポート", + "den.import_provider": "インポート", + "den.import_provider_failed": "{name}のインポートに失敗しました。", + "den.imported_badge": "インポート済み", + "den.imported_provider": "{name}をインポートしました。", + "den.importing": "インポート中…", + "den.needs_attention": "要確認", + "den.no_cloud_providers": "この組織にはまだクラウドプロバイダーがありません。", + "den.no_cloud_workers": "この組織にはまだクラウドワーカーがありません。Cloudで作成してから、このタブを更新してください。", + "den.no_org_selected": "組織が選択されていません", + "den.no_skill_hubs": "この組織にはまだクラウドスキルハブがありません。", + "den.open": "開く", + "den.opening": "開いています…", + "den.org_member_suffix": "(メンバー)", + "den.org_owner_suffix": "(オーナー)", + "den.org_switched": "{name}に切り替えました。", + "den.out_of_sync_badge": "同期が必要", + "den.paste_signin_code": "サインインコードを貼り付け", + "den.refresh": "更新", + "den.reload_workspace": "設定変更を適用するにはワークスペースをリロードしてください。", + "den.remove_provider_failed": "{name}の削除に失敗しました。", + "den.removed_from_cloud_badge": "クラウドから削除済み", + "den.removed_provider": "{name}を削除しました。", + "den.removing": "削除中…", + "den.sign_out": "サインアウト", + "den.signed_out": "サインアウト済み", + "den.signin_button": "サインイン", + "den.signin_code_note": "openwork://den-authリンクまたは生のワンタイムコードを受け付けます。", + "den.signin_link_hint": "ブラウザが自動的にOpenWorkに戻らない場合は、OpenWork Cloudのサインインリンクまたはワンタイムコードをここに貼り付けてください。", + "den.signin_link_label": "サインインリンクまたはワンタイムコード", + "den.signin_link_placeholder": "openwork://den-auth?... またはコードを貼り付け", + "den.signin_title": "OpenWork Cloudにサインイン", + "den.signing_in": "OpenWork Cloudサインインを完了中…", + "den.signing_out": "サインアウト中…", + "den.skill_hub_detail": "{count}件の共有スキルを.opencode/skillsにインポートします。", + "den.skill_hub_imported_detail": "このワークスペースに{count}件のスキルをインポートしました。", + "den.skill_hub_removed_detail": "このハブはクラウドから削除されました。このワークスペースの{importedCount}件のインポート済みスキルをアンインストールしてください。", + "den.skill_hub_skills_badge": "{count}件のスキル", + "den.skill_hub_sync_detail": "クラウドに{liveCount}件のスキルがあり、このワークスペースは{importedCount}件をインポート済みです。同期してインストール済みセットを更新してください。", + "den.skill_hubs_hint": "共有クラウドハブからすべてのスキルをこのワークスペースに一括インポートします。", + "den.skill_hubs_title": "スキルハブ", + "den.status_base_url_updated": "Cloudコントロールプレーン URLを更新しました。続行するには再度サインインしてください。", + "den.status_browser_signin": "ブラウザでサインインを完了してOpenWorkに接続してください。", + "den.status_browser_signup": "ブラウザでアカウント作成を完了してOpenWorkに接続してください。", + "den.status_cloud_signed_in_as": "OpenWork Cloudに{email}として接続しました。", + "den.status_cloud_signin_done": "OpenWork Cloudに接続しました。", + "den.status_loaded_orgs": "{count}件の組織を読み込みました。", + "den.status_loaded_workers": "{name}の{count}件のワーカーを読み込みました。", + "den.status_no_workers": "{name}にワーカーが見つかりません。", + "den.status_opened_worker": "OpenWorkで{name}を開きました。", + "den.status_signed_in_as": "{email}としてサインインしました。", + "den.status_signed_out": "サインアウトし、このデバイスのOpenWork Cloudセッションを削除しました。", + "den.sync": "同期", + "den.sync_provider_failed": "{name}の同期に失敗しました。", + "den.synced_provider": "{name}を同期しました。", + "den.syncing": "同期中…", + "den.uninstall": "アンインストール", + "den.worker_mine_badge": "自分", + "den.worker_not_ready_title": "このワーカーはまだ開ける状態ではありません。", + "den.worker_provider_label": "{provider}ワーカー", + "den.worker_secondary_cloud": "クラウドワーカー", + "extensions.app_count_one": "{count}件のアプリ接続済み", + "extensions.app_count_many": "{count}件のアプリ接続済み", + "extensions.apps_mcp_header": "アプリ(MCP)", + "extensions.filter_all": "すべて", + "extensions.filter_apps": "アプリ", + "extensions.filter_plugins": "プラグイン", + "extensions.plugin_count_one": "{count}件のプラグイン", + "extensions.plugin_count_many": "{count}件のプラグイン", + "extensions.plugins_opencode_header": "プラグイン(OpenCode)", + "extensions.subtitle": "アプリ(MCP)とOpenCodeプラグインをまとめて管理できます。", + "extensions.title": "拡張機能", + "identities.agent_behavior_desc": "ワークスペースごとに1ファイルです。先頭行に@agent を追加すると特定のOpenCodeエージェント経由でルーティングされます。", + "identities.agent_behavior_title": "メッセージングエージェントの動作", + "identities.agent_created": "デフォルトのメッセージングエージェントファイルを作成しました。", + "identities.agent_file_changed": "ファイルがリモートで変更されました。リロードして再度保存してください。", + "identities.agent_loading": "エージェントファイルを読み込み中…", + "identities.agent_none": "なし", + "identities.agent_not_found": "このワークスペースにエージェントファイルがまだありません。", + "identities.agent_saved": "メッセージング動作を保存しました。", + "identities.agent_scope_status": "アクティブスコープ: ワークスペース · ステータス: {status} · 選択エージェント: {agent}", + "identities.agent_status_loaded": "読み込み済み", + "identities.agent_status_missing": "未設定", + "identities.agent_worker_scope_unavailable": "ワーカースコープが利用できません。", + "identities.all_channels": "すべてのチャンネル", + "identities.app_token_label": "アプリトークン", + "identities.auto_bind_label": "ダイレクト送信時にピアをディレクトリに自動バインド", + "identities.available_channels": "利用可能なチャンネル", + "identities.bot_token_label": "ボットトークン", + "identities.bot_token_placeholder": "@BotFatherからTelegramボットトークンを貼り付け", + "identities.botfather_step1_open": "1. Telegramで@BotFatherを開く", + "identities.botfather_step1_run": "/newbotを実行", + "identities.botfather_step3_choose": "3. ボットの名前とユーザー名を選択", + "identities.botfather_step3_or_private": "でオープン受信箱にするか", + "identities.botfather_step3_private": "プライベート", + "identities.botfather_step3_public": "パブリック", + "identities.botfather_step3_to_require": "にしてペアリングコードを要求", + "identities.channel_label": "チャンネル", + "identities.channels_connected": "接続済み", + "identities.channels_label": "チャンネル", + "identities.configured_suffix": "設定済み", + "identities.connect_server_desc": "OpenWorkホストに接続されている場合、アイデンティティが利用可能になります。", + "identities.connect_server_title": "OpenWorkサーバーに接続", + "identities.connect_slack": "Slackに接続", + "identities.connected_badge": "接続済み", + "identities.connecting": "接続中…", + "identities.copy_bot_token_hint": "ボットトークンをコピーして下に貼り付けてください。", + "identities.copy_code": "コードをコピー", + "identities.create_default_file": "デフォルトファイルを作成", + "identities.create_private_bot": "プライベートボットを作成", + "identities.create_public_bot": "パブリックボットを作成", + "identities.days_ago": "{days}日前", + "identities.default_routing": "デフォルトルーティング", + "identities.directory_label": "ディレクトリ(任意)", + "identities.disable_messaging": "メッセージングを無効化", + "identities.disable_messaging_message": "このワークスペースのメッセージングをオフにします。メッセージングを再度有効にするまでTelegramとSlackの設定は非表示になり、メッセージングサイドカーを完全に停止するにはワーカーの再起動が必要です。", + "identities.disable_messaging_title": "このワーカーのメッセージングを無効にしますか?", + "identities.disabled_label": "無効", + "identities.disabling": "無効化中…", + "identities.disconnect": "切断", + "identities.dispatched_messages": "{sent}/{attempted}件のメッセージを送信しました。", + "identities.enable_messaging": "メッセージングを有効化", + "identities.enable_messaging_risk": "メッセージングを有効にすると、このワーカーがリモートコマンドにさらされる可能性があります。ボットがパブリックまたは侵害された場合、このワーカーがアクセスできるファイル、認証情報、APIキーにアクセスされる恐れがあります。", + "identities.enable_messaging_title": "このワーカーのメッセージングを有効にしますか?", + "identities.enabled_label": "有効", + "identities.enabling": "有効化中…", + "identities.health_offline": "オフライン", + "identities.health_running": "実行中", + "identities.health_unavailable": "利用不可", + "identities.health_unknown": "不明", + "identities.hours_ago": "{hours}時間前", + "identities.identities_label": "アイデンティティ", + "identities.just_now": "たった今", + "identities.last_activity": "最終アクティビティ", + "identities.later": "後で", + "identities.message_label": "メッセージ", + "identities.message_routing_desc": "どの会話をどのワークスペースフォルダに送るかを制御します。ここでルールを設定しない限り、メッセージはワーカーのデフォルトフォルダにルーティングされます。", + "identities.message_routing_title": "メッセージルーティング", + "identities.messages_today": "今日のメッセージ", + "identities.messaging_disabled_hint": "リスクを理解し、アクセスを保護する予定がある場合のみメッセージングを有効にしてください(例: Telegramプライベートペアリング)。", + "identities.messaging_disabled_restart": "メッセージングが無効です。メッセージングサイドカーを停止するにはこのワーカーを再起動してください。", + "identities.messaging_disabled_risk": "メッセージングボットはローカルワーカーに対してアクションを実行できます。パブリックに公開された場合、このワーカーがアクセスできるファイル、認証情報、APIキーへのアクセスが可能になる恐れがあります。", + "identities.messaging_disabled_title": "メッセージングはデフォルトで無効です", + "identities.messaging_enabled_restart": "メッセージングが有効です。メッセージングサイドカーを起動してTelegramとSlackのセットアップを行うには、このワーカーを再起動してください。", + "identities.messaging_sidecar_not_running": "このワークスペースでメッセージングは有効ですが、メッセージングサイドカーがまだ実行されていません。このワーカーを再起動してから、メッセージング設定に戻ってTelegramまたはSlackに接続してください。", + "identities.minutes_ago": "{minutes}分前", + "identities.not_set": "未設定", + "identities.open_bot_link": "Telegramで@{username}を開く", + "identities.pairing_code_copied": "ペアリングコードをコピーしました。", + "identities.pairing_code_copy_failed": "ペアリングコードをコピーできませんでした。手動でコピーしてください。", + "identities.pairing_code_instruction_prefix": "送信", + "identities.peer_id_label": "ピアID(任意)", + "identities.peer_id_placeholder_slack": "例: slack:U12345678", + "identities.peer_id_placeholder_telegram": "例: telegram:123456789", + "identities.private_label": "プライベート", + "identities.private_pairing_code": "プライベートペアリングコード", + "identities.public_bot_confirm": "リスクを理解しました", + "identities.public_bot_warning_message": "ボットがパブリックになり、アクセスした人がローカルワーカーに完全アクセスできるようになります。ファイルやAPIキーも含まれます。プライベートボットを作成するとペアリングトークンでアクセスを制限できます。本当にパブリックにしますか?", + "identities.public_bot_warning_title": "このボットをパブリックにしますか?", + "identities.public_label": "パブリック", + "identities.quick_setup": "クイックセットアップ", + "identities.reconnect_failed": "再接続に失敗しました。OpenWork URL/トークンを確認して再試行してください。", + "identities.reconnected": "再接続しました。", + "identities.reconnected_refreshing": "再接続しました。ワーカーの状態を更新中…", + "identities.reload": "リロード", + "identities.repair_reconnect": "修復して再接続", + "identities.restart_failed": "再起動に失敗しました。設定からワーカーを再起動して再試行してください。", + "identities.restart_to_disable_messaging": "このワークスペースのメッセージングが無効になりました。メッセージングサイドカーを停止するにはワーカーを今すぐ再起動してください。", + "identities.restart_to_enable_messaging": "このワークスペースのメッセージングが有効になりました。メッセージングサイドカーを起動してTelegramとSlackのセットアップを行うには、ワーカーを今すぐ再起動してください。", + "identities.restart_worker": "ワーカーを再起動", + "identities.restart_worker_title": "ワーカーを今すぐ再起動しますか?", + "identities.restarting": "再起動中…", + "identities.routing_override_prefix": "すべてのメッセージの転送先:", + "identities.routing_override_suffix": "(オーバーライド有効)", + "identities.running_label": "実行中", + "identities.save_behavior": "動作を保存", + "identities.saving": "保存中…", + "identities.send_test_button": "テストメッセージを送信", + "identities.send_test_desc": "送信経路を検証します。ダイレクト送信にはピアIDを使用するか、ディレクトリ内のバインディングでファンアウトするにはピアIDを空にしてください。", + "identities.send_test_title": "テストメッセージを送信", + "identities.sending": "送信中…", + "identities.slack_desc": "ワーカーがSlackチャンネルでボットとして表示されます。チームメンバーがダイレクトメッセージやスレッドで操作できます。", + "identities.slack_intro": "Slackワークスペースに接続して、チームメンバーがチャンネルやDMでこのワーカーとやり取りできるようにします。", + "identities.slack_unavailable": "Slackアイデンティティが利用できません。", + "identities.status_active": "アクティブ", + "identities.status_label": "ステータス", + "identities.status_stopped": "停止", + "identities.stopped_label": "停止", + "identities.subtitle": "メッセージングアプリを通じてワーカーにアクセスできるようにします。チャンネルを接続すると、ワーカーが自動的にメッセージを読み取り応答します。", + "identities.tab_general": "一般", + "identities.telegram_bot_access_desc": "パブリックボット: 最初のTelegramチャットが自動リンクされます。プライベートボット: ツールの実行前にペアリングコードが必要です。", + "identities.telegram_delete_failed": "削除に失敗しました。", + "identities.telegram_deleted": "削除しました。", + "identities.telegram_deleted_pending": "削除済み(適用待ち)。", + "identities.telegram_desc": "Telegramボットをパブリックモード(オープン受信箱)またはプライベートモード(ペアリングコード必須)で接続します。", + "identities.telegram_private_saved_pair": "プライベートボットを保存しました。/pair {code}でペアリングしてください", + "identities.telegram_save_failed": "保存に失敗しました。", + "identities.telegram_saved": "保存しました。", + "identities.telegram_saved_pending": "保存済み(適用待ち)。", + "identities.telegram_saved_username": "保存しました(@{username})", + "identities.telegram_unavailable": "Telegramアイデンティティが利用できません。", + "identities.title": "メッセージングチャンネル", + "identities.unsaved_changes": "未保存の変更", + "identities.worker_offline": "ワーカーオフライン", + "identities.worker_online": "ワーカーオンライン", + "identities.worker_restarted": "ワーカーを再起動しました。", + "identities.worker_restarted_refreshing": "ワーカーを再起動しました。メッセージングステータスを更新中…", + "identities.worker_scope_unavailable": "ワーカースコープが利用できません。", + "identities.worker_scope_unavailable_detail": "ワーカースコープが利用できません。ワーカーURLで再接続するか、既知のワーカーに切り替えてください。", + "identities.worker_unavailable": "ワーカーが利用できません", + "identities.workspace_id_required": "アイデンティティを管理するにはワークスペースIDが必要です。ワークスペースURLで再接続するか、このホストにマッピングされたワークスペースを選択してください。", + "identities.workspace_scope_prefix": "ワークスペーススコープ:", + "inbox_panel.connect_to_download": "共有ファイルをダウンロードするにはワーカーに接続してください。", + "inbox_panel.connect_to_see": "共有ファイルを表示するには接続してください。", + "inbox_panel.connect_to_upload": "アップロードするにはワーカーに接続してください", + "inbox_panel.copy_failed": "コピーに失敗しました。ブラウザがクリップボードアクセスをブロックしている可能性があります。", + "inbox_panel.download": "ダウンロード", + "inbox_panel.drop_to_upload": "ここにファイルをドロップしてアップロード", + "inbox_panel.helper_text": "アプリからこのワーカーにファイルを共有できます。", + "inbox_panel.load_failed": "共有フォルダの読み込みに失敗しました", + "inbox_panel.missing_file_id": "共有ファイルIDが見つかりません。", + "inbox_panel.no_files": "まだ共有ファイルがありません。", + "inbox_panel.refresh_tooltip": "共有フォルダを更新", + "inbox_panel.shared_folder": "共有フォルダ", + "inbox_panel.showing_first": "先頭{count}件を表示中。", + "inbox_panel.upload_failed": "共有フォルダへのアップロードに失敗しました", + "inbox_panel.upload_needs_worker": "共有フォルダにファイルをアップロードするにはワーカーに接続してください。", + "inbox_panel.upload_prompt": "ファイルをドロップまたはクリックしてアップロード", + "inbox_panel.upload_success": "共有フォルダにアップロードしました。", + "inbox_panel.uploading": "アップロード中…", + "inbox_panel.uploading_label": "{label}をアップロード中…", + "mcp.activate_button": "有効化", + "mcp.add_modal_subtitle": "URLまたはローカルコマンドでカスタムMCPサーバーを接続します。", + "mcp.add_modal_title": "カスタムアプリを追加", + "mcp.add_server_button": "アプリを追加", + "mcp.advanced": "詳細", + "mcp.advanced_settings": "詳細設定", + "mcp.advanced_settings_hint": "設定ファイルを編集し、接続を手動で管理します。", + "mcp.app_connected": "アプリ接続済み", + "mcp.apps_connected": "アプリ接続済み", + "mcp.apps_subtitle": "ツールを接続して、OpenWorkが代わりに操作できるようにしましょう。", + "mcp.apps_title": "アプリ", + "mcp.auth.already_connected": "既に接続済み", + "mcp.auth.already_connected_description": "{server} は既に認証済みで、使用する準備ができています。", + "mcp.auth.applying_changes_body": "新しいMCPが認証できるようにワーカーを再起動しています。", + "mcp.auth.applying_changes_title": "サインイン前に変更を適用中", + "mcp.auth.authorization_link": "認証リンク", + "mcp.auth.authorization_still_required": "承認がまだ必要です。フローを再開するには再試行してください。", + "mcp.auth.callback_invalid": "OAuthを完了するにはコールバックURLまたはcodeパラメータを貼り付けてください。", + "mcp.auth.callback_label": "コールバックURLまたはコード", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "キャンセル", + "mcp.auth.client_registration_required": "OAuthを続行する前にクライアント登録が必要です。", + "mcp.auth.complete_connection": "接続を完了", + "mcp.auth.configured_previously": "MCPはグローバルまたは前のセッションで設定されている可能性があります。この画面を閉じて、すぐにMCPツールの使用を開始できます。", + "mcp.auth.connect_server": "{server} に接続", + "mcp.auth.copied": "コピー済み", + "mcp.auth.copy_link": "リンクをコピー", + "mcp.auth.done": "完了", + "mcp.auth.failed_to_start_oauth": "OAuthフローの開始に失敗しました", + "mcp.auth.follow_browser_steps": "ブラウザで承認手順に従ってください。", + "mcp.auth.force_stop": "強制停止", + "mcp.auth.force_stopping": "停止中…", + "mcp.auth.im_done": "完了しました", + "mcp.auth.invalid_refresh_token": "OAuthリフレッシュトークンが無効または期限切れです。続行するには再承認してください。", + "mcp.auth.manual_finish_hint": "コールバックURL(localhost:19876)またはコードだけを貼り付けて接続を完了してください。", + "mcp.auth.manual_finish_title": "リモートサーバーですか?", + "mcp.auth.oauth_completed_reload": "OAuthが完了しました。MCPを有効化するにはエンジンをリロードしてください。", + "mcp.auth.oauth_failed": "OAuth認証に失敗しました。", + "mcp.auth.oauth_not_supported_hint": "考えられる原因:\n• MCPサーバーがOAuth機能を公開していません\n• エンジンをリロードしてサーバー機能を検出する必要があります\n• 試してみてください: opencode mcp auth {server}(CLIから)", + "mcp.auth.open_browser_signin": "ブラウザを開いてサインインを完了します。", + "mcp.auth.port_forward_hint": "ヒント: 必要に応じてコールバックポートを転送してください: ssh -L 19876:127.0.0.1:19876 user@host", + "mcp.auth.reauth_action": "OAuthを再承認", + "mcp.auth.reauth_cli_hint": "実行: opencode mcp auth {server}", + "mcp.auth.reauth_failed": "再承認に失敗しました。", + "mcp.auth.reauth_remote_hint": "このワーカーを実行しているマシンから再承認してください。", + "mcp.auth.reauth_running": "再承認中…", + "mcp.auth.reload_blocked": "セッション実行中はリロードが一時停止されます。セットアップを完了するには実行を停止してください。", + "mcp.auth.reload_engine_retry": "変更を適用して再試行", + "mcp.auth.reload_failed": "サインイン前にワーカーのリロードに失敗しました。", + "mcp.auth.reload_notice": "この変更を反映するには、OpenWorkがワーカーサービスを更新する必要があります。実行中のセッションが中断される可能性があります。", + "mcp.auth.reload_remote_confirm": "この変更を反映するには、OpenWorkがワーカーサービスを更新する必要があります。実行中のセッションが停止する可能性があります。続行しますか?", + "mcp.auth.reopen_browser_link": "ここをクリックしてブラウザを再度開く", + "mcp.auth.request_timed_out": "リクエストがタイムアウトしました。", + "mcp.auth.retry": "再試行", + "mcp.auth.retry_now": "今すぐ再試行", + "mcp.auth.server_disabled": "このMCPサーバーは無効です。有効にしてから再試行してください。", + "mcp.auth.step1_description": "{server} のサインインフローを自動的に起動します。", + "mcp.auth.step1_title": "ブラウザを開いています", + "mcp.auth.step2_description": "サインインしてプロンプトが表示されたらアクセスを承認してください。", + "mcp.auth.step2_title": "OpenWorkを承認", + "mcp.auth.step3_description": "承認が完了次第、接続を完了します。", + "mcp.auth.step3_title": "完了したらここに戻ってください", + "mcp.auth.try_reload_engine": "{message}。まずエンジンのリロードを試してください。", + "mcp.auth.waiting_authorization": "ブラウザでの承認完了を待っています…", + "mcp.auth.waiting_for_conversation_body": "できるだけ早く認証画面にリダイレクトします。", + "mcp.auth.waiting_for_conversation_title": "会話の完了を待っています", + "mcp.auth.waiting_for_session": "{session} の作業完了を待っています", + "mcp.available_apps": "利用可能なアプリ", + "mcp.cap_signin": "アカウントサインイン", + "mcp.cap_tools": "AIツール", + "mcp.config_file": "設定ファイル", + "mcp.config_load_failed": "設定ファイルを読み込めませんでした", + "mcp.config_not_loaded": "まだ読み込まれていません", + "mcp.config_source": "設定ファイルから", + "mcp.configured": "設定済み", + "mcp.connect": "接続", + "mcp.connect_failed": "接続できませんでした。再試行してください。", + "mcp.connect_server_first": "先にサーバーに接続してください。", + "mcp.connected": "接続済み", + "mcp.connected_badge": "接続済み", + "mcp.connecting": "接続中…", + "mcp.connection_failed": "接続の問題 — 再試行してください", + "mcp.connection_type": "接続", + "mcp.control_chrome_browser_hint": "Chrome 144以降では、まず次の手順を実行してください:", + "mcp.control_chrome_browser_step_one": "chrome://inspect/#remote-debuggingを開く。", + "mcp.control_chrome_browser_step_two": "リモートデバッグを有効にする。", + "mcp.control_chrome_browser_step_three": "Chromeが求めたらデバッグ接続を許可する。", + "mcp.control_chrome_browser_title": "1. Chromeアクセスを有効にする", + "mcp.control_chrome_connect": "Control Chromeを追加", + "mcp.control_chrome_docs": "公式MCPガイド", + "mcp.control_chrome_edit": "設定を編集", + "mcp.control_chrome_profile_hint": "Control Chromeは通常、別のChromeプロファイルを開きます。すでに開いているChromeウィンドウをOpenWorkで再利用したい場合はこれをオンにしてください。", + "mcp.control_chrome_profile_title": "2. 使用するChromeを選択", + "mcp.control_chrome_save": "設定を保存", + "mcp.control_chrome_setup_subtitle": "Chromeアクセスを有効にし、OpenWorkが独自のクリーンプロファイルを使うか、すでに使用しているChromeに接続するかを選択します。", + "mcp.control_chrome_setup_title": "Control Chromeのセットアップ", + "mcp.control_chrome_toggle_hint": "オンにすると、OpenWorkが--autoConnectを追加し、すでに起動しているChromeインスタンスにMCPが接続します。", + "mcp.control_chrome_toggle_label": "既存のChromeプロファイルを使用", + "mcp.control_chrome_toggle_off": "OpenWorkは自動化専用の別のChromeプロファイルを起動します。", + "mcp.control_chrome_toggle_on": "OpenWorkは現在のタブ、Cookie、サインイン情報を再利用します。", + "mcp.custom_app_cta_hint": "独自のMCPサーバー、社内ツール、またはホスト型アプリを接続します。", + "mcp.desktop_required": "アプリにはデスクトップアプリが必要です。", + "mcp.docs_link": "詳細を見る", + "mcp.file_not_found": "設定ファイルがまだ作成されていません", + "mcp.finish_setup": "もう少しです", + "mcp.finish_setup_hint": "有効化をタップしてアプリの接続を完了してください。", + "mcp.friendly_status_issue": "問題", + "mcp.friendly_status_needs_signin": "サインインが必要", + "mcp.friendly_status_offline": "オフライン", + "mcp.friendly_status_paused": "一時停止中", + "mcp.friendly_status_ready": "準備完了", + "mcp.last_synced": "同期済み", + "mcp.login_action": "サインイン", + "mcp.login_hint": "アカウントを接続してこのアプリのセットアップを完了してください。", + "mcp.login_unavailable": "このアプリはOpenWorkからのサインインに対応していません。", + "mcp.logout_action": "ログアウト", + "mcp.logout_failed": "ログアウトに失敗しました。", + "mcp.logout_hint": "保存されたOAuth認証情報を削除します。再度サインインが必要になります。", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "{server} の保存されたOAuth認証情報が削除されます。このアプリを使用するには再度サインインが必要です。", + "mcp.logout_modal_title": "このアプリからログアウトしますか?", + "mcp.logout_success": "{server} からログアウトしました。", + "mcp.logout_working": "ログアウト中…", + "mcp.name_required": "サーバー名を入力してください。", + "mcp.no_apps_hint": "上のアプリを接続して始めましょう。", + "mcp.no_apps_yet": "まだアプリが接続されていません", + "mcp.oauth": "サインイン", + "mcp.oauth_optional_hint": "ブラウザでOAuthを使ってアカウントに接続します。", + "mcp.oauth_optional_label": "このアプリはサインインが必要です", + "mcp.one_click_connect": "ワンクリック接続", + "mcp.open_file": "ファイルを開く", + "mcp.opening_label": "開いています…", + "mcp.pick_workspace_error": "最初にワークスペースフォルダを選択してください。", + "mcp.pick_workspace_first": "最初にワークスペースフォルダを選択してください。", + "mcp.quick_connect_chrome_desc": "ブラウザ自動化でChromeタブを操作。", + "mcp.quick_connect_chrome_title": "Chromeを操作", + "mcp.quick_connect_context7_desc": "より豊富なコンテキストで製品ドキュメントを検索。", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "スプリントを計画し、チケットをより速く処理。", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "ページ、データベース、プロジェクトドキュメントを同期。", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "リリースを追跡し、本番エラーを解決。", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "支払い、請求書、サブスクリプションを確認。", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "実行中のタスクを停止して有効化してください。", + "mcp.reload_banner_description": "有効化をタップしてアプリの接続を完了してください。", + "mcp.reload_banner_description_blocked": "タスクが実行中です。先に停止してから有効化してください。", + "mcp.remote_workspace_url_hint": "リモートワーカーはURLベースのMCPサーバーとの接続が最も速いです。", + "mcp.remove_app": "削除", + "mcp.remove_failed": "アプリを削除できませんでした。", + "mcp.remove_modal_message": "{server} を削除してもよろしいですか?後で再度追加できます。", + "mcp.remove_modal_title": "アプリを削除", + "mcp.reveal_config_failed": "設定ファイルを開けませんでした", + "mcp.reveal_in_finder": "Finderで表示", + "mcp.scope_global": "すべてのワークスペース", + "mcp.scope_project": "このワークスペース", + "mcp.server_command": "コマンド", + "mcp.server_command_hint": "サーバーを起動するシェルコマンド。", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "アプリ名", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "タイプ", + "mcp.server_url": "サーバーURL", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "サインイン", + "mcp.tap_to_connect": "タップして接続", + "mcp.technical_details": "技術的な詳細", + "mcp.type_cloud": "クラウド(アカウントでサインイン)", + "mcp.type_local": "ローカル(このデバイスで実行)", + "mcp.type_local_cmd": "ローカル(コマンド)", + "mcp.type_remote": "リモート(URL)", + "mcp.url_or_command_required": "リモートの場合はURL、ローカルの場合はコマンドを入力してください。", + "mcp.your_apps": "接続済みアプリ", + "message.tool_request_label": "リクエスト", + "message.tool_result_label": "結果", + "message.waiting_subagent": "サブエージェントのトランスクリプトの到着を待っています。", + "message_list.copy_message": "メッセージをコピー", + "message_list.open_session": "セッションを開く", + "message_list.step_updates_progress": "進捗を更新", + "message_list.subagent_loading_transcript": "トランスクリプトを読み込み中", + "message_list.subagent_message_count": "{count}件のメッセージ", + "message_list.subagent_running": "実行中", + "message_list.subagent_session_fallback": "サブエージェントセッション", + "message_list.subagent_type_task": "{agentType}タスク", + "message_list.subagent_waiting_transcript": "トランスクリプトを待機中", + "message_list.tool_checked_url": "{url}を確認", + "message_list.tool_checked_web_fallback": "Webページを確認", + "message_list.tool_delegate_agent": "{agent}に委任", + "message_list.tool_delegate_task_fallback": "タスクを委任", + "message_list.tool_load_skill_fallback": "スキルを読み込み", + "message_list.tool_load_skill_named": "スキル{name}を読み込み", + "message_list.tool_read_todo": "Todoリストを読み込み", + "message_list.tool_reviewed_file": "{file}をレビュー", + "message_list.tool_reviewed_file_fallback": "ファイルをレビュー", + "message_list.tool_reviewed_files_fallback": "ファイルをレビュー", + "message_list.tool_reviewed_path": "{path}をレビュー", + "message_list.tool_run_command": "{command}を実行", + "message_list.tool_run_command_fallback": "コマンドを実行", + "message_list.tool_searched_code_fallback": "コードを検索", + "message_list.tool_searched_pattern": "{pattern}を検索", + "message_list.tool_update_file": "{file}を更新", + "message_list.tool_update_file_fallback": "ファイルを更新", + "message_list.tool_update_todo": "Todoリストを更新", + "message_list.tool_updated_file": "{file}を更新済み", + "message_list.tool_updated_file_fallback": "ファイル更新済み", + "model_behavior.desc_builtin": "このモデルは独自の推論パスを決定し、ここではプロファイルを公開しません。", + "model_behavior.desc_generic": "{label}プロファイルを使用します。", + "model_behavior.desc_high": "回答前により多くの時間をかけて推論します。", + "model_behavior.desc_high_anthropic": "標準の拡張シンキングバジェットを使用します。", + "model_behavior.desc_low": "回答前に軽めの推論パスを実行します。", + "model_behavior.desc_low_google": "より軽い推論バジェットで素早く応答します。", + "model_behavior.desc_max": "プロバイダーの最も深い推論プロファイルを使用します。", + "model_behavior.desc_max_anthropic": "利用可能な最大の拡張シンキングバジェットを使用します。", + "model_behavior.desc_medium": "速度と推論の深さをバランスします。", + "model_behavior.desc_minimal": "ごく少量の推論を使用します。", + "model_behavior.desc_none": "最軽量の推論パスで速度を優先します。", + "model_behavior.desc_standard": "このモデルは追加の推論コントロールを公開していません。", + "model_behavior.label_balanced": "バランス", + "model_behavior.label_builtin": "ビルトイン", + "model_behavior.label_deep": "ディープ", + "model_behavior.label_extended": "拡張", + "model_behavior.label_fast": "高速", + "model_behavior.label_light": "ライト", + "model_behavior.label_maximum": "最大", + "model_behavior.label_quick": "クイック", + "model_behavior.label_standard": "スタンダード", + "model_behavior.title_builtin_reasoning": "組み込み推論", + "model_behavior.title_extended_thinking": "拡張シンキング", + "model_behavior.title_reasoning_budget": "推論バジェット", + "model_behavior.title_reasoning_effort": "推論エフォート", + "model_behavior.title_standard_generation": "標準生成", + "model_picker.chat_model_desc": "このチャットのモデルを選択します。モデルが推論プロファイルをサポートしている場合は、カードで設定します。", + "model_picker.chat_model_title": "チャットモデル", + "model_picker.connect_provider_hint": "このプロバイダーに接続してモデルを閲覧・保存", + "model_picker.default_model_desc": "新しいチャットのデフォルトモデルを選択し、その後、カードで推論プロファイルを微調整してから「完了」を押します。", + "model_picker.default_model_title": "デフォルトモデル", + "model_picker.model_count": "{count}件のモデル", + "model_picker.model_count_one": "1件のモデル", + "model_picker.more_providers": "さらにプロバイダー", + "model_picker.no_results": "検索に一致するモデルがありません。", + "model_picker.other_connected_models": "その他の接続済みモデル", + "model_picker.recommended": "おすすめ", + "onboarding.access_label": "アクセス", + "onboarding.add": "追加", + "onboarding.add_folder_path": "フォルダパスを追加", + "onboarding.advanced_settings": "詳細設定", + "onboarding.attach": "アタッチ", + "onboarding.attach_description": "このデバイスの既存セッションにアタッチします。", + "onboarding.authorize_folder": "フォルダを承認", + "onboarding.back": "戻る", + "onboarding.checking_cli": "OpenCode CLIを確認中…", + "onboarding.choose_workspace_folder": "ワークスペースフォルダを選択", + "onboarding.cli_checking": "インストールを確認中…", + "onboarding.cli_install_commands": "以下のコマンドでOpenCodeをインストールしてからOpenWorkを再起動してください。", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "OpenCode CLIのserve対応にはアップデートが必要です。", + "onboarding.cli_not_found": "OpenCode CLIが見つかりません。", + "onboarding.cli_not_found_hint": "見つかりません。ローカルサーバーの実行にはインストールが必要です。", + "onboarding.cli_ready": "OpenCode CLI準備完了。", + "onboarding.cli_recheck": "再確認", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "インストール済み", + "onboarding.create_first_workspace": "最初のワークスペースを作成", + "onboarding.create_workspace": "ワークスペースを作成", + "onboarding.engine_running": "エンジンは既に実行中です", + "onboarding.folders_allowed": "{count}個のフォルダが許可済み", + "onboarding.getting_ready": "準備しています", + "onboarding.install": "OpenCodeをインストール", + "onboarding.install_instruction": "ローカルサーバーを有効にするにはOpenCodeをインストールしてください(ターミナル不要)。", + "onboarding.last_checked": "最終確認 {time}", + "onboarding.manage_access_hint": "アクセスは詳細設定で管理できます。", + "onboarding.open_settings": "設定を開く", + "onboarding.open_settings_hint": "エンジンやアクセスのオプションが必要ですか?設定を開いてください。", + "onboarding.pick": "選択", + "onboarding.ready_message": "OpenCodeはローカルサーバーを起動する準備ができています。", + "onboarding.remember_choice": "次回のためにこの選択を記憶する", + "onboarding.remote_workspace_action": "接続", + "onboarding.remote_workspace_card_description": "OpenWorkサーバーに接続して共有ワークスペースにアクセスします。", + "onboarding.remote_workspace_card_title": "リモートワークスペースに接続", + "onboarding.remote_workspace_description": "OpenWorkサーバーに接続して、どこからでもワークスペースにアクセスできます。", + "onboarding.remote_workspace_title": "OpenWorkサーバーに接続", + "onboarding.remove": "削除", + "onboarding.resolved_path": "解決されたパス", + "onboarding.run_local": "ローカルで実行", + "onboarding.run_local_description": "OpenWorkはOpenCodeをローカルで実行し、作業をプライベートに保ちます。", + "onboarding.search_notes": "メモを検索", + "onboarding.searching_host": "OpenWorkサーバーに接続中…", + "onboarding.serve_help": "serve --helpの出力", + "onboarding.show_search_notes": "検索メモを表示", + "onboarding.start": "OpenWorkを開始", + "onboarding.starting_host": "OpenWorkサーバーを起動中…", + "onboarding.theme_current": "現在: {mode}", + "onboarding.theme_dark": "ダーク", + "onboarding.theme_label": "テーマ", + "onboarding.theme_light": "ライト", + "onboarding.theme_system": "システム", + "onboarding.verifying": "安全な接続を確認中", + "onboarding.version": "バージョン", + "onboarding.welcome_title": "今日のOpenWorkの使い方は?", + "onboarding.windows_install_instruction": "Windows版OpenCodeをインストールしてからOpenWorkを再起動してください。opencode.exeがPATHにあることを確認してください。", + "onboarding.workspace_folder_label": "ワークスペースは独自のスキル、プラグイン、コマンドを持つフォルダです。", + "plugins.add": "追加", + "plugins.add_hint": "npmパッケージ名を追加してください(例: opencode-wakatime)", + "plugins.add_label": "プラグインを追加", + "plugins.added": "追加済み", + "plugins.config": "設定", + "plugins.config_label": "設定", + "plugins.desc": "プロジェクトまたはグローバルのOpenCodeプラグインの`opencode.json`を管理します。", + "plugins.empty": "まだプラグインが設定されていません。", + "plugins.enabled": "有効", + "plugins.hide_setup": "セットアップを隠す", + "plugins.not_loaded": "まだ読み込まれていません", + "plugins.not_loaded_yet": "まだ読み込まれていません", + "plugins.remove": "削除", + "plugins.scope_global": "グローバル", + "plugins.scope_project": "プロジェクト", + "plugins.setup": "セットアップ", + "plugins.suggested": "おすすめのプラグイン", + "plugins.suggested_heading": "おすすめプラグイン", + "plugins.title": "OpenCodeプラグイン", + "providers.api_key_label": "APIキー", + "providers.api_key_required": "APIキーが必要です", + "providers.auth_failed": "認証に失敗しました", + "providers.connect_failed": "プロバイダーの接続に失敗しました", + "providers.disabled_in_config_suffix": "、OpenCode設定で無効にしました。", + "providers.disconnect_failed": "プロバイダーの切断に失敗しました", + "providers.disconnected_prefix": "切断済み", + "providers.load_failed": "プロバイダーの読み込みに失敗しました", + "providers.no_oauth_prefix": "OAuthフローがありません:", + "providers.no_providers_available": "利用可能なプロバイダーがありません", + "providers.not_connected": "サーバーに接続されていません", + "providers.not_oauth_flow_prefix": "選択された認証方法はOAuthフローではありません:", + "providers.oauth_failed": "OAuthの完了に失敗しました", + "providers.oauth_method_required": "OAuthメソッドが必要です", + "providers.provider_error": "プロバイダーエラー({provider})", + "providers.provider_id_required": "プロバイダーIDが必要です", + "providers.rate_limit_exceeded": "レートリミットを超過しました", + "providers.removal_unsupported": "プロバイダー認証の削除はこのクライアントではサポートされていません。", + "providers.request_failed": "リクエストに失敗しました", + "providers.save_api_key_failed": "APIキーの保存に失敗しました", + "providers.still_connected_suffix": "、ワーカーはまだ接続済みと報告しています。残存するAPIキーまたはOAuth認証情報をクリアし、ワーカーを再起動して完全に切断してください。", + "providers.unknown_provider": "不明なプロバイダー", + "providers.use_api_key_suffix": "代わりにAPIキーを使用してください。", + "question_modal.custom_answer_label": "またはカスタム回答を入力", + "question_modal.custom_answer_placeholder": "ここに回答を入力…", + "question_modal.question_counter": "質問{current}/{total}", + "session.allow_for_session": "セッション中許可", + "session.allow_once": "一度だけ許可", + "session.api_key_saved": "APIキーを保存しました", + "session.attachments_add_token": "ファイルを添付するにはサーバートークンを追加してください。", + "session.attachments_connect_server": "ファイルを添付するにはOpenWorkサーバーに接続してください。", + "session.back": "戻る", + "session.close_quick_actions": "クイックアクションを閉じる", + "session.close_search": "検索を閉じる", + "session.cmd_compact_detail": "このセッションのOpenCodeにコンパクト指示を送信", + "session.cmd_compact_detail_empty": "まだ圧縮するユーザーメッセージがありません", + "session.cmd_compact_meta": "圧縮", + "session.cmd_compact_title": "会話を圧縮", + "session.cmd_current_workspace": "現在のワークスペース", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "モデル", + "session.cmd_model_meta": "開く", + "session.cmd_model_title": "モデルを変更", + "session.cmd_new_session_detail": "現在のワークスペースで新しいタスクを開始", + "session.cmd_new_session_meta": "作成", + "session.cmd_new_session_title": "新規セッションを作成", + "session.cmd_provider_detail": "プロバイダー接続フローを開く", + "session.cmd_provider_meta": "開く", + "session.cmd_provider_title": "プロバイダーを接続", + "session.cmd_rename_detail_fallback": "選択したセッションにわかりやすい名前を付ける", + "session.cmd_rename_meta": "名前変更", + "session.cmd_rename_title": "現在のセッション名を変更", + "session.cmd_sessions_detail": "ワークスペース全体で{count}件利用可能", + "session.cmd_sessions_meta": "移動", + "session.cmd_sessions_title": "セッションを検索", + "session.cmd_switch": "切り替え", + "session.compacted": "セッションを圧縮しました", + "session.compacting": "セッションコンテキストを圧縮中…", + "session.compacting_auto": "OpenCodeがこのセッションを自動圧縮しています", + "session.compacting_manual": "OpenCodeがこのセッションを圧縮しています", + "session.compaction_finished": "OpenCodeがセッションコンテキストの圧縮を完了しました。", + "session.compaction_started": "OpenCodeがセッションコンテキストの圧縮を開始しました。", + "session.conflict_sync_toast": "{path}の同期で競合が発生しました。ローカルの変更を{conflictPath}に保存しました。", + "session.connect_failed": "接続に失敗しました", + "session.connect_to_sync": "リモートファイルを同期するにはOpenWorkサーバーに接続してください。", + "session.create_or_connect_workspace": "ワークスペースを作成または接続", + "session.create_workspace_desc": "ワークスペースクリエイターを開いて開始方法を選択します。", + "session.create_workspace_title": "ワークスペースを作成", + "session.default_agent": "デフォルトエージェント", + "session.default_title": "新規セッション", + "session.delete": "削除", + "session.delete_named_session_message": "「{title}」とそのメッセージを完全に削除します。", + "session.delete_session_generic": "選択したセッションとそのメッセージを完全に削除します。", + "session.delete_session_title": "セッションを削除しますか?", + "session.deleted": "セッションを削除しました", + "session.deleting": "削除中…", + "session.deny": "拒否", + "session.details": "詳細", + "session.details_label": "詳細", + "session.doom_loop_label": "ドゥームループ", + "session.doom_loop_message": "OpenCodeが同一入力でのツール呼び出しの繰り返しを検出しました。繰り返し失敗した後、続行するか確認しています。", + "session.doom_loop_note": "ループを停止するには拒否、エージェントに再試行させるには許可を選択してください。", + "session.doom_loop_repeated_call_label": "繰り返しの呼び出し", + "session.doom_loop_repeated_tool_call": "繰り返しのツール呼び出し", + "session.doom_loop_title": "ドゥームループを検出", + "session.doom_loop_tool_label": "ツール", + "session.downloading": "ダウンロード中", + "session.downloading_percent": "ダウンロード中 {percent}%", + "session.downloading_update_title": "アップデート{version}をダウンロード中", + "session.export_already_running": "エクスポートは既に実行中です。", + "session.export_desktop_only": "エクスポートはデスクトップアプリで利用可能です。", + "session.export_desktop_only_local": "エクスポートはデスクトップアプリのローカルワーカーで利用可能です。", + "session.export_local_only": "エクスポートはローカルワーカーでのみサポートされています。", + "session.failed_to_compact": "セッションの圧縮に失敗しました", + "session.failed_to_create_session": "セッションの作成に失敗しました", + "session.failed_to_delete": "セッションの削除に失敗しました", + "session.failed_to_load_agents": "エージェントの読み込みに失敗しました", + "session.failed_to_load_providers": "プロバイダーの読み込みに失敗しました", + "session.failed_to_redo": "やり直しに失敗しました", + "session.failed_to_save_api_key": "APIキーの保存に失敗しました", + "session.failed_to_stop": "停止に失敗しました", + "session.failed_to_undo": "元に戻す操作に失敗しました", + "session.file_open_desktop_only": "ファイルを開く機能はデスクトップアプリで利用可能です。", + "session.file_open_failed": "ファイルを開けませんでした", + "session.file_open_remote_unavailable": "リモートワークスペースではファイルを開く機能は利用できません。", + "session.flyout_file_modified": "ファイルが変更されました", + "session.flyout_new_task": "新しいタスク", + "session.install_update": "アップデートをインストール", + "session.jump_to_latest": "最新に移動", + "session.jump_to_start": "メッセージの先頭に移動", + "session.load_earlier": "以前のメッセージを読み込む", + "session.loading_detail": "このタスクの最新メッセージを取得しています。", + "session.loading_earlier": "以前のメッセージを読み込み中…", + "session.loading_session": "セッションを読み込み中", + "session.loading_title": "セッションを読み込み中", + "session.menu_label": "メニュー", + "session.model": "モデル", + "session.model_fallback": "モデル", + "session.new_task": "新しいタスク", + "session.next_match": "次の一致", + "session.no_matches": "一致なし", + "session.no_matches_command": "一致するものがありません。", + "session.no_session_selected": "セッションが選択されていません", + "session.nothing_to_compact": "まだ圧縮するものがありません。", + "session.nothing_to_redo": "やり直すものがありません。", + "session.nothing_to_retry": "再試行するものがまだありません", + "session.nothing_to_undo": "元に戻すものがまだありません。", + "session.oauth_failed": "OAuthに失敗しました", + "session.obsidian_worker_relative_only": "Obsidianで開けるのはワーカー相対パスのファイルのみです。", + "session.open": "開く", + "session.palette_hint_navigate": "矢印キーで移動", + "session.palette_hint_run": "Enterで実行 · Escで閉じる", + "session.palette_placeholder_actions": "アクションを検索", + "session.palette_placeholder_sessions": "セッション名またはワークスペースで検索", + "session.palette_title_actions": "クイックアクション", + "session.palette_title_sessions": "セッションを検索", + "session.permission_label": "権限", + "session.permission_detail_command": "コマンド", + "session.permission_detail_cwd": "作業ディレクトリ", + "session.permission_detail_description": "説明", + "session.permission_detail_diff": "差分", + "session.permission_detail_file": "ファイル", + "session.permission_detail_files": "ファイル", + "session.permission_detail_agent": "エージェント", + "session.permission_detail_parent_directory": "親ディレクトリ", + "session.permission_detail_path": "パス", + "session.permission_detail_query": "クエリ", + "session.permission_detail_target": "対象", + "session.permission_detail_tool": "ツール", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "ファイル編集", + "session.permission_kind_external_directory": "外部ディレクトリ", + "session.permission_kind_question": "質問", + "session.permission_kind_read": "ファイル読み取り", + "session.permission_kind_skill": "スキル", + "session.permission_kind_task": "サブタスク", + "session.permission_kind_todowrite": "Todo書き込み", + "session.permission_message": "OpenCodeが続行するために許可を求めています。", + "session.permission_message_bash": "OpenCodeの続行を許可する前に、コマンドのスコープを確認してください。", + "session.permission_message_edit": "OpenCodeに変更を許可する前に、ファイルと差分を確認してください。", + "session.permission_message_external_directory": "ワークスペース外へのアクセスを許可する前に、フォルダを確認してください。", + "session.permission_message_read": "アクセスを許可する前に、要求されたファイルスコープを確認してください。", + "session.permission_message_task": "開始を許可する前に、要求されたサブタスクを確認してください。", + "session.permission_metadata_unavailable": "メタデータを表示できませんでした。", + "session.permission_required": "権限が必要です", + "session.permission_review_label": "確認", + "session.permission_scope_empty": "具体的なスコープはありません。", + "session.permission_title_bash": "シェルコマンドを実行しますか?", + "session.permission_title_edit": "ファイルを変更しますか?", + "session.permission_title_external_directory": "外部フォルダにアクセスしますか?", + "session.permission_title_generic": "{permission}を承認しますか?", + "session.permission_title_read": "ファイルを読み取りますか?", + "session.permission_title_task": "サブタスクを開始しますか?", + "session.permission_decision_hint": "このリクエストだけなら一度だけ許可し、このスコープを信頼できる場合はセッション中許可してください。", + "session.phase_responding": "応答中", + "session.phase_retrying": "再試行中", + "session.phase_run_failed": "実行に失敗しました", + "session.phase_sending": "送信中", + "session.pick_folder_desc": "既存のプロジェクトまたはメモフォルダを選択すると、OpenWorkがワークスペースとして使用します。", + "session.pick_folder_title": "作業したいフォルダを選択", + "session.pick_workspace_to_open": "ファイルを開くにはワークスペースを選択してください。", + "session.prev_match": "前の一致", + "session.provider_auth_in_progress": "プロバイダー認証が既に進行中です。", + "session.provider_connected": "プロバイダーが接続されました", + "session.quick_actions_label": "クイックアクション", + "session.quick_actions_title": "クイックアクション(Ctrl/Cmd+K)", + "session.redo_aria_label": "最後に取り消したメッセージをやり直す", + "session.redo_label": "やり直し", + "session.redo_title": "最後に取り消したメッセージをやり直す", + "session.remote_sync_failed": "リモートファイルの同期に失敗しました", + "session.rename_description": "このセッションの名前を更新します。", + "session.rename_label": "セッション名", + "session.rename_placeholder": "新しい名前を入力", + "session.rename_title": "セッション名を変更", + "session.resize_workspace_column": "ワークスペース列のサイズを変更", + "session.restart_update_title": "アップデート{version}を適用するには再起動してください", + "session.restored_message": "取り消したメッセージを復元しました。", + "session.reveal": "表示", + "session.reveal_desktop_only": "表示機能はデスクトップアプリで利用可能です。", + "session.revert_label": "元に戻す", + "session.reverted_last_message": "最後のユーザーメッセージを取り消しました。", + "session.run": "実行", + "session.scope_label": "スコープ", + "session.search_conversation_label": "会話を検索", + "session.search_conversation_title": "会話を検索(Ctrl/Cmd+F)", + "session.search_next": "次へ", + "session.search_placeholder": "このチャット内を検索", + "session.search_position": "{current}/{total}", + "session.search_prev": "前へ", + "session.share_active_cloud_org": "アクティブなCloud組織", + "session.share_choose_org": "チームと共有する前に設定→Cloudで組織を選択してください。", + "session.share_collaborator_hint": "オーナー専用アクションが不要な通常のリモートアクセス。", + "session.share_collaborator_host_hint": "オーナー専用アクションなしのこのホストへの通常のリモートアクセス。", + "session.share_collaborator_label": "コラボレータートークン", + "session.share_collaborator_token": "コラボレータートークン", + "session.share_connected_with_hint": "このワークスペースは現在このパスワードで接続されています。", + "session.share_desktop_app_required": "デスクトップアプリが必要です", + "session.share_desktop_required": "デスクトップアプリが必要です", + "session.share_host_url_and_token_required": "OpenWorkホストのURLとトークンが必要です。", + "session.share_local_host_not_ready": "ローカルOpenWorkホストがまだ準備できていません。", + "session.share_missing_host_url": "OpenWorkホストのURLがありません。", + "session.share_missing_token": "OpenWorkトークンがありません。", + "session.share_no_skills": "このワークスペースにはスキルが見つかりません。", + "session.share_note_direct_runtime": "エンジンランタイムがダイレクトに設定されています。ローカルワーカーの切り替えでホストが再起動しクライアントが切断される場合があります。再起動後にトークンが変更される可能性があります。", + "session.share_opencode_base_url": "OpenCodeベースURL", + "session.share_openwork_workers_only": "共有サービスリンクはOpenWorkワーカーで利用可能です。", + "session.share_owner_permission_hint": "リモートクライアントが許可プロンプトに応答する必要がある場合に使用します。", + "session.share_password": "パスワード", + "session.share_password_owner_hint": "リモートクライアントが許可プロンプトに応答する必要がある場合に使用します。", + "session.share_publish_skills_failed": "スキルセットの公開に失敗しました", + "session.share_publish_workspace_failed": "ワークスペースプロファイルの公開に失敗しました", + "session.share_resolve_local_workspace_failed": "ローカルOpenWorkホストでこのワークスペースを解決できませんでした。", + "session.share_resolve_remote_workspace_failed": "OpenWorkホストでこのワークスペースを解決できませんでした。", + "session.share_save_team_template_failed": "チームテンプレートの保存に失敗しました", + "session.share_saved_to_org": "{name}を{org}に保存しました。", + "session.share_select_workspace": "最初にワークスペースを選択してください。", + "session.share_set_token_hint": "ワークスペース設定でトークンを設定", + "session.share_sign_in_required": "チームと共有するには設定のOpenWork Cloudでサインインしてください。", + "session.share_skills_set_desc": "OpenWorkワークスペースの完全なスキルセット。", + "session.share_starting_server": "サーバーを起動中…", + "session.share_team_fallback_name": "チームテンプレート", + "session.share_url_resolving_hint": "ワーカーURLを解決中。フォールバックとしてホストURLを表示しています。", + "session.share_url_worker_hint": "このワーカーに接続するスマートフォンやノートPCで使用します。", + "session.share_worker_url": "ワーカーURL", + "session.share_worker_url_phones_hint": "このワーカーに接続するスマートフォンやノートPCで使用します。", + "session.share_worker_url_resolving_hint": "ワーカーURLを解決中。フォールバックとしてホストURLを表示しています。", + "session.shared_folder_upload_failed": "共有フォルダへのアップロードに失敗しました", + "session.show_earlier": "以前の{count}件のメッセージを表示", + "session.status_active": "セッション稼働中", + "session.status_compacting": "コンテキストを圧縮中", + "session.status_delegating": "委任中", + "session.status_gathering_context": "コンテキストを収集中", + "session.status_planning": "計画中", + "session.status_ready": "準備完了", + "session.status_ready_session": "セッション準備完了", + "session.status_running_shell": "シェルを実行中", + "session.status_searching_codebase": "コードベースを検索中", + "session.status_searching_web": "Webを検索中", + "session.status_thinking": "考え中", + "session.status_working": "作業中", + "session.status_writing_file": "ファイルを書き込み中", + "session.stopped": "停止しました", + "session.stopping_run": "実行を停止中…", + "session.todo_progress": "{total}件中{completed}件のタスクが完了", + "session.trying_again": "再試行中…", + "session.unable_to_open_file": "ファイルを開けません", + "session.unable_to_open_obsidian": "Obsidianでファイルを開けません", + "session.unable_to_reveal": "ワークスペースを表示できません", + "session.undo_label": "元に戻す", + "session.undo_title": "最後のメッセージを元に戻す", + "session.update_available": "アップデートがあります", + "session.update_available_title": "アップデート{version}が利用可能", + "session.update_ready": "アップデート準備完了", + "session.update_ready_stop_runs_title": "アップデート{version}準備完了。再起動するにはアクティブな実行を停止してください。", + "session.upload_connect_server": "共有フォルダにファイルをアップロードするにはOpenWorkサーバーに接続してください。", + "session.uploaded_to_shared_folder": "共有フォルダにアップロードしました。", + "session.uploaded_with_summary": "共有フォルダにアップロードしました: {summary}", + "session.uploading_to_shared_folder": "{label}を共有フォルダにアップロード中…", + "session.workspace_fallback": "ワークスペース", + "session.workspace_label": "ワークスペース", + "session.workspace_path_unavailable": "ワークスペースのパスが利用できません。", + "session.workspace_setup_desc": "ガイド付きOpenWorkワークスペースで開始するか、作業したい既存のフォルダを選択してください。", + "session.workspace_setup_label": "ワークスペースセットアップ", + "session.workspace_setup_title": "最初のワークスペースをセットアップ", + "settings.action_download": "ダウンロード", + "settings.action_install": "インストール", + "settings.actor_host": "ホスト", + "settings.actor_remote": "リモート", + "settings.actor_unknown": "不明", + "settings.advanced": "詳細設定", + "settings.advanced_title": "詳細設定", + "settings.api_keys_info": "APIキーはOpenCodeによってローカルに保存されます。環境に基づくプロバイダーはワーカー環境で変更してからリロードしてください。", + "settings.appearance_hint": "システムに合わせるか、ライト/ダークモードを強制します。", + "settings.appearance_title": "外観", + "settings.audit_error": "エラー", + "settings.audit_loading": "読み込み中", + "settings.audit_log_title": "監査ログ", + "settings.audit_ready": "準備完了", + "settings.auto_compact": "自動コンテキスト圧縮", + "settings.auto_compact_desc": "このワークスペースのOpenCode compaction.autoを制御します。変更後にエンジンをリロードしてください。", + "settings.auto_update_desc": "アップデートを自動的にダウンロードします(インストール前に確認)。", + "settings.auto_update_title": "自動アップデート", + "settings.available_count": "{count}件利用可能", + "settings.background_checks_desc": "OpenWorkは起動時に常にチェックします。また1日1回バックグラウンドでチェックします。", + "settings.background_checks_title": "バックグラウンドチェック", + "settings.base_url_unavailable": "ベースURLが利用できません", + "settings.binary_unavailable": "バイナリが利用できません", + "settings.cache_nothing_to_repair": "OpenCodeキャッシュが見つかりません。修復の必要はありません。", + "settings.cache_repair_requires_desktop": "キャッシュの修復にはデスクトップアプリが必要です", + "settings.cache_repaired": "OpenCodeキャッシュを修復しました。エンジンが実行中だった場合は再起動してください。", + "settings.cap_browser_tools": "ブラウザツール: {value}", + "settings.cap_commands": "コマンド: {value}", + "settings.cap_config": "設定: {value}", + "settings.cap_file_tools": "ファイルツール: {value}", + "settings.cap_inbox_off": "インボックスオフ", + "settings.cap_inbox_on": "インボックスオン", + "settings.cap_mcp": "MCP: {value}", + "settings.cap_outbox_off": "アウトボックスオフ", + "settings.cap_outbox_on": "アウトボックスオン", + "settings.cap_plugins": "プラグイン: {value}", + "settings.cap_read": "読み取り", + "settings.cap_sandbox": "サンドボックス: {value}", + "settings.cap_skills": "スキル: {value}", + "settings.cap_write": "書き込み", + "settings.capabilities_title": "OpenWorkサーバー機能", + "settings.capabilities_unavailable": "機能が利用できません。クライアントトークンで接続してください。", + "settings.change": "変更", + "settings.check_update": "確認", + "settings.checking_for_updates": "アップデートを確認中", + "settings.choose": "選択", + "settings.clear": "クリア", + "settings.clipboard_unavailable": "この環境ではクリップボードが利用できません。", + "settings.configure": "設定", + "settings.connect_opencode_hint": "プロバイダーを読み込むにはOpenCodeに接続してください。", + "settings.connect_provider": "プロバイダーを接続", + "settings.connected_count": "{count}件接続済み", + "settings.connection": "接続", + "settings.connection_failed": "接続に失敗しました", + "settings.connection_title": "接続", + "settings.copied_debug_report": "ランタイムレポートJSONをコピーしました。", + "settings.copy_failed": "ランタイムレポートのコピーに失敗しました。", + "settings.copy_json": "JSONをコピー", + "settings.custom_binary_hint": "ローカルのOpenCodeビルドを指定するために使用します", + "settings.custom_binary_label": "カスタムOpenCodeバイナリ", + "settings.data_dir_unavailable": "データディレクトリが利用できません", + "settings.debug_commit": "コミット: {sha}", + "settings.debug_desktop_app": "デスクトップアプリ: {version}", + "settings.debug_opencode_version": "OpenCode: {version}", + "settings.debug_openwork_server_version": "OpenWorkサーバー: {version}", + "settings.debug_section_title": "デベロッパー", + "settings.deeplink_failed": "ディープリンクを開けませんでした。", + "settings.deeplink_hint": "openwork://、openwork-dev://、またはhttps://share.openworklabs.com/b/...のURLを受け付けます。", + "settings.default_model": "デフォルトモデル", + "settings.delete_containers": "コンテナを削除中…", + "settings.delete_local_config": "ローカル状態を削除中…", + "settings.desktop_only_hint": "デスクトップアプリで利用可能です。", + "settings.dev_mode_badge": "デベロッパーモード", + "settings.developer": "デベロッパー", + "settings.developer_mode_desc": "デバッグツール、ダイアグノスティクス、デベロッパータブを有効にします。", + "settings.developer_mode_title": "デベロッパーモード", + "settings.developer_panel_disabled": "デベロッパーパネルが無効になりました。", + "settings.developer_panel_enabled": "デベロッパーパネルが有効になりました。", + "settings.devlog_cleared": "デベロッパーログ出力をクリアしました。", + "settings.devlog_clipboard_unavailable": "この環境ではクリップボードを使用できません。", + "settings.devlog_copied": "デベロッパーログ出力をコピーしました。", + "settings.devlog_copy_failed": "デベロッパーログ出力のコピーに失敗しました。", + "settings.devlog_export_failed": "デベロッパーログ出力のエクスポートに失敗しました。", + "settings.devlog_export_unavailable": "この環境ではエクスポートを使用できません。", + "settings.devlog_exported": "デベロッパーログ出力をエクスポートしました。", + "settings.devtools_desc": "サイドカーのヘルス、機能、監査証跡。", + "settings.devtools_title": "デベロッパーツール", + "settings.diag_approval": "承認: {mode}({ms}ms)", + "settings.diag_config_path": "設定パス: {path}", + "settings.diag_daemon_url": "デーモン: {url}", + "settings.diag_default": "デフォルト", + "settings.diag_health_port": "ヘルスポート: {port}", + "settings.diag_healthy_ms": "ヘルシー: {ms}ms", + "settings.diag_host_token_source": "ホストトークンソース: {source}", + "settings.diag_last_attempt": "最終試行: {time}", + "settings.diag_load_sessions_ms": "セッション読み込み: {ms}ms", + "settings.diag_opencode_binary": "OpenCodeバイナリ: {binary}", + "settings.diag_opencode_url": "OpenCode: {url}", + "settings.diag_pending_permissions_ms": "保留中の権限: {ms}ms", + "settings.diag_pid": "PID: {pid}", + "settings.diag_providers_ms": "プロバイダー: {ms}ms", + "settings.diag_read_only": "読み取り専用: {value}", + "settings.diag_reason": "理由: {reason}", + "settings.diag_runtime_workspace": "ランタイムワークスペース: {id}", + "settings.diag_selected_workspace": "選択されたワークスペース: {id}", + "settings.diag_sidecar": "サイドカー: {info}", + "settings.diag_started": "開始: {time}", + "settings.diag_token_source": "トークンソース: {source}", + "settings.diag_total_ms": "合計: {ms}ms", + "settings.diag_version": "バージョン: {version}", + "settings.diag_workspaces": "ワークスペース: {count}", + "settings.diagnostics_unavailable": "診断が利用できません。", + "settings.disable_developer_mode": "デベロッパーモードを無効化", + "settings.disabled": "無効", + "settings.disconnect": "切断", + "settings.disconnect_confirm_suffix": "{resolved}を切断しますか?このプロバイダーの保存済みAPIキーまたはOAuth認証情報が削除されます。", + "settings.disconnect_server": "サーバーを切断", + "settings.disconnected_prefix": "{resolved}を切断しました。", + "settings.disconnecting": "切断中…", + "settings.docker_containers_desc": "OpenWorkによって起動されたDockerコンテナを強制削除します。", + "settings.docker_containers_title": "OpenWork Dockerコンテナ", + "settings.docker_requires_desktop": "Dockerクリーンアップにはデスクトップアプリが必要です。", + "settings.done": "完了", + "settings.downloading_bytes": "{downloaded}をダウンロード中", + "settings.downloading_progress": "{downloaded}/{total}をダウンロード中({percent}%)", + "settings.enable_developer_mode": "デベロッパーモードを有効化", + "settings.enable_exa": "Exa ウェブ検索を有効化", + "settings.enable_exa_desc": "OpenWork OrchestratorがOpenCodeを起動する際に適用されます。", + "settings.enabled": "有効", + "settings.engine_bundled": "バンドル版(推奨)", + "settings.engine_bundled_hint": "バンドルエンジンが最も安定した選択肢です。システム", + "settings.engine_custom_binary": "カスタムバイナリ", + "settings.engine_desc": "OpenCodeのローカル実行方法を選択します。", + "settings.engine_runtime_label": "エンジンランタイム", + "settings.engine_source": "エンジンソース", + "settings.engine_source_debug": "エンジンソース", + "settings.engine_system_path": "システムインストール(PATH)", + "settings.engine_title": "エンジン", + "settings.environment.add_button": "変数を追加", + "settings.environment.add_title": "環境変数を追加", + "settings.environment.cancel": "キャンセル", + "settings.environment.click_to_edit": "クリックして編集", + "settings.environment.confirm_delete": "{key} を削除しますか?変更を適用すると、エージェントから見えなくなります。", + "settings.environment.close_editor": "エディターを閉じる", + "settings.environment.delete": "削除", + "settings.environment.delete_title": "環境変数を削除", + "settings.environment.delete_variable": "{key} を削除", + "settings.environment.deleting": "削除中…", + "settings.environment.description": "ローカルのエージェント、skills、MCP servers が使う API キーやトークンを保存します。シークレットはこのデバイスに保持され、変更を適用すると利用可能になります。", + "settings.environment.edit_title": "環境変数を編集", + "settings.environment.empty_body": "ANTHROPIC_API_KEY、GOOGLE_API_KEY、ELEVENLABS_API_KEY、GITHUB_TOKEN など、エージェントや MCP servers が必要とするサービスキーを追加します。", + "settings.environment.empty_title": "環境変数はまだありません", + "settings.environment.empty_value": "(空)", + "settings.environment.footer_hint": "OPENWORK_ / OPENCODE_ キーはアプリとランタイムの内部配線用です。OpenCode のランタイム設定はシェルで指定してください。", + "settings.environment.override_hint": "OpenWork 起動前に設定済みの環境変数は、ここで保存した値より優先されます。", + "settings.environment.hide": "隠す", + "settings.environment.hide_value": "{key} の値を隠す", + "settings.environment.key_hint": "英字・数字・アンダースコアのみ。数字で始められません。", + "settings.environment.key_label": "名前", + "settings.environment.loading": "読み込み中…", + "settings.environment.remote_workspace_hint": "このワークスペースはリモートです。ここではローカル環境変数を表示・編集しません。クラウドの LLM Providers を使うか、worker ホストを直接設定してください。", + "settings.environment.apply_button": "変更を適用", + "settings.environment.apply_blocked_active_tasks": "環境変数の変更を適用する前に、実行中のタスクを停止してください。", + "settings.environment.apply_confirm_body": "OpenWork はローカルエージェントを再起動し、最新の環境変数を使えるようにします。実行中のローカルタスクは停止する場合があります。", + "settings.environment.apply_no_local_workspace": "OpenWork はローカルワークスペースに接続されていません。", + "settings.environment.apply_pending_body": "変更を適用してローカルエージェントを再起動すると、最新の値が利用可能になります。", + "settings.environment.apply_pending_body_manual": "ローカルエージェントを再起動すると、最新の値が利用可能になります。", + "settings.environment.apply_pending_title": "変更は保存済みですが、まだ有効ではありません", + "settings.environment.apply_refresh_failed": "変更は有効になりましたが、OpenWork の状態を更新できませんでした。表示が古い場合はアプリを開き直してください。", + "settings.environment.apply_success": "環境変数の変更が有効になりました。", + "settings.environment.apply_title": "環境変数の変更を適用しますか?", + "settings.environment.apply_unavailable": "変更の適用はデスクトップアプリでのみ利用できます。", + "settings.environment.applying": "適用中…", + "settings.environment.restart_required": "保存しました。変更を適用すると有効になります。", + "settings.environment.reveal": "表示", + "settings.environment.reveal_value": "{key} の値を表示", + "settings.environment.save": "保存", + "settings.environment.saving": "保存中…", + "settings.environment.title": "環境変数", + "settings.environment.validation_duplicate": "同じ名前の変数がすでに存在します。", + "settings.environment.validation_empty": "名前は必須です。", + "settings.environment.validation_reserved": "OPENWORK_ / OPENCODE_ の名前は OpenWork/OpenCode が管理します。", + "settings.environment.validation_shape": "英字・数字・アンダースコアを使用してください。数字で始められません。", + "settings.environment.value_label": "値", + "settings.exa_restart_hint": "この設定を変更した後、OpenCodeまたはオーケストレーターを再起動してください。", + "settings.export": "エクスポート", + "settings.export_failed": "ランタイムレポートのエクスポートに失敗しました。", + "settings.export_unavailable": "この環境ではエクスポートが利用できません。", + "settings.exported_debug_report": "ランタイムレポートJSONをエクスポートしました。", + "settings.failed": "失敗", + "settings.failed_open_providers": "プロバイダーを開けませんでした", + "settings.feedback_badge": "すべてのメッセージを読んでいます", + "settings.feedback_desc": "良い点や改善点を教えてください。フィードバックはチームに直接届き、次に何をリリースするかの優先順位付けに役立ちます。", + "settings.feedback_title": "OpenWorkを改善する", + "settings.group_global": "グローバル", + "settings.group_workspace": "ワークスペース", + "settings.hide_titlebar": "タイトルバーを非表示", + "settings.hide_titlebar_desc": "ウィンドウのタイトルバーを非表示にします。タイル型ウィンドウマネージャーに便利です。", + "settings.join_discord": "Discordに参加", + "settings.language": "言語", + "settings.language.description": "使用する言語を選択してください", + "settings.last_error": "最後のエラー", + "settings.last_stderr": "最後のstderr", + "settings.last_stdout": "最後のstdout", + "settings.loading_providers": "プロバイダーを読み込み中…", + "settings.logs_on_host": "ログはホストで確認できます。", + "settings.managed_by_env": "環境変数で管理", + "settings.messaging_bridge_service": "メッセージングブリッジサービス。", + "settings.messaging_section_desc": "「アイデンティティ」タブでTelegram/Slackアイデンティティとバインディングを管理します。", + "settings.messaging_section_title": "メッセージング", + "settings.model": "モデル", + "settings.model_behavior": "モデル動作", + "settings.model_behavior_desc": "デフォルトモデルピッカーを開いて、利用可能な場合に推論プロファイルを選択します。", + "settings.model_default": "デフォルト", + "settings.model_description": "実行のデフォルトとシンキング設定。", + "settings.model_description_default": "設定済みのプロバイダーから選択してください。この選択は新しいセッションに使用されます。", + "settings.model_description_session": "設定済みのプロバイダーから選択してください。この選択は次のメッセージに適用されます。", + "settings.model_fallback": "フォールバック", + "settings.model_reasoning": "推論", + "settings.model_section_desc": "デフォルトのチャットモデルを選択し、推論方法を確認します。", + "settings.model_title": "モデル", + "settings.no_access": "アクセスなし", + "settings.no_active_workspace": "アクティブなローカルワークスペースがありません。", + "settings.no_audit_entries": "まだ監査エントリがありません。", + "settings.no_binary_selected": "バイナリが選択されていません。", + "settings.no_custom_path_set": "カスタムパスが設定されていません", + "settings.no_project_directory": "プロジェクトディレクトリなし", + "settings.no_stderr": "まだstderrがキャプチャされていません。", + "settings.no_stdout": "まだstdoutがキャプチャされていません。", + "settings.no_worker_directory": "プロジェクトディレクトリなし", + "settings.no_worker_path": "ワーカーパスが利用できません", + "settings.nuke_confirm_dev": "この操作は元に戻せません。この開発ビルドのすべてのOpenWorkデータおよびすべてのOpenCode開発設定、認証、キャッシュ、データ、状態が削除され、その後OpenWorkが終了します。続行しますか?", + "settings.nuke_confirm_prod": "この操作は元に戻せません。この開発ビルドのすべてのOpenWorkデータおよびすべてのOpenCode開発設定、認証、キャッシュ、データ、状態が削除され、その後OpenWorkが終了します。続行しますか?", + "settings.nuke_failed": "OpenWorkとOpenCodeの状態の削除に失敗しました。", + "settings.nuke_hint": "デスクトップアプリとそのOpenCodeランタイム状態を完全にリセットする場合にのみ使用してください。", + "settings.nuke_success": "OpenWorkとOpenCodeの状態を削除しました。OpenWorkを終了しています…", + "settings.off": "オフ", + "settings.offline": "オフライン", + "settings.on": "オン", + "settings.open_deeplink_action": "開いています…", + "settings.open_deeplink_button": "開く", + "settings.open_deeplink_desc": "OpenWorkのディープリンクまたは共有URLを貼り付けて開きます。", + "settings.open_deeplink_title": "ディープリンクを開く", + "settings.opencode_cache": "OpenCodeキャッシュ", + "settings.opencode_cache_description": "エンジンの起動に使用されるキャッシュデータを修復します。安全に実行できます。", + "settings.opencode_engine_desc": "エージェント、ツール、モデルプロバイダーのローカルランタイム。", + "settings.opencode_engine_label": "OpenCodeエンジン", + "settings.opencode_engine_sidecar_desc": "ローカル実行サイドカー。", + "settings.opencode_sdk_desc": "UI接続診断。", + "settings.opencode_sdk_title": "OpenCodeエンジン", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "ベースURLが利用できません", + "settings.opening": "開いています…", + "settings.openwork_config_sidecar_desc": "設定と承認のサイドカー。", + "settings.openwork_diagnostics_title": "OpenWorkサーバー診断", + "settings.openwork_server_desc": "アプリ同期、ワーカー、リモート接続のセッションコントロールプレーン。", + "settings.openwork_server_label": "OpenWorkサーバー", + "settings.pending_permissions": "保留中の権限", + "settings.production_mode_badge": "プロダクション", + "settings.provider_default_desc": "モデルの組み込みデフォルト推論動作を使用します。", + "settings.provider_default_label": "プロバイダーのデフォルト", + "settings.provider_source_config": "設定", + "settings.provider_source_custom": "カスタム", + "settings.provider_source_env": "環境変数", + "settings.providers_desc": "モデルとツール用のサービスを接続します。", + "settings.providers_title": "プロバイダー", + "settings.quit_hint": "OpenWorkはクリーンアップ後すぐに終了し、次回起動時にこのモードのクリーンなローカル状態から開始します。", + "settings.recent_events": "最近のイベント", + "settings.reconnect_failed": "再接続に失敗しました。サーバーURL/トークンを確認して再試行してください。", + "settings.reconnect_server": "再接続中…", + "settings.reconnect_server_failed": "OpenWorkサーバーへの再接続に失敗しました。", + "settings.reconnected": "OpenWorkサーバーに再接続しました。", + "settings.reconnecting": "再接続中…", + "settings.removing_containers": "コンテナを削除中…", + "settings.removing_local_state": "ローカル状態を削除中…", + "settings.repair_cache": "キャッシュを修復", + "settings.repairing_cache": "キャッシュ修復中", + "settings.report_issue": "問題を報告", + "settings.reset": "リセット", + "settings.reset_app_data": "アプリデータをリセット", + "settings.reset_app_data_description": "より強力なリセット。OpenWorkのキャッシュとアプリデータをクリアします。", + "settings.reset_app_data_title": "アプリデータをリセット", + "settings.reset_app_data_warning": "このデバイスのOpenWorkキャッシュとアプリデータをクリアします。", + "settings.reset_button": "リセット", + "settings.reset_cancel": "キャンセル", + "settings.reset_config_defaults": "リセット中…", + "settings.reset_config_failed": "アプリ設定のリセットに失敗しました。", + "settings.reset_confirm_button": "リセットして再起動", + "settings.reset_confirmation_hint": "確認のため {resetWord} と入力してください。OpenWorkが再起動されます。", + "settings.reset_confirmation_label": "確認", + "settings.reset_confirmation_placeholder": "RESETと入力", + "settings.reset_onboarding": "オンボーディングをリセット", + "settings.reset_onboarding_description": "OpenWorkの設定をクリアしてアプリを再起動します。", + "settings.reset_onboarding_title": "オンボーディングをリセット", + "settings.reset_onboarding_warning": "OpenWorkのローカル設定とワークスペースのオンボーディングマーカーをクリアします。", + "settings.reset_openwork_desc_dev": "開発モードが有効な場合、openwork-dev-data内のOpenCode開発状態のみをクリアします。", + "settings.reset_openwork_desc_prod": "開発モードが有効な場合、openwork-dev-data内のOpenCode開発状態のみをクリアします。", + "settings.reset_openwork_title": "OpenWork + OpenCodeの状態をリセット", + "settings.reset_recovery_desc": "データをクリアするか、セットアップフローを再実行します。", + "settings.reset_recovery_title": "リセットとリカバリー", + "settings.reset_requires_confirm": "RESETの入力が必要で、アプリが再起動します。", + "settings.reset_startup": "デフォルト起動モードをリセット", + "settings.reset_startup_pref": "起動設定をリセット", + "settings.reset_stop_active_runs": "リセットする前にアクティブな実行を停止してください。", + "settings.resetting": "リセット中…", + "settings.restart_blocked_message": "このアップデートを完了するにはOpenWorkの再起動が必要です。作業を中断しないため、アクティブな実行が終了するか停止するまでインストールは一時停止しています。", + "settings.restart_failed": "再起動に失敗しました。ログを確認してもう一度試してください。", + "settings.restart_opencode": "再起動中…", + "settings.restart_openwork_server": "OpenWorkサーバーを再起動中…", + "settings.restart_server_failed": "ローカルサーバーの再起動に失敗しました。", + "settings.restarted": "ローカルサーバーを再起動しました。", + "settings.restarting": "再起動中…", + "settings.reveal_config": "設定ファイルを開く", + "settings.reveal_config_failed": "ワークスペース設定の表示に失敗しました。", + "settings.reveal_config_requires_desktop": "設定の表示にはデスクトップアプリが必要です", + "settings.revealed_workspace_config": "ワークスペース設定を表示しました。", + "settings.run_sandbox_probe": "プローブを実行中…", + "settings.running_probe": "プローブを実行中…", + "settings.runtime_applies_hint": "次回エンジン起動またはリロード時に適用されます。", + "settings.runtime_debug_desc": "ワンクリックエクスポート付きの読みやすい診断スナップショット。", + "settings.runtime_debug_title": "ランタイムデバッグレポート", + "settings.runtime_desc": "ローカルエンジンとOpenWorkサーバーのステータス。", + "settings.runtime_direct": "ダイレクト(OpenCode)", + "settings.runtime_title": "ランタイム", + "settings.sandbox_error": "エラー", + "settings.sandbox_export_hint": "上のランタイムデバッグレポートのエクスポートを使用して", + "settings.sandbox_probe_desc": "一時的なDockerサンドボックスの起動チェックを実行し、", + "settings.sandbox_probe_errors": "サンドボックスプローブがエラー付きで完了しました。", + "settings.sandbox_probe_failed": "サンドボックスプローブに失敗しました。", + "settings.sandbox_probe_success": "サンドボックスプローブに成功しました。サポート用にデバッグレポートをエクスポートしてください。", + "settings.sandbox_probe_title": "サンドボックスプローブ", + "settings.sandbox_ready": "準備完了", + "settings.sandbox_requires_desktop": "サンドボックスプローブにはデスクトップアプリが必要です", + "settings.sandbox_result": "結果: {status}", + "settings.sandbox_run_id": "実行ID: {id}", + "settings.sandbox_stop_runs_hint": "プローブの前にアクティブな実行を停止してください", + "settings.search_models": "モデルを検索…", + "settings.select_binary": "OpenCodeバイナリを選択", + "settings.select_workspace_first": "設定を表示する前にローカルワークスペースを選択してください。", + "settings.send_feedback": "フィードバックを送信", + "settings.service_restarts_desc": "この画面を離れずに特定のホストサービスを再起動します。", + "settings.service_restarts_title": "サービス再起動", + "settings.session_model": "モデル", + "settings.show_model_reasoning": "モデルの推論を表示", + "settings.show_model_reasoning_desc": "モデルが推論トレースを提供する場合、UIで展開表示します。", + "settings.showing_models": "{total}件中{count}件を表示", + "settings.sidecar_config_unavailable": "サイドカー設定が利用できません", + "settings.startup": "起動", + "settings.startup_local": "ローカルサーバーを起動", + "settings.startup_not_set": "サーバーに接続", + "settings.startup_remote_warning": "起動設定は現在リモートです。エンジン設定は", + "settings.startup_reset_hint": "保存された設定をクリアし、接続画面を表示します。", + "settings.startup_server": "サーバーに接続", + "settings.startup_title": "起動", + "settings.stop_local_server": "ローカルサーバーを停止", + "settings.stop_runs_before_cleanup": "クリーンアップの前にアクティブな実行を停止してください", + "settings.stop_runs_before_reset_config": "設定リセットの前にアクティブな実行を停止してください", + "settings.stop_runs_to_reset": "リセットするにはアクティブな実行を停止してください", + "settings.switch": "切り替え", + "settings.tab_advanced": "詳細", + "settings.tab_appearance": "外観", + "settings.tab_cloud": "クラウド", + "settings.tab_debug": "デバッグ", + "settings.tab_description_advanced": "初期設定をやり直すためにOpenWorkのローカル状態をリセットします。", + "settings.tab_description_appearance": "テーマとユーザーインターフェースの外観をカスタマイズします。", + "settings.tab_description_debug": "ランタイムダイアログ、ログ、低レベルデバッグユーティリティを確認します。", + "settings.tab_description_den": "OpenWork Cloud接続、ホストワーカー、ワークスペースアクセスを管理します。", + "settings.tab_description_extensions": "このワークスペースのMCPアプリとOpenCodeプラグインを管理します。", + "settings.tab_description_general": "プロバイダーを接続し、デフォルトモデルを選択し、フォルダへのアクセスを許可し、OpenWorkワークスペースとそのランタイム接続を管理します。", + "settings.tab_description_environment": "ローカルのエージェント、skills、MCP servers が使う API キーやトークンを保存します。シークレットはこのデバイスに保持されます。", + "settings.tab_description_messaging": "ワークスペース設定からルーターアイデンティティとインボックス動作を設定します。", + "settings.tab_description_model": "デフォルトモデル、ランタイム動作、アシスタント出力設定を調整します。", + "settings.tab_description_recovery": "マイグレーション状態を修復し、ワークスペースのデフォルトをリセットし、ローカル設定を復元します。", + "settings.tab_description_skills": "カスタムスキルをこのワークスペースに作成、保存、実行します。", + "settings.tab_description_updates": "OpenWorkを最新の状態に保ちます。", + "settings.tab_environment": "環境変数", + "settings.tab_extensions": "拡張機能", + "settings.tab_general": "設定", + "settings.tab_messaging": "メッセージ", + "settings.tab_model": "モデル", + "settings.tab_recovery": "リカバリー", + "settings.tab_skills": "スキル", + "settings.tab_updates": "アップデート", + "settings.theme_dark": "ダーク", + "settings.theme_light": "ライト", + "settings.theme_system": "システム", + "settings.theme_system_hint": "システムモードはOSの設定に自動的に従います。", + "settings.toolbar_ready_to_install": "インストール準備完了", + "settings.update": "アップデート", + "settings.update_available": "アップデート利用可能: v", + "settings.update_available_version": "アップデート利用可能: v{version}", + "settings.update_check_button": "確認", + "settings.update_check_failed": "アップデートの確認に失敗しました", + "settings.update_checking": "確認中…", + "settings.update_download_button": "ダウンロード", + "settings.update_downloading": "ダウンロード中…", + "settings.update_error": "アップデートの確認に失敗しました", + "settings.update_install_button": "インストールして再起動", + "settings.update_last_checked": "最終確認 {time}", + "settings.update_published": "公開日 {date}", + "settings.update_ready": "インストール準備完了: v", + "settings.update_ready_version": "インストール準備完了: v{version}", + "settings.update_uptodate": "最新です", + "settings.updates": "アップデート", + "settings.updates_desc": "OpenWorkを最新の状態に保ちます。", + "settings.updates_desktop_only": "アップデートはデスクトップアプリでのみ利用可能です。", + "settings.updates_not_supported": "この環境ではアップデートはサポートされていません。", + "settings.updates_title": "アップデート", + "settings.version": "バージョン", + "settings.versions_desc": "サイドカーとデスクトップのビルド情報。", + "settings.versions_title": "バージョン", + "settings.window_appearance_desc": "ウィンドウの外観をカスタマイズします。", + "settings.worker_id_label": "ワーカー{id}", + "settings.worker_unresolved": "ワーカー{runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "ワークスペース設定", + "settings.workspace_debug_events_label": "ワークスペースデバッグイベント", + "settings.workspace_fallback_name": "ワークスペース", + "share.active_cloud_org": "アクティブなCloud組織", + "share.back_hint": "共有オプションに戻る", + "share.chooser_subtitle": "このワークスペースの共有方法を選択してください。", + "share.close_hint": "閉じる", + "share.cloud_signin_note": "OpenWork Cloudがブラウザで開き、サインイン後にここに戻ります。", + "share.collaborator_hint": "許可承認なしの通常アクセス。", + "share.connect_messaging_desc": "Slack、Telegram、その他からこのワークスペースを使用できます。", + "share.connect_messaging_title": "メッセージングを接続", + "share.connection_details_label": "接続詳細", + "share.copy_hint": "コピー", + "share.copy_link_hint": "リンクをコピー", + "share.create_template_link": "テンプレートリンクを作成", + "share.credentials_disabled_hint": "リモートアクセスを有効にして保存をクリックすると、ワーカーが再起動してこのワークスペースのライブ接続詳細が表示されます。", + "share.field_password": "パスワード", + "share.field_worker_url": "ワーカーURL", + "share.hide_password": "パスワードを隠す", + "share.included_in_template": "このテンプレートに含まれるもの", + "share.option_access_desc": "別のマシンからこの実行中のワークスペースに到達するために必要なライブ接続詳細を表示します。", + "share.option_access_title": "ワークスペースにリモートアクセス", + "share.option_public_desc": "誰でもこのテンプレートから開始できる共有リンクを作成します。", + "share.option_public_title": "公開テンプレート", + "share.option_team_title": "チームと共有", + "share.option_template_desc": "他の人が同じ環境から開始できるようにセットアップをパッケージ化します。", + "share.optional_collaborator": "任意のコラボレーターアクセス", + "share.public_intro": "このワークスペースを公開テンプレートリンクとして共有します。", + "share.publishing": "公開中…", + "share.regenerate_link": "リンクを再生成", + "share.remote_access_desc": "デフォルトでオフ。このワーカーを別のマシンからアクセス可能にしたい場合のみオンにしてください。", + "share.remote_access_disabled": "リモートアクセスは現在無効です。", + "share.remote_access_enabled": "リモートアクセスは現在有効です。", + "share.remote_access_title": "リモートアクセス", + "share.remote_save": "保存", + "share.remote_save_busy": "保存中…", + "share.reveal_password": "パスワードを表示", + "share.save_to_team": "チームに保存", + "share.saving": "保存中…", + "share.setup": "セットアップ", + "share.sign_in_to_share": "チームと共有するにはサインイン", + "share.subtitle_access": "別のマシンからこのワークスペースに到達するために必要なライブ接続詳細を表示します。", + "share.team_intro": "チームメイトが後からCloud設定で開けるよう、アクティブなOpenWork Cloud組織にこのテンプレートを保存します。", + "share.template_intro": "この実行中のワークスペースへのライブアクセスを付与せずに、再利用可能なセットアップを共有します。", + "share.template_item_config": "コマンドと設定", + "share.template_item_config_desc": "再利用可能なコマンドとOpenWork/OpenCode設定。", + "share.template_item_settings": "ワークスペース設定", + "share.template_item_settings_desc": "共有ワークスペースプロファイルとデフォルト動作。", + "share.template_item_skills": "含まれるスキル", + "share.template_item_skills_desc": "このワークスペースに保存されたカスタムスキル。", + "share.template_name_label": "テンプレート名", + "share.title": "ワークスペースを共有", + "share.view_access": "ワークスペースにリモートアクセス", + "share.warning_basic": "信頼できる人にのみ共有してください。これらの認証情報はこのワークスペースへのライブアクセスを許可します。", + "share.warning_full": "これらの認証情報はこのワークスペースへのライブアクセスを許可します。このワークスペースをリモート共有すると、ネットワークにアクセスできる人がワーカーを制御できる可能性があります。", + "share.workspace_fallback": "ワークスペース", + "share.workspace_template_desc": "コアセットアップとワークスペースのデフォルトを共有します。", + "share.workspace_template_title": "ワークスペーステンプレート", + "share_skill_destination.add_to_workspace": "ワークスペースに追加", + "share_skill_destination.adding": "追加中…", + "share_skill_destination.confirm_busy": "スキルを追加中…", + "share_skill_destination.confirm_button": "ワークスペースにスキルを追加", + "share_skill_destination.connect_remote": "リモートワークスペースに接続", + "share_skill_destination.connect_remote_desc": "OpenWorkホストを接続し、リストから選択してこのスキルをインポートします。", + "share_skill_destination.connect_remote_hint": "リモートワークスペースに接続してからリストで選択してください。", + "share_skill_destination.create_worker": "新しいワークスペースを作成", + "share_skill_destination.create_worker_desc": "ワークスペースセットアップフローを開き、新しいワークスペースの準備ができたらこのスキルを追加します。", + "share_skill_destination.create_worker_hint": "新しいワークスペースを作成してこのスキルを追加します。", + "share_skill_destination.current_badge": "現在", + "share_skill_destination.existing_workers": "既存のワークスペース", + "share_skill_destination.fallback_skill_name": "共有スキル", + "share_skill_destination.footer_idle": "続行するにはワークスペースを選択してください。", + "share_skill_destination.footer_selected": "選択されたワークスペース:", + "share_skill_destination.local_badge": "ローカル", + "share_skill_destination.more_options": "その他のオプション", + "share_skill_destination.new_destination": "新しい送信先", + "share_skill_destination.no_workers": "まだ準備できたワークスペースがありません。このスキルをインストールするには、ワークスペースを作成するかリモートワークスペースに接続してください。", + "share_skill_destination.remote_badge": "リモート", + "share_skill_destination.sandbox_badge": "サンドボックス", + "share_skill_destination.selected_badge": "選択済み", + "share_skill_destination.selected_hint": "選択済み。下の送信先を確認してから確定してください。", + "share_skill_destination.skill_label": "共有スキル", + "share_skill_destination.subtitle": "共有されたスキルをインポートする前に、既存のワークスペースを選択するか新しいワークスペースを作成してください。", + "share_skill_destination.title": "このスキルをどこに追加しますか?", + "share_skill_destination.trigger_label": "トリガー", + "sidebar.active": "アクティブ", + "sidebar.add_workspace": "新しいワークスペースを追加", + "sidebar.collapse": "折りたたむ", + "sidebar.connect_remote": "リモート接続", + "sidebar.delete_session": "セッションを削除", + "sidebar.drag_reorder": "ドラッグで並べ替え", + "sidebar.edit_connection": "接続を編集", + "sidebar.expand": "展開", + "sidebar.import_config": "設定をインポート", + "sidebar.needs_attention": "要確認", + "sidebar.new_worker": "新しいワーカー", + "sidebar.no_workspaces": "このセッションにはまだワークスペースがありません。追加して始めましょう。", + "sidebar.progress": "進行状況", + "sidebar.show_fewer": "表示を減らす", + "sidebar.show_more": "さらに{count}件表示", + "sidebar.stop_sandbox": "サンドボックスを停止", + "sidebar.switch": "切り替え", + "sidebar.test_connection": "接続テスト", + "skills.add_custom_repo": "カスタムGitHubリポを追加", + "skills.add_git_repo": "Gitリポを追加", + "skills.add_openwork_hub": "OpenWork Hubを追加", + "skills.available_from_hub": "ハブから利用可能", + "skills.catalog_search_placeholder": "インストール済み、チーム、ハブのスキルを検索", + "skills.cloud_add_skill": "スキルを追加", + "skills.cloud_choose_org_detail": "Cloudパネルでアクティブな組織を選択してから、このリストを更新してください。", + "skills.cloud_choose_org_hint": "チームスキルを読み込むには設定→Cloudで組織を選択してください。", + "skills.cloud_footer_label": "チーム", + "skills.cloud_hub_label": "ハブ: {name}", + "skills.cloud_install_need_server": "チームスキルをこのワーカーにインストールするには、スキル書き込みアクセスのあるOpenWorkサーバーに接続してください。", + "skills.cloud_installed": "このワーカーに{name}をインストールしました。", + "skills.cloud_installing": "{title}をインストール中…", + "skills.cloud_installing_short": "インストール中", + "skills.cloud_no_search_matches": "検索に一致するスキルがありません。", + "skills.cloud_org_empty": "組織のスキルはまだ利用できません。", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "組織スキルの読み込みに失敗しました。", + "skills.cloud_refresh": "チームスキルを更新", + "skills.cloud_section_subtitle": "OpenWork Cloudで共有されたスキル(アクセス可能なチームスキルハブを含む)。", + "skills.cloud_section_title": "組織から", + "skills.cloud_shared_org": "組織", + "skills.cloud_shared_public": "公開", + "skills.cloud_sign_in": "Cloudにサインイン", + "skills.cloud_sign_in_hint": "チームおよび組織のスキルを参照するにはOpenWork Cloudにサインインしてください。", + "skills.copy_link_failed": "リンクのコピーに失敗しました", + "skills.create_in_chat": "チャットでスキルを作成", + "skills.desktop_required": "スキル管理にはデスクトップアプリが必要です。", + "skills.enter_plugin_name": "プラグインパッケージ名を入力してください。", + "skills.failed_load_active": "アクティブなプラグインの読み込みに失敗しました。", + "skills.failed_load_opencode": "opencode.jsonの読み込みに失敗しました", + "skills.failed_parse_opencode": "opencode.jsonの解析に失敗しました", + "skills.failed_to_load": "スキルの読み込みに失敗しました", + "skills.failed_update_opencode": "opencode.jsonの更新に失敗しました", + "skills.filter_all": "すべて", + "skills.filter_cloud": "チーム", + "skills.filter_hub": "ハブ", + "skills.filter_installed": "インストール済み", + "skills.from_repo": "{owner}/{repo}から", + "skills.github_repo_hint": "owner/repo形式でGitHubリポジトリを入力してください。", + "skills.host_mode_only": "ローカルワークスペースのみ", + "skills.host_only_error": "スキル管理にはローカルワークスペースまたは接続されたOpenWorkサーバーが必要です。", + "skills.hub_desc": "GitHubベースのハブから共有スキルを閲覧し、このワーカーに追加します。", + "skills.hub_label": "ハブ", + "skills.import": "インポート", + "skills.import_failed": "インポートに失敗しました({status})", + "skills.import_local": "ローカルスキルをインポート", + "skills.import_local_hint": "既存のスキルフォルダをこのワークスペースにコピーします。", + "skills.import_local_skill": "ローカルスキルをインポート", + "skills.imported": "インポート完了。", + "skills.install": "インストール", + "skills.install_failed": "スキルのインストールに失敗しました。", + "skills.install_name_title": "{name}をインストール", + "skills.install_skill_creator": "スキルクリエイターをインストール", + "skills.install_skill_creator_hint": "このスキルを使って、チャット内から他のスキルを作成できます。", + "skills.installed": "インストール済みスキル", + "skills.installed_desc": "インストール済みスキルはこのワーカーで実行でき、編集または共有できます。", + "skills.installed_label": "インストール済み", + "skills.installed_status": "インストール済み", + "skills.installing": "スキルを追加", + "skills.installing_prefix": "{name}をインストール中…", + "skills.installing_skill_creator": "スキルクリエイターをインストール中…", + "skills.link_copied": "リンクをコピーしました", + "skills.loading": "読み込み中…", + "skills.no_description": "説明はまだありません。", + "skills.no_hub_repo_label": "ハブリポが選択されていません", + "skills.no_hub_repo_selected": "ハブスキルが利用できません。", + "skills.no_hub_skills": "ハブリポが選択されていません。スキルを閲覧するにはGitHubリポを追加してください。", + "skills.no_opencode_found": "opencode.jsonがまだ見つかりません。プラグインを追加して作成してください。", + "skills.no_opencode_workspace": "このワークスペースにはまだopencode.jsonがありません。", + "skills.no_skills": "`.opencode/skills`、`.claude/skills`、`~/.agents/skills`にスキルが検出されませんでした。", + "skills.no_skills_found": "まだスキルが見つかりません。", + "skills.owner_label": "オーナー", + "skills.owner_repo_required": "オーナーとリポは必須です。", + "skills.pick_project_first": "最初にプロジェクトフォルダを選択してください。", + "skills.pick_project_for_active": "アクティブなプラグインを読み込むにはプロジェクトフォルダを選択してください。", + "skills.pick_project_for_plugins": "プロジェクトのプラグインを管理するにはプロジェクトフォルダを選択してください。", + "skills.pick_workspace_first": "最初にワークスペースフォルダを選択してください。", + "skills.plugin_already_listed": "プラグインは既にopencode.jsonに登録されています。", + "skills.plugin_management_host_only": "プラグイン管理にはデスクトップアプリが必要です。", + "skills.plugins_host_only": "プラグインはデスクトップアプリでのみ利用可能です。", + "skills.ref_label": "Ref(ブランチ/タグ/コミット)", + "skills.refresh": "更新", + "skills.refresh_hub": "ハブを更新", + "skills.refresh_hub_title": "ハブカタログを更新", + "skills.remove_saved_repo": "保存済みリポを削除", + "skills.repo_label": "リポ", + "skills.reveal_failed": "スキルフォルダを開けませんでした。", + "skills.reveal_folder": "スキルフォルダを開く", + "skills.reveal_folder_hint": "スキルディレクトリをFinderで開きます。", + "skills.save_and_load": "保存して読み込み", + "skills.save_failed": "スキルの保存に失敗しました。", + "skills.select_skill_folder": "スキルフォルダを選択", + "skills.share_back": "戻る", + "skills.share_chooser_subtitle": "OpenWork Cloudの組織に保存するか、公開インストールリンクを発行します。", + "skills.share_close": "閉じる", + "skills.share_copy_link": "コピー", + "skills.share_done": "完了", + "skills.share_option_public_desc": "誰でもこのスキルをインストールできるリンクを作成します。", + "skills.share_option_public_title": "公開リンク", + "skills.share_option_team_desc": "アクティブなOpenWork Cloud組織にこのスキルを追加します。", + "skills.share_option_team_title": "チームと共有", + "skills.share_public_create": "リンクを作成", + "skills.share_public_creating": "公開中…", + "skills.share_public_intro": "公開リンクを発行します。URLを知っている人は誰でもインストールできます。", + "skills.share_public_regenerate": "リンクを再発行", + "skills.share_publisher_label": "パブリッシャー", + "skills.share_subtitle_public": "リンクを知っている人は誰でもこのスキルをインストールできます。", + "skills.share_subtitle_team": "チーム向けに組織に保存されます。", + "skills.share_team_choose_org": "チーム共有の前に設定のCloudで組織を選んでください。", + "skills.share_team_hub_label": "スキルハブに追加(任意)", + "skills.share_team_hub_none": "組織のみ — ハブには入れない", + "skills.share_team_hubs_loading": "ハブを読み込み中…", + "skills.share_team_intro": "チームがCloudからインストールできるよう、アクティブな組織に保存します。", + "skills.share_team_org_fallback": "アクティブなCloud組織", + "skills.share_team_save": "チームに保存", + "skills.share_team_saving": "保存中…", + "skills.share_team_sign_in": "チーム共有のためにサインイン", + "skills.share_team_sign_in_hint": "ブラウザでOpenWork Cloudが開きます。サインイン後にここに戻ってください。", + "skills.share_team_success": "{org} に保存しました。チームは組織スキルからインストールできます。", + "skills.share_title": "スキルを共有", + "skills.shown_count": "{count}件表示", + "skills.skill_creator_already_installed": "スキルクリエイターは既にインストール済みです。", + "skills.skill_creator_installed": "スキルクリエイターをインストールしました。", + "skills.skill_load_failed": "スキルの読み込みに失敗しました。", + "skills.source_label": "ソース", + "skills.subtitle": "このワークスペースのスキルを管理します。", + "skills.title": "スキル", + "skills.trigger_label": "トリガー: {trigger}", + "skills.uninstall": "アンインストール", + "skills.uninstall_failed": "スキルのアンインストールに失敗しました。", + "skills.uninstall_title": "スキルをアンインストールしますか?", + "skills.uninstall_warning": "ワークスペースから`{name}`スキルを完全に削除します。", + "skills.uninstalled": "スキルを削除しました。", + "skills.unknown_error": "不明なエラー", + "skills.worker_profile_desc": "スキルはこのワーカーの主要な機能です。ハブから探し、インストール済みのものを管理し、チャットから直接作成できます。", + "status.back": "前の画面に戻る", + "status.connected": "接続済み", + "status.connecting": "接続中", + "status.creating_task": "新しいタスクを作成中", + "status.creating_workspace": "ワークスペースを作成中", + "status.developer_mode": "デベロッパーモード", + "status.disconnected": "切断済み", + "status.disconnected_hint": "設定を開いて再接続してください", + "status.disconnected_label": "切断済み", + "status.disconnecting": "切断中", + "status.docs": "ドキュメント", + "status.feedback": "フィードバック", + "status.idle": "アイドル", + "status.installing_opencode": "OpenCodeをインストール中", + "status.limited_hint": "再接続してOpenWorkの全機能を復元", + "status.limited_mcp_hint": "{count}件のMCP接続済み · 全機能には再接続が必要", + "status.limited_mode": "制限モード", + "status.live": "ライブ", + "status.loading_session": "セッションを読み込み中", + "status.mcp_connected": "{count}件のMCP接続済み", + "status.open_docs": "ドキュメントを開く", + "status.openwork_ready": "OpenWork準備完了", + "status.providers_connected": "{count}件のプロバイダーが接続済み", + "status.ready_for_tasks": "新しいタスクの準備完了", + "status.reloading_engine": "エンジンをリロード中", + "status.restarting_engine": "エンジンを再起動中", + "status.running": "実行中", + "status.send_feedback": "フィードバックを送信", + "status.settings": "設定", + "status.starting_engine": "エンジンを起動中", + "system.cache_repair_requires_desktop": "キャッシュの修復にはデスクトップアプリが必要です。", + "system.docker_cleanup_requires_desktop": "Dockerクリーンアップにはデスクトップアプリが必要です。", + "system.reload_body_agents": "OpenCodeは起動時にエージェントを読み込みます。更新されたエージェントを利用可能にするにはエンジンをリロードしてください。", + "system.reload_body_commands": "OpenCodeは起動時にコマンドを読み込みます。更新されたコマンドを利用可能にするにはエンジンをリロードしてください。", + "system.reload_body_config": "OpenCodeは起動時にopencode.jsonを読み込みます。設定変更を適用するにはエンジンをリロードしてください。", + "system.reload_body_default": "OpenWorkがOpenCodeインスタンスのリロードが必要な変更を検出しました。", + "system.reload_body_mcp": "OpenCodeは起動時にMCPサーバーを読み込みます。新しい接続を有効にするにはエンジンをリロードしてください。", + "system.reload_body_mixed": "OpenWorkがOpenCode設定の変更を検出しました。エンジンをリロードして適用してください。", + "system.reload_body_plugins": "OpenCodeは起動時にnpmプラグインを読み込みます。opencode.jsonの変更を適用するにはエンジンをリロードしてください。", + "system.reload_body_skills": "OpenCodeはスキルの検出/状態をキャッシュできます。新しくインストールしたスキルを利用可能にするにはエンジンをリロードしてください。", + "system.reload_failed": "エンジンのリロードに失敗しました。", + "system.reload_required": "リロードが必要", + "system.reload_unavailable": "このワーカーではリロードできません。", + "system.stop_active_runs_before_reset": "リセットする前にアクティブな実行を停止してください。", + "system.stop_runs_before_update": "アップデートをインストールする前にアクティブな実行を停止してください。", + "system.updates_not_supported": "この環境ではアップデートはサポートされていません。", + "time.hours_ago": "{count}時間前", + "time.just_now": "たった今", + "time.minutes_ago": "{count}分前", + "time.seconds_ago": "{count}秒前", + "workspace.loading_tasks": "タスクを読み込み中…", + "workspace.local_badge": "ローカル", + "workspace.new_task_inline": "+ 新しいタスク", + "workspace.no_tasks": "まだタスクがありません。", + "workspace.remote_badge": "リモート", + "workspace.rename_description": "サイドバーに表示される名前を更新します。", + "workspace.rename_label": "ワークスペース名", + "workspace.rename_placeholder": "デザインチームワークスペース", + "workspace.rename_title": "ワークスペース名を編集", + "workspace.sandbox_badge": "サンドボックス", + "workspace.selected": "選択済み", + "workspace.switch": "切り替え", + "workspace.switching_status_connecting": "接続を確認中", + "workspace.switching_status_loading": "最近のタスクを読み込み中", + "workspace.switching_status_preparing": "準備しています", + "workspace.switching_subtitle": "最近の作業をすぐにお見せします。", + "workspace.switching_title": "{name} を開いています", + "workspace.switching_title_unknown": "ワークスペースを開いています", + "workspace_list.add_workspace": "ワークスペースを追加", + "workspace_list.connect_remote": "リモートワークスペースに接続", + "workspace_list.connecting": "接続中…", + "workspace_list.delete_session": "セッションを削除", + "workspace_list.desktop_only_hint": "ローカルワークスペースはデスクトップアプリで作成できます。", + "workspace_list.edit_connection": "接続を編集", + "workspace_list.edit_name": "名前を編集", + "workspace_list.hide_child_sessions": "子セッションを隠す", + "workspace_list.import_config": "設定をインポート", + "workspace_list.new_workspace": "新しいワークスペース", + "workspace_list.recover": "復元", + "workspace_list.remove_workspace": "ワークスペースを削除", + "workspace_list.rename_session": "セッション名を変更", + "workspace_list.reveal_explorer": "エクスプローラーで表示", + "workspace_list.reveal_finder": "Finderで表示", + "workspace_list.session_actions": "セッションアクション", + "workspace_list.share": "共有…", + "workspace_list.show_child_sessions": "子セッションを表示", + "workspace_list.show_more": "さらに{count}件表示", + "workspace_list.show_more_fallback": "もっと見る", + "workspace_list.test_connection": "接続テスト", + "workspace_list.workspace_fallback": "ワークスペース", + "workspace_list.workspace_options": "ワークスペースオプション", + "workspace_sidebar.close_sidebar": "サイドバーを閉じる", + "workspace_sidebar.collapse_sidebar": "サイドバーを折りたたむ", + "workspace_sidebar.configuration": "設定", + "workspace_sidebar.expand_sidebar": "サイドバーを展開", + "workspace_sidebar.extensions": "拡張機能", + "workspace_sidebar.messaging": "メッセージング", +} as const; diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts new file mode 100644 index 0000000000..96e94bddf5 --- /dev/null +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -0,0 +1,1989 @@ +/** + * Traduções para português do Brasil + * Termos profissionais (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) NÃO são traduzidos + */ + +export default { + "app.compact_command_desc": "Resumir esta sessão para reduzir o tamanho do contexto.", + "app.connection_lost": "Conexão com o servidor perdida. Por favor, recarregue.", + "app.deep_link_auth_queued": "Link de autenticação Cloud enfileirado para o OpenWork.", + "app.deep_link_remote_queued": "Link de worker remoto enfileirado. O OpenWork deve entrar no fluxo de conexão.", + "app.error.choose_folder": "Escolha uma pasta para continuar.", + "app.error.host_requires_local": "Selecione um workspace local para iniciar o engine.", + "app.error.install_failed": "Falha na instalação do OpenCode. Veja os logs acima.", + "app.error.pick_workspace_folder": "Selecione primeiro uma pasta de workspace.", + "app.error.remote_base_url_required": "Adicione uma URL de servidor para continuar.", + "app.error.tauri_required": "Esta ação requer o runtime do app desktop do OpenWork.", + "app.error_audit_load": "Falha ao carregar o log de auditoria.", + "app.error_auth_failed": "Falha na autenticação", + "app.error_auto_compact_scope": "A compactação automática de contexto só pode ser alterada para um workspace local ou um workspace de servidor OpenWork com permissão de escrita.", + "app.error_cloud_signin": "Falha ao concluir o login no OpenWork Cloud.", + "app.error_command_not_resolved": "Comando não foi resolvido.", + "app.error_compact_empty": "Nada para compactar ainda.", + "app.error_compact_no_session": "Selecione uma sessão com mensagens antes de executar /compact.", + "app.error_compact_no_session_id": "Selecione uma sessão antes de compactar.", + "app.error_connect_first": "Conecte a este worker antes de aplicar alterações de runtime.", + "app.error_connection_failed": "Falha na conexão", + "app.error_connection_failed_url": "Falha na conexão. Verifique a URL e o token.", + "app.error_deep_link_unrecognized": "Esse link não é um deep link ou URL de compartilhamento reconhecido do OpenWork.", + "app.error_desktop_signin": "Login no desktop concluído, mas o OpenWork Cloud não retornou um token de sessão.", + "app.error_not_connected": "Não conectado a um servidor", + "app.error_pick_local_folder": "Selecione uma pasta de worker local antes de reiniciar o servidor local.", + "app.error_rate_limit": "Limite de requisições excedido", + "app.error_remote_access": "Falha ao atualizar o acesso remoto.", + "app.error_request_failed": "Falha na requisição", + "app.error_reset_config": "Falha ao redefinir as configurações padrão do app.", + "app.error_restart_local_worker": "Falha ao reiniciar o worker local com a configuração de compartilhamento atualizada.", + "app.error_runtime_changes": "Falha ao aplicar alterações de runtime.", + "app.error_session_name_required": "O nome da sessão é obrigatório", + "app.error_update_opencode_json": "Falha ao atualizar opencode.json", + "app.import_bundle_desc": "Escolha como importar este bundle.", + "app.import_shared_bundle": "Importar bundle compartilhado", + "app.local_disabled_reason": "Crie workspaces locais no app desktop. Workspaces remotos e compartilhados ainda funcionam aqui.", + "app.local_worker_detail": "Worker local", + "app.model_behavior_desc": "Escolha o modelo primeiro para ver os controles de comportamento específicos do provedor.", + "app.model_behavior_title": "Comportamento do modelo", + "app.plugins_hint_disconnected": "Servidor OpenWork indisponível. Plugins estão em modo somente leitura.", + "app.plugins_hint_limited": "O servidor OpenWork precisa de um token para editar plugins.", + "app.plugins_hint_readonly": "O servidor OpenWork está em modo somente leitura para plugins.", + "app.reload_later": "Depois", + "app.reload_now": "Recarregar agora", + "app.reload_stop_tasks": "Recarregar e Parar Tarefas", + "app.remote_worker_detail": "Worker remoto", + "app.reset_config_ok": "Configurações padrão do app redefinidas. Reinicie o OpenWork se alguma configuração obsoleta permanecer.", + "app.shared_setup": "Configuração compartilhada", + "app.skill_added": "Skill adicionada", + "app.skills_hint_disconnected": "Servidor OpenWork indisponível. Adicione a URL/token do servidor em Avançado para gerenciar skills.", + "app.skills_hint_limited": "O servidor OpenWork precisa de um token de host para instalar/atualizar skills. Adicione-o em Avançado e reconecte.", + "app.skills_hint_readonly": "O servidor OpenWork está em modo somente leitura para skills. Adicione um token de host em Avançado para habilitar instalações.", + "app.unknown_error": "Erro desconhecido", + "app.worker_fallback": "Worker", + "blueprint.automation_body": "Comece com um workflow reutilizável ou digite sua tarefa abaixo.", + "blueprint.automation_title": "O que você quer automatizar?", + "blueprint.csv_session_assistant": "Posso ajudar a gerar, limpar, mesclar e resumir arquivos CSV. Que tipo de trabalho com CSV você quer automatizar?", + "blueprint.csv_session_title": "Ideias de workflow com CSV", + "blueprint.csv_session_user": "Quero combinar exportações de várias ferramentas em um CSV limpo.", + "blueprint.empty_body": "Escolha um ponto de partida ou digite abaixo.", + "blueprint.empty_title": "O que você quer fazer?", + "blueprint.minimal_body": "Faça uma pergunta sobre este workspace ou use um prompt inicial.", + "blueprint.minimal_title": "Comece com uma tarefa", + "blueprint.starter_blueprint_desc": "Projete um workflow reproduzível com skills, comandos e etapas de handoff.", + "blueprint.starter_blueprint_prompt": "Me ajude a projetar um blueprint de automação reutilizável para este workspace. Pergunte o que deve ser padronizado e depois proponha o workflow.", + "blueprint.starter_blueprint_title": "Planejar um blueprint de automação", + "blueprint.starter_chrome_desc": "Comece uma conversa de automação de navegador agora mesmo.", + "blueprint.starter_chrome_prompt": "Me ajude a conectar ao Chrome e automatizar uma tarefa repetitiva.", + "blueprint.starter_chrome_title": "Automatizar Chrome", + "blueprint.starter_command_desc": "Transforme um workflow repetido em um comando slash para este workspace.", + "blueprint.starter_command_prompt": "Me ajude a criar um /comando reutilizável para este workspace. Pergunte qual workflow eu quero automatizar e depois elabore o comando.", + "blueprint.starter_command_title": "Criar um comando reutilizável", + "blueprint.starter_connect_openai_desc": "Adicione seu provedor OpenAI para que os modelos ChatGPT estejam prontos em novas sessões.", + "blueprint.starter_connect_openai_title": "Conectar ChatGPT", + "blueprint.starter_csv_desc": "Limpe ou gere dados de planilha.", + "blueprint.starter_csv_prompt": "Me ajude a criar ou editar arquivos CSV neste computador.", + "blueprint.starter_csv_title": "Trabalhar com CSV", + "blueprint.starter_explore_desc": "Resuma os arquivos e sugira a melhor primeira tarefa a realizar.", + "blueprint.starter_explore_prompt": "Resuma este workspace, aponte os arquivos mais importantes e sugira a melhor primeira tarefa.", + "blueprint.starter_explore_title": "Explorar este workspace", + "blueprint.welcome_message": "Olá, boas-vindas ao OpenWork!\n\nAs pessoas usam o OpenWork para escrever arquivos .csv no computador, conectar ao Chrome e automatizar tarefas repetitivas, e sincronizar contatos com o Notion.\n\nMas o único limite é a sua imaginação.\n\nO que você gostaria de fazer?", + "blueprint.welcome_title": "Boas-vindas ao OpenWork", + "common.add": "Adicionar", + "common.cancel": "Cancelar", + "common.choose": "Escolher", + "common.close": "Fechar", + "common.default_parens": "(padrão)", + "common.done": "Concluído", + "common.edit": "Editar", + "common.hide": "Ocultar", + "common.install": "Instalar", + "common.navigate": "navegar", + "common.next": "Próximo", + "common.off": "Desativado", + "common.on": "Ativado", + "common.path": "Caminho", + "common.question": "Pergunta", + "common.refresh": "Atualizar", + "common.remove": "Remover", + "common.reset": "Redefinir", + "common.retry": "Tentar novamente", + "common.save": "Salvar", + "common.select": "selecionar", + "common.show": "Mostrar", + "common.something_went_wrong": "Algo deu errado", + "common.submit": "Enviar", + "common.unknown": "Desconhecido", + "composer.agent_label": "Agente", + "composer.attach_files": "Anexar arquivos", + "composer.attachments_unavailable": "Anexos não estão disponíveis.", + "composer.behavior_label": "Comportamento", + "composer.configure": "Configurar", + "composer.default_agent": "Agente padrão", + "composer.expand_pasted": "Clique para expandir o texto colado", + "composer.failed_read_attachment": "Falha ao ler o anexo", + "composer.file_exceeds_limit": "{name} excede o limite de 8MB.", + "composer.file_kind": "Arquivo", + "composer.file_too_large_encoding": "{name} é muito grande após codificação. Tente uma imagem menor.", + "composer.image_kind": "Imagem", + "composer.inserted_links_unsupported": "Links inseridos para arquivos não suportados.", + "composer.loading_agents": "Carregando agentes...", + "composer.loading_commands": "Carregando comandos...", + "composer.mcps_label": "MCPs", + "composer.no_commands": "Nenhum comando encontrado.", + "composer.no_matches": "Nenhuma correspondência encontrada.", + "composer.placeholder": "Descreva sua tarefa...", + "composer.remote_worker_paste_warning": "Este é um worker remoto. Sandboxes também são remotos. Para compartilhar arquivos, envie-os para a Pasta Compartilhada na barra lateral.", + "composer.run_task": "Executar tarefa", + "composer.skill_source": "Skill", + "composer.stop": "Parar", + "composer.tools_label": "Comandos, skills e MCPs", + "composer.unsupported_attachment_type": "Tipo de anexo não suportado.", + "composer.upload_failed_local_links": "Não foi possível enviar para a pasta compartilhada. Links locais inseridos.", + "composer.upload_to_shared_folder": "Enviar para pasta compartilhada", + "composer.uploaded_multiple_files": "{count} arquivos enviados para a pasta compartilhada com links inseridos.", + "composer.uploaded_single_file": "{name} enviado para a pasta compartilhada com link inserido.", + "config.auto_reload_desc": "Recarregar automaticamente após alterações em agentes/skills/comandos/config (apenas quando ocioso).", + "config.auto_reload_title": "Recarregamento automático (local)", + "config.auto_reload_unavailable": "Disponível para workspaces locais no app desktop.", + "config.collaborator_token_disabled_hint": "Armazenado antecipadamente para compartilhamento remoto, mas o acesso remoto está desativado.", + "config.collaborator_token_label": "Token de colaborador", + "config.collaborator_token_remote_hint": "Acesso remoto rotineiro para celulares ou laptops conectando a este servidor.", + "config.connection_failed": "Falha na conexão.", + "config.connection_failed_check": "Falha na conexão. Verifique a URL e o token do host.", + "config.connection_status_updated": "Status da conexão atualizado.", + "config.connection_successful": "Conexão bem-sucedida.", + "config.copied": "Copiado", + "config.copy": "Copiar", + "config.desktop_only_hint": "Alguns recursos de config (compartilhamento de servidor local + bridge de mensagens) requerem o app desktop.", + "config.diagnostics_desc": "Copiar estado de runtime sanitizado para depuração.", + "config.diagnostics_title": "Pacote de diagnósticos", + "config.enable_auto_reload_first": "Ative o recarregamento automático primeiro", + "config.engine_reload_desc": "Reiniciar o servidor OpenCode para este workspace.", + "config.engine_reload_title": "Recarregar engine", + "config.host_admin_token_hint": "Token interno apenas para o host, usado para CLI de aprovações e APIs admin. Não use no fluxo de conexão do app remoto.", + "config.host_admin_token_label": "Token admin do host", + "config.host_local_only": "Apenas local", + "config.host_offline": "Offline", + "config.host_remote_enabled": "Remoto ativado", + "config.local_ip_hint": "Use seu IP local na mesma rede Wi-Fi para a conexão mais rápida.", + "config.mdns_hint": "Nomes .local são mais fáceis de lembrar, mas podem não resolver em todas as redes.", + "config.messaging_identities_desc": "Gerencie identidades do Telegram/Slack e roteamento na aba Identidades.", + "config.messaging_identities_title": "Identidades de mensagens", + "config.not_set": "Não definido", + "config.owner_token_disabled_hint": "Relevante apenas após ativar o acesso remoto para este worker.", + "config.owner_token_label": "Token do proprietário", + "config.owner_token_remote_hint": "Use quando o cliente remoto precisar responder a prompts de permissão ou executar ações exclusivas do proprietário.", + "config.reload_active_tasks_warning": "O recarregamento parará as tarefas ativas.", + "config.reload_availability_hint": "O recarregamento está disponível apenas para workers locais ou servidores OpenWork conectados.", + "config.reload_connect_hint": "Conecte a este worker para recarregar.", + "config.reload_engine": "Recarregar engine", + "config.reload_now_desc": "Aplica atualizações de config e reconecta sua sessão.", + "config.reload_now_title": "Recarregar agora", + "config.reloading": "Recarregando...", + "config.remote_access_off_hint": "O acesso remoto está desativado. Use Compartilhar workspace para ativá-lo antes de conectar de outra máquina.", + "config.resolved_worker_url": "URL do worker resolvida:", + "config.resume_sessions_desc": "Se um recarregamento foi enfileirado enquanto tarefas estavam em execução, envia uma mensagem de retomada depois.", + "config.resume_sessions_title": "Retomar sessões após recarregamento automático", + "config.server_needed_hint": "Conexão com o servidor OpenWork necessária para sincronizar skills, plugins e comandos.", + "config.server_section_desc": "Conecte a um servidor OpenWork. Use a URL mais um token de colaborador ou proprietário do administrador do servidor.", + "config.server_section_title": "Servidor OpenWork", + "config.server_sharing_desc": "Compartilhe esses dados com um dispositivo de confiança. Mantenha o servidor na mesma rede para a configuração mais rápida.", + "config.server_sharing_menu_hint": "Para links de compartilhamento por workspace, use Compartilhar... no menu do workspace.", + "config.server_sharing_title": "Compartilhamento do servidor OpenWork", + "config.server_url_hint": "Use a URL compartilhada pelo seu servidor OpenWork. Workers locais do desktop reutilizam uma porta alta persistente na faixa 48000-51000.", + "config.server_url_input_label": "URL do servidor OpenWork", + "config.server_url_label": "URL do Servidor OpenWork", + "config.starting_server": "Iniciando servidor…", + "config.status_connected": "Conectado", + "config.status_limited": "Limitado", + "config.status_not_connected": "Não conectado", + "config.test_connection": "Testar conexão", + "config.testing": "Testando...", + "config.testing_connection": "Testando conexão...", + "config.token_hint": "Opcional. Cole um token de colaborador para acesso rotineiro ou um token de proprietário quando este cliente precisar responder a prompts de permissão.", + "config.token_label": "Token de colaborador ou proprietário", + "config.token_placeholder": "Cole seu token", + "config.unavailable": "Indisponível", + "config.worker_id": "ID do Worker:", + "config.workspace_config_desc": "Essas configurações afetam o workspace selecionado. Ações somente de runtime se aplicam ao workspace conectado no momento.", + "config.workspace_config_title": "Config do workspace", + "config.workspace_id_prefix": "Workspace:", + "context_panel.add_button": "Adicionar", + "context_panel.add_folder_hint": "Adicione uma pasta para permitir que este workspace leia e edite arquivos fora do diretório raiz.", + "context_panel.adding_button": "Adicionando...", + "context_panel.always_available": "Sempre disponível", + "context_panel.authorized_folders": "Pastas autorizadas", + "context_panel.authorized_folders_desc": "Conceda a este workspace acesso para ler e editar arquivos em diretórios fora da raiz.", + "context_panel.authorized_folders_no_access": "Conecte a um workspace OpenWork com permissão de escrita para editar as pastas autorizadas.", + "context_panel.browse_button": "Navegar", + "context_panel.config_access_unavailable": "O acesso à configuração do servidor OpenWork não está disponível para este workspace.", + "context_panel.config_read_only": "O servidor OpenWork está conectado em modo somente leitura para configuração do workspace.", + "context_panel.context": "Contexto", + "context_panel.folder_already_authorized": "Pasta já autorizada.", + "context_panel.folders_updated": "Pastas autorizadas atualizadas.", + "context_panel.input_placeholder": "Digite o caminho de uma pasta para autorizar...", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "Conectado", + "context_panel.mcp_disabled": "Desativado", + "context_panel.mcp_disconnected": "Desconectado", + "context_panel.mcp_failed": "Falhou", + "context_panel.mcp_needs_auth": "Requer autenticação", + "context_panel.mcp_register_client": "Registrar cliente", + "context_panel.no_external_folders": "Nenhuma pasta externa autorizada", + "context_panel.no_mcp": "Nenhum servidor MCP carregado.", + "context_panel.no_plugins": "Nenhum plugin carregado.", + "context_panel.no_server_workspace": "Nenhum workspace de servidor ativo selecionado.", + "context_panel.no_skills": "Nenhuma skill carregada.", + "context_panel.none_yet": "Nenhum ainda.", + "context_panel.plugins": "Plugins", + "context_panel.preserving_entries": "Preservando {count} entradas de permissão não relacionadas a pastas.", + "context_panel.preserving_entry": "Preservando 1 entrada de permissão não relacionada a pasta.", + "context_panel.remove_folder": "Remover {name}", + "context_panel.saving_folders": "Salvando pastas autorizadas...", + "context_panel.server_disconnected": "Servidor OpenWork desconectado.", + "context_panel.skills": "Skills", + "context_panel.working_files": "Arquivos de trabalho", + "context_panel.workspace_root_available": "A raiz do workspace já está disponível.", + "context_panel.workspace_root_badge": "Raiz do workspace", + "context_panel.writable_workspace_required": "É necessário um workspace OpenWork com permissão de escrita para atualizar as pastas autorizadas.", + "dashboard.access_token": "Token de acesso", + "dashboard.access_token_optional_hint": "Adicione um token apenas se o worker exigir.", + "dashboard.blueprints_workspace": "Blueprints", + "dashboard.blueprints_workspace_desc": "Comece com um workspace pronto para automação com skills reutilizáveis, comandos e fluxos compartilhados.", + "dashboard.change": "Alterar", + "dashboard.choose_folder": "Escolher uma pasta", + "dashboard.choose_folder_continue": "Escolha uma pasta para continuar.", + "dashboard.choose_folder_next": "Compartilhar arquivos com seu workspace.", + "dashboard.choose_preset": "Escolher Predefinição", + "dashboard.chooser_local_desc": "Crie um workspace neste dispositivo e, opcionalmente, comece a partir de um template de equipe.", + "dashboard.chooser_remote_desc": "Conecte a um worker OpenWork auto-hospedado usando uma URL e token de acesso.", + "dashboard.chooser_shared_desc": "Navegue por workers na nuvem compartilhados com sua organização e conecte em um passo.", + "dashboard.close_settings": "Fechar configurações", + "dashboard.cloud_signin_button": "Continuar com Cloud", + "dashboard.cloud_signin_hint": "Acesse workers remotos compartilhados com sua organização.", + "dashboard.cloud_signin_next": "Você escolherá uma equipe e se conectará a um workspace existente em seguida.", + "dashboard.cloud_signin_title": "Entrar no OpenWork Cloud", + "dashboard.cloud_worker": "Worker na nuvem", + "dashboard.commands": "Comandos", + "dashboard.connect_remote_button": "Conectar remoto", + "dashboard.connected": "Conectado", + "dashboard.connecting": "Conectando...", + "dashboard.create_local_workspace_subtitle": "Crie um workspace neste dispositivo e, opcionalmente, comece a partir de um template de equipe.", + "dashboard.create_local_workspace_title": "Workspace local", + "dashboard.create_remote_custom_subtitle": "Conecte a um worker OpenWork auto-hospedado.", + "dashboard.create_remote_custom_title": "Conectar remoto personalizado", + "dashboard.create_remote_workspace_confirm": "Adicionar Workspace", + "dashboard.create_remote_workspace_subtitle": "Salvar um servidor OpenWork como workspace.", + "dashboard.create_remote_workspace_title": "Adicionar Workspace Remoto", + "dashboard.create_sandbox_confirm": "Criar como sandbox", + "dashboard.create_shared_subtitle_signed_in": "Navegue por workers na nuvem compartilhados com sua organização e conecte em um passo.", + "dashboard.create_shared_subtitle_signed_out": "Entre no OpenWork Cloud para acessar workers compartilhados com sua organização.", + "dashboard.create_shared_title": "Workspaces compartilhados", + "dashboard.create_workspace_confirm": "Criar Workspace", + "dashboard.create_workspace_subtitle": "Inicializar um novo workspace baseado em pasta.", + "dashboard.create_workspace_title": "Criar Workspace", + "dashboard.creating": "Criando...", + "dashboard.desktop_badge": "Desktop", + "dashboard.display_name_label": "Nome de exibição", + "dashboard.display_name_optional": "(opcional)", + "dashboard.docker_debug_details": "Detalhes de depuração do Docker", + "dashboard.edit_remote_workspace_confirm": "Salvar conexão", + "dashboard.edit_remote_workspace_subtitle": "Atualizar os dados do servidor OpenWork para este workspace.", + "dashboard.edit_remote_workspace_title": "Editar Conexão Remota", + "dashboard.empty_workspace": "Workspace vazio", + "dashboard.empty_workspace_desc": "Comece com uma pasta em branco e adicione o que precisar.", + "dashboard.error_choose_org": "Escolha uma organização antes de abrir um workspace.", + "dashboard.error_connect_worker": "Falha ao conectar a {name}.", + "dashboard.error_create_template": "Falha ao criar {name}.", + "dashboard.error_load_orgs": "Falha ao carregar organizações.", + "dashboard.error_load_shared_workspaces": "Falha ao carregar workspaces compartilhados.", + "dashboard.error_workspace_not_ready": "O workspace ainda não está pronto para conectar. Tente novamente em instantes.", + "dashboard.import_config": "Importar config", + "dashboard.importing": "Importando…", + "dashboard.modal_back": "Voltar", + "dashboard.modal_close": "Fechar modal de adicionar workspace", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "Nenhuma pasta selecionada ainda.", + "dashboard.open_cloud_dashboard": "Abrir painel da nuvem", + "dashboard.opening": "Abrindo...", + "dashboard.openwork_host_hint": "Use a URL fornecida pelo seu servidor OpenWork.", + "dashboard.openwork_host_label": "URL do servidor OpenWork", + "dashboard.openwork_host_placeholder": "https://seu-servidor.openwork.app", + "dashboard.openwork_host_token_hint": "Opcional. Cole um token de colaborador para acesso rotineiro ou um token de proprietário quando este cliente precisar responder a prompts de permissão.", + "dashboard.openwork_host_token_label": "Token de colaborador ou proprietário", + "dashboard.openwork_host_token_placeholder": "Cole seu token", + "dashboard.recently_updated": "Atualizados recentemente", + "dashboard.remote": "Remoto", + "dashboard.remote_base_url_required": "Adicione uma URL de servidor para continuar.", + "dashboard.remote_connection_direct": "Direto", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "Deixe em branco para usar o padrão do servidor.", + "dashboard.remote_directory_label": "Diretório do workspace (opcional)", + "dashboard.remote_directory_placeholder": "/home/equipe/projeto", + "dashboard.remote_display_name_label": "Nome de exibição (opcional)", + "dashboard.remote_display_name_placeholder": "Workspace da equipe de design", + "dashboard.remote_server_details_hint": "Conecte a um worker OpenWork auto-hospedado.", + "dashboard.remote_server_details_title": "Detalhes do servidor remoto", + "dashboard.remote_workspace_hint": "Acompanhe um servidor OpenWork e reconecte a qualquer momento.", + "dashboard.remote_workspace_title": "Workspace remoto", + "dashboard.repair_cache": "Reparar cache", + "dashboard.repairing_cache": "Reparando cache", + "dashboard.sandbox_checking_docker": "Verificando Docker...", + "dashboard.sandbox_get_ready_action": "Preparar o sistema", + "dashboard.sandbox_get_ready_desc": "Execute este workspace em um container Docker isolado para execuções mais seguras e reproduzíveis.", + "dashboard.sandbox_get_ready_title": "Sandboxes precisam do Docker", + "dashboard.sandbox_hide_logs": "Ocultar logs", + "dashboard.sandbox_live_logs": "Logs ao vivo", + "dashboard.sandbox_setup": "Configuração do sandbox", + "dashboard.sandbox_show_logs": "Mostrar logs", + "dashboard.search_shared_workspaces": "Buscar workspaces compartilhados", + "dashboard.select_folder": "Selecionar Pasta", + "dashboard.settings": "Configurações", + "dashboard.shared_workspaces_loading": "Carregando workspaces compartilhados…", + "dashboard.shared_workspaces_no_match": "Nenhum workspace compartilhado corresponde a essa busca.", + "dashboard.shared_workspaces_none": "Nenhum workspace compartilhado disponível ainda.", + "dashboard.shared_workspaces_refreshing": "Atualizando workspaces…", + "dashboard.skills": "Skills", + "dashboard.starter_workspace": "Workspace inicial", + "dashboard.starter_workspace_desc": "Pré-configurado para mostrar como usar plugins, comandos e skills.", + "dashboard.unknown_creator": "Criador desconhecido", + "dashboard.worker_status_attention": "Atenção", + "dashboard.worker_status_ready": "Pronto", + "dashboard.worker_status_starting": "Iniciando", + "dashboard.worker_status_stopped": "Parado", + "dashboard.worker_status_unknown": "Desconhecido", + "dashboard.worker_url_hint": "Cole a URL do worker OpenWork ao qual deseja conectar.", + "dashboard.worker_url_label": "URL do worker", + "dashboard.workspace_connect": "Conectar", + "dashboard.workspace_connect_unavailable": "Conectar workspaces compartilhados não está disponível aqui.", + "dashboard.workspace_connecting": "Conectando", + "dashboard.workspace_folder_hint": "Escolha onde este workspace ficará no seu dispositivo.", + "dashboard.workspace_folder_title": "Pasta do workspace", + "dashboard.workspace_not_ready_title": "Este workspace ainda não está pronto para conectar.", + "dashboard.workspaces": "Workspaces", + "den.active_org_hint": "Workers na nuvem e templates de equipe estão vinculados à organização selecionada.", + "den.active_org_title": "Organização ativa", + "den.auto_reconnect_hint": "Conclua a autenticação no navegador e o OpenWork reconectará aqui automaticamente.", + "den.checking_session": "Verificando sessão", + "den.choose_org_for_providers": "Escolha uma organização para ver provedores cloud.", + "den.choose_org_for_skill_hubs": "Escolha uma organização para ver hubs de skills cloud.", + "den.cloud_account_hint": "Gerencie sua conta conectada e organização.", + "den.cloud_account_title": "Conta Cloud", + "den.cloud_control_plane_open": "Abrir no navegador", + "den.cloud_control_plane_reset": "Redefinir", + "den.cloud_control_plane_save": "Salvar URL", + "den.cloud_control_plane_url_hint": "Apenas modo desenvolvedor. Use para apontar para um plano de controle Cloud local ou auto-hospedado. Alterá-lo desconecta você para que o app possa se re-hidratar no novo plano de controle.", + "den.cloud_control_plane_url_label": "URL do plano de controle Cloud", + "den.cloud_provider_detail": "{count} modelos · provedor {source}", + "den.cloud_provider_removed_detail": "Este provedor importado não está mais no cloud. Desinstale a configuração local {providerId}.", + "den.cloud_provider_sync_detail": "O provedor cloud mudou. Sincronize a configuração {source} com {count} modelos para o opencode.jsonc.", + "den.cloud_providers_hint": "Importe provedores LLM gerenciados para o opencode.jsonc e use a credencial da organização neste workspace.", + "den.cloud_providers_title": "Provedores cloud", + "den.cloud_section_desc": "Entre, escolha uma organização e abra workers Cloud ou templates de equipe.", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "Entre no OpenWork Cloud para manter suas tarefas ativas mesmo quando seu computador entrar em suspensão.", + "den.cloud_workers_hint": "Abra workers diretamente no OpenWork usando o mesmo fluxo de conexão remota que o app já usa.", + "den.cloud_workers_title": "Workers na nuvem", + "den.create_account": "Criar conta", + "den.credentials_ready_badge": "Credencial pronta", + "den.error_base_url": "Digite uma URL de plano de controle Cloud válida com http:// ou https://.", + "den.error_choose_org": "Escolha uma organização antes de abrir um worker.", + "den.error_load_orgs": "Falha ao carregar organizações.", + "den.error_load_workers": "Falha ao carregar workers.", + "den.error_no_session": "Nenhuma sessão Cloud ativa encontrada.", + "den.error_no_token": "Login no desktop concluído, mas o OpenWork Cloud não retornou um token de sessão.", + "den.error_open_worker": "Falha ao abrir {name} no OpenWork.", + "den.error_open_worker_fallback": "Falha ao abrir {name}.", + "den.error_paste_valid_code": "Cole um link de login do OpenWork válido ou um código de uso único.", + "den.error_signin_failed": "Falha ao concluir o login no OpenWork Cloud.", + "den.error_worker_not_ready": "O worker ainda não está pronto. Tente novamente após o provisionamento ser concluído.", + "den.finish_signin": "Concluir login", + "den.finishing": "Concluindo...", + "den.hide_signin_code": "Ocultar código de login", + "den.import_all": "Importar tudo", + "den.import_provider": "Importar", + "den.import_provider_failed": "Falha ao importar {name}.", + "den.imported_badge": "Importado", + "den.imported_provider": "{name} importado.", + "den.importing": "Importando…", + "den.needs_attention": "Requer atenção", + "den.no_cloud_providers": "Nenhum provedor cloud disponível para esta organização.", + "den.no_cloud_workers": "Nenhum worker na nuvem visível para esta organização ainda. Crie um no Cloud e atualize esta aba.", + "den.no_org_selected": "Nenhuma organização selecionada", + "den.no_skill_hubs": "Nenhum hub de skills cloud disponível para esta organização.", + "den.open": "Abrir", + "den.opening": "Abrindo...", + "den.org_member_suffix": "(Membro)", + "den.org_owner_suffix": "(Proprietário)", + "den.org_switched": "Alterado para {name}.", + "den.out_of_sync_badge": "Desatualizado", + "den.paste_signin_code": "Colar código de login", + "den.refresh": "Atualizar", + "den.reload_workspace": "Recarregue o workspace para aplicar as alterações.", + "den.remove_provider_failed": "Falha ao remover {name}.", + "den.removed_from_cloud_badge": "Removido do cloud", + "den.removed_provider": "{name} removido.", + "den.removing": "Removendo…", + "den.sign_out": "Sair", + "den.signed_out": "Desconectado", + "den.signin_button": "Entrar", + "den.signin_code_note": "Aceita um link openwork://den-auth ou o código de uso único.", + "den.signin_link_hint": "Se o navegador não redirecionar de volta para o OpenWork automaticamente, cole o link de login ou código de uso único do OpenWork Cloud aqui.", + "den.signin_link_label": "Link de login ou código de uso único", + "den.signin_link_placeholder": "openwork://den-auth?... ou código colado", + "den.signin_title": "Entrar no OpenWork Cloud", + "den.signing_in": "Concluindo login no OpenWork Cloud...", + "den.signing_out": "Saindo...", + "den.skill_hub_detail": "Importar {count} skills compartilhadas para .opencode/skills.", + "den.skill_hub_imported_detail": "Importadas {count} skills para este workspace.", + "den.skill_hub_removed_detail": "Este hub foi removido do cloud. Desinstale as {importedCount} skills importadas deste workspace.", + "den.skill_hub_skills_badge": "{count} skills", + "den.skill_hub_sync_detail": "O cloud agora tem {liveCount} skills; este workspace importou {importedCount}. Sincronize para atualizar.", + "den.skill_hubs_hint": "Importe todas as skills de um hub cloud compartilhado para este workspace de uma vez.", + "den.skill_hubs_title": "Hubs de skills", + "den.status_base_url_updated": "URL do plano de controle Cloud atualizada. Entre novamente para continuar.", + "den.status_browser_signin": "Conclua o login no navegador para conectar o OpenWork.", + "den.status_browser_signup": "Conclua a criação da conta no navegador para conectar o OpenWork.", + "den.status_cloud_signed_in_as": "OpenWork Cloud conectado como {email}.", + "den.status_cloud_signin_done": "OpenWork Cloud conectado.", + "den.status_loaded_orgs": "{count} org{plural} carregada{plural}.", + "den.status_loaded_workers": "{count} worker{plural} carregado{plural} para {name}.", + "den.status_no_workers": "Nenhum worker encontrado para {name}.", + "den.status_opened_worker": "{name} aberto no OpenWork.", + "den.status_signed_in_as": "Conectado como {email}.", + "den.status_signed_out": "Saiu do OpenWork Cloud e a sessão foi limpa neste dispositivo.", + "den.sync": "Sincronizar", + "den.sync_provider_failed": "Falha ao sincronizar {name}.", + "den.synced_provider": "{name} sincronizado.", + "den.syncing": "Sincronizando…", + "den.uninstall": "Desinstalar", + "den.worker_mine_badge": "Meu", + "den.worker_not_ready_title": "Este worker ainda não está pronto para abrir.", + "den.worker_provider_label": "Worker {provider}", + "den.worker_secondary_cloud": "Worker na nuvem", + "extensions.app_count_one": "{count} app conectado", + "extensions.app_count_many": "{count} apps conectados", + "extensions.apps_mcp_header": "Apps (MCP)", + "extensions.filter_all": "Todos", + "extensions.filter_apps": "Apps", + "extensions.filter_plugins": "Plugins", + "extensions.plugin_count_one": "{count} plugin", + "extensions.plugin_count_many": "{count} plugins", + "extensions.plugins_opencode_header": "Plugins (OpenCode)", + "extensions.subtitle": "Apps (MCP) e plugins OpenCode ficam em um só lugar.", + "extensions.title": "Extensões", + "identities.agent_behavior_desc": "Um arquivo por workspace. Adicione uma primeira linha opcional @agent para rotear via um agente OpenCode específico.", + "identities.agent_behavior_title": "Comportamento do agente de mensagens", + "identities.agent_created": "Arquivo padrão do agente de mensagens criado.", + "identities.agent_file_changed": "Arquivo alterado remotamente. Recarregue e salve novamente.", + "identities.agent_loading": "Carregando arquivo do agente…", + "identities.agent_none": "nenhum", + "identities.agent_not_found": "Arquivo do agente não encontrado neste workspace ainda.", + "identities.agent_saved": "Comportamento de mensagens salvo.", + "identities.agent_scope_status": "Escopo ativo: workspace · status: {status} · agente selecionado: {agent}", + "identities.agent_status_loaded": "carregado", + "identities.agent_status_missing": "ausente", + "identities.agent_worker_scope_unavailable": "Escopo do worker indisponível.", + "identities.all_channels": "Todos os canais", + "identities.app_token_label": "Token do app", + "identities.auto_bind_label": "Vincular peer ao diretório automaticamente ao enviar diretamente", + "identities.available_channels": "Canais disponíveis", + "identities.bot_token_label": "Token do bot", + "identities.bot_token_placeholder": "Cole o token do bot Telegram do @BotFather", + "identities.botfather_step1_open": "1. Abra o @BotFather no Telegram", + "identities.botfather_step1_run": "e execute /newbot", + "identities.botfather_step3_choose": "3. Escolha um nome e username para seu bot", + "identities.botfather_step3_or_private": "para caixa aberta ou", + "identities.botfather_step3_private": "Privado", + "identities.botfather_step3_public": "Público", + "identities.botfather_step3_to_require": "para exigir", + "identities.channel_label": "Canal", + "identities.channels_connected": "conectados", + "identities.channels_label": "Canais", + "identities.configured_suffix": "configurado", + "identities.connect_server_desc": "As identidades ficam disponíveis quando você está conectado a um servidor OpenWork.", + "identities.connect_server_title": "Conectar a um servidor OpenWork", + "identities.connect_slack": "Conectar Slack", + "identities.connected_badge": "Conectado", + "identities.connecting": "Conectando...", + "identities.copy_bot_token_hint": "Copie o token do bot e cole abaixo.", + "identities.copy_code": "Copiar código", + "identities.create_default_file": "Criar arquivo padrão", + "identities.create_private_bot": "Criar bot privado", + "identities.create_public_bot": "Criar bot público", + "identities.days_ago": "{days}d atrás", + "identities.default_routing": "Roteamento padrão", + "identities.directory_label": "Diretório (opcional)", + "identities.disable_messaging": "Desativar mensagens", + "identities.disable_messaging_message": "Isso desativará as mensagens para este workspace. A configuração do Telegram e Slack ficará oculta até que as mensagens sejam reativadas, e você precisará reiniciar o worker para parar completamente o sidecar de mensagens.", + "identities.disable_messaging_title": "Desativar mensagens para este worker?", + "identities.disabled_label": "Desativado", + "identities.disabling": "Desativando...", + "identities.disconnect": "Desconectar", + "identities.dispatched_messages": "{sent}/{attempted} mensagens despachadas.", + "identities.enable_messaging": "Ativar mensagens", + "identities.enable_messaging_risk": "Mensagens podem expor este worker a comandos remotos. Se o bot for público ou comprometido, pode acessar arquivos, credenciais e chaves de API disponíveis neste worker.", + "identities.enable_messaging_title": "Ativar mensagens para este worker?", + "identities.enabled_label": "Ativado", + "identities.enabling": "Ativando...", + "identities.health_offline": "Offline", + "identities.health_running": "Em execução", + "identities.health_unavailable": "Indisponível", + "identities.health_unknown": "Desconhecido", + "identities.hours_ago": "{hours}h atrás", + "identities.identities_label": "Identidades", + "identities.just_now": "Agora mesmo", + "identities.last_activity": "Última atividade", + "identities.later": "Depois", + "identities.message_label": "Mensagem", + "identities.message_routing_desc": "Controle quais conversas vão para qual pasta do workspace. Mensagens são roteadas para a pasta padrão do worker, a menos que você configure regras aqui.", + "identities.message_routing_title": "Roteamento de mensagens", + "identities.messages_today": "Mensagens hoje", + "identities.messaging_disabled_hint": "Ative as mensagens apenas se entender o risco e planejar proteger o acesso (por exemplo, emparelhamento privado do Telegram).", + "identities.messaging_disabled_restart": "Mensagens desativadas. Reinicie este worker para parar o sidecar de mensagens.", + "identities.messaging_disabled_risk": "Bots de mensagens podem executar ações contra seu worker local. Se expostos publicamente, podem permitir acesso a arquivos, credenciais e chaves de API disponíveis neste worker.", + "identities.messaging_disabled_title": "Mensagens desativadas por padrão", + "identities.messaging_enabled_restart": "Mensagens ativadas. Reinicie este worker para iniciar o sidecar de mensagens e desbloquear a configuração do Telegram e Slack.", + "identities.messaging_sidecar_not_running": "Mensagens estão ativadas neste workspace, mas o sidecar de mensagens ainda não está em execução. Reinicie este worker e volte às configurações de Mensagens para conectar o Telegram ou Slack.", + "identities.minutes_ago": "{minutes}m atrás", + "identities.not_set": "Não definido", + "identities.open_bot_link": "Abrir @{username} no Telegram", + "identities.pairing_code_copied": "Código de emparelhamento copiado.", + "identities.pairing_code_copy_failed": "Não foi possível copiar o código de emparelhamento. Copie manualmente.", + "identities.pairing_code_instruction_prefix": "Envie", + "identities.peer_id_label": "ID do peer (opcional)", + "identities.peer_id_placeholder_slack": "ex: slack:U12345678", + "identities.peer_id_placeholder_telegram": "ex: telegram:123456789", + "identities.private_label": "Privado", + "identities.private_pairing_code": "Código de emparelhamento privado", + "identities.public_bot_confirm": "Sim, entendo o risco", + "identities.public_bot_warning_message": "Seu bot será acessível ao público e qualquer pessoa que obter acesso ao bot terá acesso total ao seu worker local, incluindo arquivos e chaves de API que você concedeu. Se criar um bot privado, você pode limitar quem pode acessá-lo exigindo um token de emparelhamento. Tem certeza de que deseja tornar seu bot público?", + "identities.public_bot_warning_title": "Tornar este bot público?", + "identities.public_label": "Público", + "identities.quick_setup": "Configuração rápida", + "identities.reconnect_failed": "Falha ao reconectar. Verifique a URL/token do OpenWork e tente novamente.", + "identities.reconnected": "Reconectado.", + "identities.reconnected_refreshing": "Reconectado. Atualizando estado do worker...", + "identities.reload": "Recarregar", + "identities.repair_reconnect": "Reparar e reconectar", + "identities.restart_failed": "Falha ao reiniciar. Reinicie o worker nas Configurações e tente novamente.", + "identities.restart_to_disable_messaging": "Mensagens foram desativadas para este workspace. Reinicie o worker agora para parar o sidecar de mensagens.", + "identities.restart_to_enable_messaging": "Mensagens foram ativadas para este workspace. Reinicie o worker agora para iniciar o sidecar de mensagens e desbloquear a configuração do Telegram e Slack.", + "identities.restart_worker": "Reiniciar worker", + "identities.restart_worker_title": "Reiniciar worker agora?", + "identities.restarting": "Reiniciando...", + "identities.routing_override_prefix": "Todas as mensagens roteadas para", + "identities.routing_override_suffix": "(substituição ativa)", + "identities.running_label": "Em execução", + "identities.save_behavior": "Salvar comportamento", + "identities.saving": "Salvando...", + "identities.send_test_button": "Enviar mensagem de teste", + "identities.send_test_desc": "Validar a conexão de saída. Use um ID de peer para envio direto ou deixe o ID de peer vazio para distribuir por bindings em um diretório.", + "identities.send_test_title": "Enviar mensagem de teste", + "identities.sending": "Enviando...", + "identities.slack_desc": "Seu worker aparece como um bot nos canais do Slack. Membros da equipe podem enviar mensagens diretamente ou mencioná-lo em threads.", + "identities.slack_intro": "Conecte seu workspace do Slack para que membros da equipe interajam com este worker em canais e DMs.", + "identities.slack_unavailable": "Identidades do Slack indisponíveis.", + "identities.status_active": "Ativo", + "identities.status_label": "Status", + "identities.status_stopped": "Parado", + "identities.stopped_label": "Parado", + "identities.subtitle": "Permita que pessoas alcancem seu worker por apps de mensagens. Conecte um canal e seu worker lerá e responderá mensagens automaticamente.", + "identities.tab_general": "Geral", + "identities.telegram_bot_access_desc": "Bot público: o primeiro chat do Telegram vincula automaticamente. Bot privado: requer código de emparelhamento antes de qualquer mensagem executar ferramentas.", + "identities.telegram_delete_failed": "Falha ao excluir.", + "identities.telegram_deleted": "Excluído.", + "identities.telegram_deleted_pending": "Excluído (pendente aplicação).", + "identities.telegram_desc": "Conecte um bot Telegram em modo público (caixa aberta) ou modo privado (código de emparelhamento obrigatório).", + "identities.telegram_private_saved_pair": "Bot privado salvo. Emparelhe via /pair {code}", + "identities.telegram_save_failed": "Falha ao salvar.", + "identities.telegram_saved": "Salvo.", + "identities.telegram_saved_pending": "Salvo (pendente aplicação).", + "identities.telegram_saved_username": "Salvo (@{username})", + "identities.telegram_unavailable": "Identidades do Telegram indisponíveis.", + "identities.title": "Canais de mensagens", + "identities.unsaved_changes": "Alterações não salvas", + "identities.worker_offline": "Worker offline", + "identities.worker_online": "Worker online", + "identities.worker_restarted": "Worker reiniciado.", + "identities.worker_restarted_refreshing": "Worker reiniciado. Atualizando status de mensagens...", + "identities.worker_scope_unavailable": "Escopo do worker indisponível.", + "identities.worker_scope_unavailable_detail": "Escopo do worker indisponível. Reconecte usando uma URL de worker ou mude para um worker conhecido.", + "identities.worker_unavailable": "Worker indisponível", + "identities.workspace_id_required": "O ID do workspace é obrigatório para gerenciar identidades. Reconecte com uma URL de workspace ou selecione um workspace mapeado neste host.", + "identities.workspace_scope_prefix": "Escopo do workspace:", + "inbox_panel.connect_to_download": "Conecte a um worker para baixar arquivos compartilhados.", + "inbox_panel.connect_to_see": "Conecte para ver os arquivos compartilhados.", + "inbox_panel.connect_to_upload": "Conecte a um worker para enviar", + "inbox_panel.copy_failed": "Falha ao copiar. Seu navegador pode bloquear o acesso à área de transferência.", + "inbox_panel.download": "Baixar", + "inbox_panel.drop_to_upload": "Solte os arquivos aqui para enviar", + "inbox_panel.helper_text": "Compartilhe arquivos com este worker pelo app.", + "inbox_panel.load_failed": "Falha ao carregar a pasta compartilhada", + "inbox_panel.missing_file_id": "ID do arquivo compartilhado ausente.", + "inbox_panel.no_files": "Nenhum arquivo compartilhado ainda.", + "inbox_panel.refresh_tooltip": "Atualizar pasta compartilhada", + "inbox_panel.shared_folder": "Pasta compartilhada", + "inbox_panel.showing_first": "Exibindo os primeiros {count}.", + "inbox_panel.upload_failed": "Falha no envio para a pasta compartilhada", + "inbox_panel.upload_needs_worker": "Conecte a um worker para enviar arquivos para a pasta compartilhada.", + "inbox_panel.upload_prompt": "Solte arquivos ou clique para enviar", + "inbox_panel.upload_success": "Enviado para a pasta compartilhada.", + "inbox_panel.uploading": "Enviando...", + "inbox_panel.uploading_label": "Enviando {label}...", + "mcp.activate_button": "Ativar", + "mcp.add_modal_subtitle": "Conecte um servidor MCP personalizado por URL ou comando local.", + "mcp.add_modal_title": "Adicionar App Personalizado", + "mcp.add_server_button": "Adicionar app", + "mcp.advanced": "Avançado", + "mcp.advanced_settings": "Configurações avançadas", + "mcp.advanced_settings_hint": "Edite arquivos de configuração e gerencie conexões manualmente.", + "mcp.app_connected": "app conectado", + "mcp.apps_connected": "apps conectados", + "mcp.apps_subtitle": "Conecte suas ferramentas favoritas para que o OpenWork as use em seu nome.", + "mcp.apps_title": "Apps", + "mcp.auth.already_connected": "Já Conectado", + "mcp.auth.already_connected_description": "{server} já está autenticado e pronto para uso.", + "mcp.auth.applying_changes_body": "Estamos reiniciando o worker para que o novo MCP esteja pronto para autenticar.", + "mcp.auth.applying_changes_title": "Aplicando alterações antes do login", + "mcp.auth.authorization_link": "Link de autorização", + "mcp.auth.authorization_still_required": "A autorização ainda é necessária. Tente novamente para reiniciar o fluxo.", + "mcp.auth.callback_invalid": "Cole a URL de callback ou o parâmetro de código para concluir o OAuth.", + "mcp.auth.callback_label": "URL de callback ou código", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "Cancelar", + "mcp.auth.client_registration_required": "O registro do cliente é necessário antes de continuar com o OAuth.", + "mcp.auth.complete_connection": "Concluir conexão", + "mcp.auth.configured_previously": "O MCP pode ter sido configurado globalmente ou em uma sessão anterior. Você pode fechar este modal e começar a usar as ferramentas MCP imediatamente.", + "mcp.auth.connect_server": "Conectar {server}", + "mcp.auth.copied": "Copiado", + "mcp.auth.copy_link": "Copiar link", + "mcp.auth.done": "Concluído", + "mcp.auth.failed_to_start_oauth": "Falha ao iniciar fluxo OAuth", + "mcp.auth.follow_browser_steps": "Siga os passos de autorização no navegador.", + "mcp.auth.force_stop": "Forçar parada", + "mcp.auth.force_stopping": "Parando...", + "mcp.auth.im_done": "Terminei", + "mcp.auth.invalid_refresh_token": "O token de atualização OAuth é inválido ou expirou. Reautorize para continuar.", + "mcp.auth.manual_finish_hint": "Cole a URL de callback (localhost:19876) ou apenas o código para concluir a conexão.", + "mcp.auth.manual_finish_title": "Servidor remoto?", + "mcp.auth.oauth_completed_reload": "OAuth concluído. Recarregue o engine para ativar o MCP.", + "mcp.auth.oauth_failed": "Falha na autenticação OAuth.", + "mcp.auth.oauth_not_supported_hint": "Isso pode significar:\n• O servidor MCP não anuncia capacidades OAuth\n• O engine precisa recarregar para descobrir as capacidades do servidor\n• Tente: opencode mcp auth {server} pela CLI", + "mcp.auth.open_browser_signin": "Abriremos seu navegador para concluir o login.", + "mcp.auth.port_forward_hint": "Dica: encaminhe a porta de callback se necessário: ssh -L 19876:127.0.0.1:19876 user@host", + "mcp.auth.reauth_action": "Reautorizar OAuth", + "mcp.auth.reauth_cli_hint": "Execute: opencode mcp auth {server}", + "mcp.auth.reauth_failed": "Falha na reautorização.", + "mcp.auth.reauth_remote_hint": "Reautorize a partir da máquina que executa este worker.", + "mcp.auth.reauth_running": "Reautorizando...", + "mcp.auth.reload_blocked": "O recarregamento está pausado enquanto uma sessão está em execução. Pare a execução para concluir a configuração.", + "mcp.auth.reload_engine_retry": "Aplicar alterações e tentar novamente", + "mcp.auth.reload_failed": "Falha ao recarregar o worker antes do login.", + "mcp.auth.reload_notice": "Para isso ter efeito, o OpenWork precisa reiniciar o serviço worker. Isso pode interromper uma sessão em andamento.", + "mcp.auth.reload_remote_confirm": "Para isso ter efeito, o OpenWork precisa reiniciar o serviço worker. Isso pode parar sua sessão em andamento. Continuar?", + "mcp.auth.reopen_browser_link": "Clique aqui para reabrir o navegador", + "mcp.auth.request_timed_out": "Tempo da solicitação expirou.", + "mcp.auth.retry": "Tentar novamente", + "mcp.auth.retry_now": "Tentar Agora", + "mcp.auth.server_disabled": "Este servidor MCP está desativado. Ative-o e tente novamente.", + "mcp.auth.step1_description": "Iniciaremos o fluxo de login do {server} automaticamente.", + "mcp.auth.step1_title": "Abrindo seu navegador", + "mcp.auth.step2_description": "Entre e aprove o acesso quando solicitado.", + "mcp.auth.step2_title": "Autorizar o OpenWork", + "mcp.auth.step3_description": "Concluiremos a conexão assim que a autorização for completada.", + "mcp.auth.step3_title": "Volte aqui quando terminar", + "mcp.auth.try_reload_engine": "{message}. Tente recarregar o engine primeiro.", + "mcp.auth.waiting_authorization": "Aguardando a autorização ser concluída no seu navegador...", + "mcp.auth.waiting_for_conversation_body": "Vamos redirecioná-lo para autenticar assim que possível.", + "mcp.auth.waiting_for_conversation_title": "Aguardando conversa ser concluída", + "mcp.auth.waiting_for_session": "Aguardando {session} terminar", + "mcp.available_apps": "Apps disponíveis", + "mcp.cap_signin": "Login na conta", + "mcp.cap_tools": "Ferramentas de IA", + "mcp.config_file": "Arquivo de configuração", + "mcp.config_load_failed": "Não foi possível carregar o arquivo de configuração", + "mcp.config_not_loaded": "Ainda não carregado", + "mcp.config_source": "Da configuração", + "mcp.configured": "configurado", + "mcp.connect": "Conectar", + "mcp.connect_failed": "Não foi possível conectar. Tente novamente.", + "mcp.connect_server_first": "Conecte ao servidor primeiro.", + "mcp.connected": "Conectado", + "mcp.connected_badge": "Conectado", + "mcp.connecting": "Conectando...", + "mcp.connection_failed": "Problema de conexão, tente novamente", + "mcp.connection_type": "Conexão", + "mcp.control_chrome_browser_hint": "No Chrome 144 ou mais recente, faça isso primeiro:", + "mcp.control_chrome_browser_step_one": "Abra chrome://inspect/#remote-debugging.", + "mcp.control_chrome_browser_step_two": "Ative a depuração remota.", + "mcp.control_chrome_browser_step_three": "Permita conexões de depuração quando o Chrome solicitar.", + "mcp.control_chrome_browser_title": "1. Ativar acesso ao Chrome", + "mcp.control_chrome_connect": "Adicionar Control Chrome", + "mcp.control_chrome_docs": "Guia oficial do MCP", + "mcp.control_chrome_edit": "Editar configurações", + "mcp.control_chrome_profile_hint": "O Control Chrome normalmente abre um perfil separado do Chrome. Ative isso se quiser que o OpenWork reutilize a janela do Chrome que você já tem aberta.", + "mcp.control_chrome_profile_title": "2. Escolher qual Chrome usar", + "mcp.control_chrome_save": "Salvar configurações", + "mcp.control_chrome_setup_subtitle": "Ative o acesso ao Chrome e escolha se o OpenWork deve usar seu próprio perfil limpo ou se conectar ao Chrome que você já usa.", + "mcp.control_chrome_setup_title": "Configurar Control Chrome", + "mcp.control_chrome_toggle_hint": "Quando ativado, o OpenWork adiciona --autoConnect para que o MCP se conecte a uma instância do Chrome que você já iniciou.", + "mcp.control_chrome_toggle_label": "Usar meu perfil existente do Chrome", + "mcp.control_chrome_toggle_off": "O OpenWork abrirá um perfil separado do Chrome apenas para automação.", + "mcp.control_chrome_toggle_on": "O OpenWork reutilizará suas abas, cookies e logins atuais.", + "mcp.custom_app_cta_hint": "Conecte seu próprio servidor MCP, ferramenta interna ou app hospedado.", + "mcp.desktop_required": "Apps requerem o app desktop.", + "mcp.docs_link": "Saiba mais", + "mcp.file_not_found": "Arquivo de configuração ainda não criado", + "mcp.finish_setup": "Quase lá", + "mcp.finish_setup_hint": "Toque em Ativar para terminar de conectar seu app.", + "mcp.friendly_status_issue": "Problema", + "mcp.friendly_status_needs_signin": "Login necessário", + "mcp.friendly_status_offline": "Offline", + "mcp.friendly_status_paused": "Pausado", + "mcp.friendly_status_ready": "Pronto", + "mcp.last_synced": "Sincronizado", + "mcp.login_action": "Entrar", + "mcp.login_hint": "Conecte sua conta para terminar de configurar este app.", + "mcp.login_unavailable": "Este app não suporta login pelo OpenWork.", + "mcp.logout_action": "Sair", + "mcp.logout_failed": "Falha ao sair.", + "mcp.logout_hint": "Remove as credenciais OAuth armazenadas. Você precisará entrar novamente.", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "Isso removerá as credenciais OAuth armazenadas para {server}. Você precisará entrar novamente para usar este app.", + "mcp.logout_modal_title": "Sair deste app?", + "mcp.logout_success": "Saiu de {server}.", + "mcp.logout_working": "Saindo...", + "mcp.name_required": "Digite um nome para o servidor.", + "mcp.no_apps_hint": "Conecte um acima para começar.", + "mcp.no_apps_yet": "Nenhum app conectado ainda", + "mcp.oauth": "Entrar", + "mcp.oauth_optional_hint": "Usa OAuth no navegador para conectar sua conta.", + "mcp.oauth_optional_label": "Este app exige login", + "mcp.one_click_connect": "Conectar com um clique", + "mcp.open_file": "Abrir arquivo", + "mcp.opening_label": "Abrindo...", + "mcp.pick_workspace_error": "Escolha primeiro uma pasta de workspace.", + "mcp.pick_workspace_first": "Escolha primeiro uma pasta de workspace.", + "mcp.quick_connect_chrome_desc": "Controle abas do Chrome com automação de navegador.", + "mcp.quick_connect_chrome_title": "Controlar Chrome", + "mcp.quick_connect_context7_desc": "Pesquise docs de produto com contexto mais rico.", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "Planeje sprints e resolva tickets mais rápido.", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "Páginas, bancos de dados e docs de projeto sincronizados.", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "Acompanhe releases e resolva erros de produção.", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "Inspecione pagamentos, faturas e assinaturas.", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "Pare a tarefa em execução para ativar.", + "mcp.reload_banner_description": "Toque em Ativar para terminar de conectar seu app.", + "mcp.reload_banner_description_blocked": "Uma tarefa está em execução. Pare-a primeiro e então ative.", + "mcp.remote_workspace_url_hint": "Workers remotos se conectam mais rápido com servidores MCP baseados em URL.", + "mcp.remove_app": "Remover", + "mcp.remove_failed": "Não foi possível remover o app.", + "mcp.remove_modal_message": "Tem certeza que deseja remover {server}? Você pode adicioná-lo de volta a qualquer momento.", + "mcp.remove_modal_title": "Remover app", + "mcp.reveal_config_failed": "Não foi possível abrir o arquivo de configuração", + "mcp.reveal_in_finder": "Mostrar no Finder", + "mcp.scope_global": "Todos os workspaces", + "mcp.scope_project": "Este workspace", + "mcp.server_command": "Comando", + "mcp.server_command_hint": "O comando shell para iniciar o servidor.", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "Nome do app", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "Tipo", + "mcp.server_url": "URL do servidor", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "Login", + "mcp.tap_to_connect": "Toque para conectar", + "mcp.technical_details": "Detalhes técnicos", + "mcp.type_cloud": "Nuvem (entrar com sua conta)", + "mcp.type_local": "Local (roda neste dispositivo)", + "mcp.type_local_cmd": "Local (comando)", + "mcp.type_remote": "Remoto (URL)", + "mcp.url_or_command_required": "Digite uma URL para servidores remotos ou um comando para servidores locais.", + "mcp.your_apps": "Seus apps", + "message.tool_request_label": "Solicitação", + "message.tool_result_label": "Resultado", + "message.waiting_subagent": "Aguardando a transcrição do subagente chegar.", + "message_list.copy_message": "Copiar mensagem", + "message_list.open_session": "Abrir sessão", + "message_list.step_updates_progress": "Atualiza o progresso", + "message_list.subagent_loading_transcript": "Carregando transcrição", + "message_list.subagent_message_count": "{count} mensagem{plural}", + "message_list.subagent_running": "Em execução", + "message_list.subagent_session_fallback": "Sessão do subagente", + "message_list.subagent_type_task": "Tarefa de {agentType}", + "message_list.subagent_waiting_transcript": "Aguardando transcrição", + "message_list.tool_checked_url": "Verificou {url}", + "message_list.tool_checked_web_fallback": "Verificou página web", + "message_list.tool_delegate_agent": "Delegar {agent}", + "message_list.tool_delegate_task_fallback": "Delegar tarefa", + "message_list.tool_load_skill_fallback": "Carregar skill", + "message_list.tool_load_skill_named": "Carregar skill {name}", + "message_list.tool_read_todo": "Ler lista de tarefas", + "message_list.tool_reviewed_file": "Revisou {file}", + "message_list.tool_reviewed_file_fallback": "Revisou arquivo", + "message_list.tool_reviewed_files_fallback": "Revisou arquivos", + "message_list.tool_reviewed_path": "Revisou {path}", + "message_list.tool_run_command": "Executar {command}", + "message_list.tool_run_command_fallback": "Executar comando", + "message_list.tool_searched_code_fallback": "Pesquisou código", + "message_list.tool_searched_pattern": "Pesquisou {pattern}", + "message_list.tool_update_file": "Atualizar {file}", + "message_list.tool_update_file_fallback": "Atualizar arquivo", + "message_list.tool_update_todo": "Atualizar lista de tarefas", + "message_list.tool_updated_file": "Atualizou {file}", + "message_list.tool_updated_file_fallback": "Atualizou arquivo", + "model_behavior.desc_builtin": "Este modelo decide seu próprio caminho de raciocínio e não expõe perfis aqui.", + "model_behavior.desc_generic": "Usar o perfil {label}.", + "model_behavior.desc_high": "Dedicar mais tempo ao raciocínio antes de responder.", + "model_behavior.desc_high_anthropic": "Usar o orçamento padrão de extended thinking.", + "model_behavior.desc_low": "Usar um passo de raciocínio mais leve antes de responder.", + "model_behavior.desc_low_google": "Usar um orçamento de raciocínio mais leve para respostas rápidas.", + "model_behavior.desc_max": "Usar o perfil de raciocínio mais profundo do provedor.", + "model_behavior.desc_max_anthropic": "Usar o maior orçamento de extended thinking disponível.", + "model_behavior.desc_medium": "Equilibrar velocidade e profundidade de raciocínio.", + "model_behavior.desc_minimal": "Usar uma quantidade mínima de raciocínio.", + "model_behavior.desc_none": "Priorizar velocidade com o caminho de raciocínio mais leve.", + "model_behavior.desc_standard": "Este modelo não expõe controles de raciocínio extras.", + "model_behavior.label_balanced": "Equilibrado", + "model_behavior.label_builtin": "Integrado", + "model_behavior.label_deep": "Profundo", + "model_behavior.label_extended": "Estendido", + "model_behavior.label_fast": "Rápido", + "model_behavior.label_light": "Leve", + "model_behavior.label_maximum": "Máximo", + "model_behavior.label_quick": "Ágil", + "model_behavior.label_standard": "Padrão", + "model_behavior.title_builtin_reasoning": "Raciocínio integrado", + "model_behavior.title_extended_thinking": "Extended thinking", + "model_behavior.title_reasoning_budget": "Reasoning budget", + "model_behavior.title_reasoning_effort": "Reasoning effort", + "model_behavior.title_standard_generation": "Geração padrão", + "model_picker.chat_model_desc": "Escolha o modelo para este chat. Se o modelo suportar perfis de raciocínio, configure-os no cartão.", + "model_picker.chat_model_title": "Modelo do chat", + "model_picker.connect_provider_hint": "Conecte este provedor para explorar e salvar modelos", + "model_picker.default_model_desc": "Escolha o modelo padrão para novos chats e ajuste os perfis de raciocínio no cartão antes de clicar em Concluído.", + "model_picker.default_model_title": "Modelo padrão", + "model_picker.model_count": "{count} modelos", + "model_picker.model_count_one": "1 modelo", + "model_picker.more_providers": "Mais provedores", + "model_picker.no_results": "Nenhum modelo corresponde à sua busca.", + "model_picker.other_connected_models": "Outros modelos conectados", + "model_picker.recommended": "Recomendado", + "onboarding.access_label": "Acesso", + "onboarding.add": "Adicionar", + "onboarding.add_folder_path": "Adicionar caminho de pasta", + "onboarding.advanced_settings": "Configurações avançadas", + "onboarding.attach": "Conectar", + "onboarding.attach_description": "Conectar à sessão existente neste dispositivo.", + "onboarding.authorize_folder": "Autorizar pasta", + "onboarding.back": "Voltar", + "onboarding.checking_cli": "Verificando OpenCode CLI...", + "onboarding.choose_workspace_folder": "Escolher pasta do workspace", + "onboarding.cli_checking": "Verificando instalação...", + "onboarding.cli_install_commands": "Instale o OpenCode com um dos comandos abaixo e reinicie o OpenWork.", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "O OpenCode CLI precisa de uma atualização para o serve.", + "onboarding.cli_not_found": "OpenCode CLI não encontrado.", + "onboarding.cli_not_found_hint": "Não encontrado. Instale para executar o servidor local.", + "onboarding.cli_ready": "OpenCode CLI pronto.", + "onboarding.cli_recheck": "Verificar novamente", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "Instalado", + "onboarding.create_first_workspace": "Crie seu primeiro workspace", + "onboarding.create_workspace": "Criar um workspace", + "onboarding.engine_running": "Engine já em execução", + "onboarding.folders_allowed": "{count} pasta{plural} permitida{plural}", + "onboarding.getting_ready": "Preparando tudo", + "onboarding.install": "Instalar OpenCode", + "onboarding.install_instruction": "Instale o OpenCode para ativar o servidor local (sem terminal necessário).", + "onboarding.last_checked": "Última verificação {time}", + "onboarding.manage_access_hint": "Você pode gerenciar o acesso nas configurações avançadas.", + "onboarding.open_settings": "Abrir Configurações", + "onboarding.open_settings_hint": "Precisa de opções de engine ou acesso? Abra as Configurações.", + "onboarding.pick": "Selecionar", + "onboarding.ready_message": "O OpenCode está pronto para iniciar o servidor local.", + "onboarding.remember_choice": "Lembrar minha escolha para a próxima vez", + "onboarding.remote_workspace_action": "Conectar", + "onboarding.remote_workspace_card_description": "Conecte a um servidor OpenWork para acessar um workspace compartilhado.", + "onboarding.remote_workspace_card_title": "Conectar um workspace remoto", + "onboarding.remote_workspace_description": "Conecte a um servidor OpenWork para acessar um workspace de qualquer lugar.", + "onboarding.remote_workspace_title": "Conectar ao servidor OpenWork", + "onboarding.remove": "Remover", + "onboarding.resolved_path": "Caminho resolvido", + "onboarding.run_local": "Executar localmente", + "onboarding.run_local_description": "O OpenWork executa o OpenCode localmente e mantém seu trabalho privado.", + "onboarding.search_notes": "Notas de busca", + "onboarding.searching_host": "Conectando ao servidor OpenWork...", + "onboarding.serve_help": "saída de serve --help", + "onboarding.show_search_notes": "Mostrar notas de busca", + "onboarding.start": "Iniciar OpenWork", + "onboarding.starting_host": "Iniciando servidor OpenWork...", + "onboarding.theme_current": "Atual: {mode}", + "onboarding.theme_dark": "Escuro", + "onboarding.theme_label": "Tema", + "onboarding.theme_light": "Claro", + "onboarding.theme_system": "Sistema", + "onboarding.verifying": "Verificando handshake seguro", + "onboarding.version": "Versão", + "onboarding.welcome_title": "Como você quer executar o OpenWork hoje?", + "onboarding.windows_install_instruction": "Instale o OpenCode para Windows e reinicie o OpenWork. Certifique-se de que opencode.exe está no PATH.", + "onboarding.workspace_folder_label": "Um workspace é uma pasta com suas próprias skills, plugins e comandos.", + "plugins.add": "Adicionar", + "plugins.add_hint": "Adicione nomes de pacotes npm, ex: opencode-wakatime", + "plugins.add_label": "Adicionar plugin", + "plugins.added": "Adicionado", + "plugins.config": "Config", + "plugins.config_label": "Config", + "plugins.desc": "Gerenciar `opencode.json` para plugins do projeto ou globais do OpenCode.", + "plugins.empty": "Nenhum plugin configurado ainda.", + "plugins.enabled": "Ativado", + "plugins.hide_setup": "Ocultar configuração", + "plugins.not_loaded": "Ainda não carregado", + "plugins.not_loaded_yet": "Ainda não carregado", + "plugins.remove": "Remover", + "plugins.scope_global": "Global", + "plugins.scope_project": "Projeto", + "plugins.setup": "Configurar", + "plugins.suggested": "Plugins sugeridos", + "plugins.suggested_heading": "Plugins sugeridos", + "plugins.title": "Plugins OpenCode", + "providers.api_key_label": "Chave de API", + "providers.api_key_required": "A chave de API é obrigatória", + "providers.auth_failed": "Falha na autenticação", + "providers.connect_failed": "Falha ao conectar provedor", + "providers.disabled_in_config_suffix": "e desativado na configuração do OpenCode.", + "providers.disconnect_failed": "Falha ao desconectar provedor", + "providers.disconnected_prefix": "Desconectado", + "providers.load_failed": "Falha ao carregar provedores", + "providers.no_oauth_prefix": "Nenhum fluxo OAuth disponível para", + "providers.no_providers_available": "Nenhum provedor disponível", + "providers.not_connected": "Não conectado a um servidor", + "providers.not_oauth_flow_prefix": "O método de autenticação selecionado não é um fluxo OAuth para", + "providers.oauth_failed": "Falha ao concluir OAuth", + "providers.oauth_method_required": "O método OAuth é obrigatório", + "providers.provider_error": "Erro do provedor ({provider})", + "providers.provider_id_required": "O ID do provedor é obrigatório", + "providers.rate_limit_exceeded": "Limite de requisições excedido", + "providers.removal_unsupported": "A remoção da autenticação do provedor não é suportada por este cliente.", + "providers.request_failed": "Falha na requisição", + "providers.save_api_key_failed": "Falha ao salvar chave de API", + "providers.still_connected_suffix": ", mas o worker ainda reporta como conectado. Limpe qualquer chave de API ou credenciais OAuth restantes e reinicie o worker para desconectar completamente.", + "providers.unknown_provider": "Provedor desconhecido", + "providers.use_api_key_suffix": "Use uma chave de API em vez disso.", + "question_modal.custom_answer_label": "Ou digite uma resposta personalizada", + "question_modal.custom_answer_placeholder": "Digite sua resposta aqui...", + "question_modal.question_counter": "Pergunta {current} de {total}", + "session.allow_for_session": "Permitir para esta sessão", + "session.allow_once": "Permitir uma vez", + "session.api_key_saved": "Chave de API salva", + "session.attachments_add_token": "Adicione um token de servidor para anexar arquivos.", + "session.attachments_connect_server": "Conecte ao servidor OpenWork para anexar arquivos.", + "session.back": "Voltar", + "session.close_quick_actions": "Fechar ações rápidas", + "session.close_search": "Fechar busca", + "session.cmd_compact_detail": "Enviar uma instrução de compactação ao OpenCode para esta sessão", + "session.cmd_compact_detail_empty": "Nenhuma mensagem de usuário para compactar ainda", + "session.cmd_compact_meta": "Compactar", + "session.cmd_compact_title": "Compactar Conversa", + "session.cmd_current_workspace": "Workspace atual", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "Modelo", + "session.cmd_model_meta": "Abrir", + "session.cmd_model_title": "Alterar modelo", + "session.cmd_new_session_detail": "Iniciar uma nova tarefa no workspace atual", + "session.cmd_new_session_meta": "Criar", + "session.cmd_new_session_title": "Criar nova sessão", + "session.cmd_provider_detail": "Abrir fluxo de conexão do provedor", + "session.cmd_provider_meta": "Abrir", + "session.cmd_provider_title": "Conectar provedor", + "session.cmd_rename_detail_fallback": "Dê um nome mais claro à sessão selecionada", + "session.cmd_rename_meta": "Renomear", + "session.cmd_rename_title": "Renomear sessão atual", + "session.cmd_sessions_detail": "{count} disponíveis nos workspaces", + "session.cmd_sessions_meta": "Ir", + "session.cmd_sessions_title": "Buscar sessões", + "session.cmd_switch": "Alternar", + "session.compacted": "Sessão compactada.", + "session.compacting": "Compactando contexto da sessão...", + "session.compacting_auto": "O OpenCode está compactando esta sessão automaticamente", + "session.compacting_manual": "O OpenCode está compactando esta sessão", + "session.compaction_finished": "O OpenCode terminou de compactar o contexto da sessão.", + "session.compaction_started": "O OpenCode começou a compactar o contexto da sessão.", + "session.conflict_sync_toast": "Conflito ao sincronizar {path}. Alterações locais salvas em {conflictPath}.", + "session.connect_failed": "Falha na conexão", + "session.connect_to_sync": "Conecte ao servidor OpenWork para sincronizar arquivos remotos.", + "session.create_or_connect_workspace": "Criar ou conectar um workspace", + "session.create_workspace_desc": "Abra o criador de workspace e escolha como deseja começar.", + "session.create_workspace_title": "Criar workspace", + "session.default_agent": "Agente padrão", + "session.default_title": "Nova sessão", + "session.delete": "Excluir", + "session.delete_named_session_message": "Isso excluirá permanentemente \"{title}\" e suas mensagens.", + "session.delete_session_generic": "Isso excluirá permanentemente a sessão selecionada e suas mensagens.", + "session.delete_session_title": "Excluir sessão?", + "session.deleted": "Sessão excluída", + "session.deleting": "Excluindo...", + "session.deny": "Negar", + "session.details": "Detalhes", + "session.details_label": "Detalhes", + "session.doom_loop_label": "Loop Infinito", + "session.doom_loop_message": "O OpenCode detectou chamadas de ferramenta repetidas com a mesma entrada e está perguntando se deve continuar após falhas repetidas.", + "session.doom_loop_note": "Rejeite para parar o loop ou permita se quiser que o agente continue tentando.", + "session.doom_loop_repeated_call_label": "Chamada repetida", + "session.doom_loop_repeated_tool_call": "Chamada de ferramenta repetida", + "session.doom_loop_title": "Loop Infinito Detectado", + "session.doom_loop_tool_label": "Ferramenta", + "session.downloading": "Baixando", + "session.downloading_percent": "Baixando {percent}%", + "session.downloading_update_title": "Baixando atualização {version}", + "session.export_already_running": "Exportação já em andamento.", + "session.export_desktop_only": "Exportação disponível apenas no app desktop.", + "session.export_desktop_only_local": "Exportação disponível para workers locais no app desktop.", + "session.export_local_only": "Exportação disponível apenas para workers locais.", + "session.failed_to_compact": "Falha ao compactar sessão", + "session.failed_to_create_session": "Falha ao criar sessão", + "session.failed_to_delete": "Falha ao excluir sessão", + "session.failed_to_load_agents": "Falha ao carregar agentes", + "session.failed_to_load_providers": "Falha ao carregar provedores", + "session.failed_to_redo": "Falha ao refazer", + "session.failed_to_save_api_key": "Falha ao salvar chave de API", + "session.failed_to_stop": "Falha ao parar", + "session.failed_to_undo": "Falha ao desfazer", + "session.file_open_desktop_only": "Abrir arquivos está disponível no app desktop.", + "session.file_open_failed": "Falha ao abrir arquivo", + "session.file_open_remote_unavailable": "Abrir arquivos não está disponível para workspaces remotos.", + "session.flyout_file_modified": "Arquivo Modificado", + "session.flyout_new_task": "Nova Tarefa", + "session.install_update": "Instalar atualização", + "session.jump_to_latest": "Ir para o mais recente", + "session.jump_to_start": "Ir para o início da mensagem", + "session.load_earlier": "Carregar mensagens anteriores", + "session.loading_detail": "Buscando as últimas mensagens desta tarefa.", + "session.loading_earlier": "Carregando mensagens anteriores...", + "session.loading_session": "Carregando sessão", + "session.loading_title": "Carregando sessão", + "session.menu_label": "Menu", + "session.model": "Modelo", + "session.model_fallback": "Modelo", + "session.new_task": "Nova tarefa", + "session.next_match": "Próxima correspondência", + "session.no_matches": "Sem correspondências", + "session.no_matches_command": "Sem correspondências.", + "session.no_session_selected": "Nenhuma sessão selecionada", + "session.nothing_to_compact": "Nada para compactar ainda.", + "session.nothing_to_redo": "Nada para refazer.", + "session.nothing_to_retry": "Nada para tentar novamente ainda", + "session.nothing_to_undo": "Nada para desfazer ainda.", + "session.oauth_failed": "Falha no OAuth", + "session.obsidian_worker_relative_only": "Apenas arquivos relativos ao worker podem ser abertos no Obsidian.", + "session.open": "Abrir", + "session.palette_hint_navigate": "Setas para navegar", + "session.palette_hint_run": "Enter para executar · Esc para fechar", + "session.palette_placeholder_actions": "Buscar ações", + "session.palette_placeholder_sessions": "Buscar por título de sessão ou workspace", + "session.palette_title_actions": "Ações rápidas", + "session.palette_title_sessions": "Buscar sessões", + "session.permission_detail_command": "Comando", + "session.permission_detail_cwd": "Diretório de trabalho", + "session.permission_detail_description": "Descrição", + "session.permission_detail_diff": "Diff", + "session.permission_detail_file": "Arquivo", + "session.permission_detail_files": "Arquivos", + "session.permission_detail_agent": "Agente", + "session.permission_detail_parent_directory": "Diretório pai", + "session.permission_detail_path": "Caminho", + "session.permission_detail_query": "Consulta", + "session.permission_detail_target": "Destino", + "session.permission_detail_tool": "Ferramenta", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "Edição de arquivo", + "session.permission_kind_external_directory": "Diretório externo", + "session.permission_kind_question": "Pergunta", + "session.permission_kind_read": "Leitura de arquivo", + "session.permission_kind_skill": "Skill", + "session.permission_kind_task": "Subtarefa", + "session.permission_kind_todowrite": "Escrita de tarefas", + "session.permission_label": "Permissão", + "session.permission_message": "O OpenCode está solicitando permissão para continuar.", + "session.permission_message_bash": "Revise o escopo do comando antes de permitir que o OpenCode continue.", + "session.permission_message_edit": "Revise o arquivo e o diff antes de permitir que o OpenCode faça alterações.", + "session.permission_message_external_directory": "Revise a pasta antes de permitir acesso fora do workspace.", + "session.permission_message_read": "Revise o escopo de arquivos solicitado antes de permitir o acesso.", + "session.permission_message_task": "Revise a subtarefa solicitada antes de permitir que ela comece.", + "session.permission_metadata_unavailable": "Não foi possível exibir os metadados.", + "session.permission_required": "Permissão Necessária", + "session.permission_review_label": "Revisão", + "session.permission_scope_empty": "Nenhum escopo específico foi fornecido.", + "session.permission_decision_hint": "Permita uma vez para esta solicitação, ou pela sessão quando confiar neste escopo.", + "session.permission_title_bash": "Executar um comando de shell?", + "session.permission_title_edit": "Modificar arquivos?", + "session.permission_title_external_directory": "Acessar uma pasta externa?", + "session.permission_title_generic": "Aprovar {permission}?", + "session.permission_title_read": "Ler arquivos?", + "session.permission_title_task": "Iniciar uma subtarefa?", + "session.phase_responding": "Respondendo", + "session.phase_retrying": "Tentando novamente", + "session.phase_run_failed": "Execução falhou", + "session.phase_sending": "Enviando", + "session.pick_folder_desc": "Escolha uma pasta de projeto ou notas existente e o OpenWork a usará como workspace.", + "session.pick_folder_title": "Escolha uma pasta para trabalhar", + "session.pick_workspace_to_open": "Selecione um workspace para abrir arquivos.", + "session.prev_match": "Correspondência anterior", + "session.provider_auth_in_progress": "Autenticação do provedor já em andamento.", + "session.provider_connected": "Provedor conectado", + "session.quick_actions_label": "Ações rápidas", + "session.quick_actions_title": "Ações rápidas (Ctrl/Cmd+K)", + "session.redo_aria_label": "Refazer última mensagem revertida", + "session.redo_label": "Refazer", + "session.redo_title": "Refazer última mensagem revertida", + "session.remote_sync_failed": "Falha na sincronização de arquivos remotos", + "session.rename_description": "Atualizar o nome desta sessão.", + "session.rename_label": "Nome da sessão", + "session.rename_placeholder": "Digite um novo nome", + "session.rename_title": "Renomear sessão", + "session.resize_workspace_column": "Redimensionar coluna do workspace", + "session.restart_update_title": "Reinicie para aplicar a atualização {version}", + "session.restored_message": "Mensagem revertida restaurada.", + "session.reveal": "Mostrar", + "session.reveal_desktop_only": "Mostrar está disponível no app desktop.", + "session.revert_label": "Reverter", + "session.reverted_last_message": "Última mensagem do usuário revertida.", + "session.run": "Executar", + "session.scope_label": "Escopo", + "session.search_conversation_label": "Buscar na conversa", + "session.search_conversation_title": "Buscar na conversa (Ctrl/Cmd+F)", + "session.search_next": "Próximo", + "session.search_placeholder": "Buscar neste chat", + "session.search_position": "{current} de {total}", + "session.search_prev": "Anterior", + "session.share_active_cloud_org": "Organização Cloud ativa", + "session.share_choose_org": "Escolha uma organização em Configurações -> Cloud antes de compartilhar com sua equipe.", + "session.share_collaborator_hint": "Acesso remoto rotineiro quando você não precisa de ações exclusivas do proprietário.", + "session.share_collaborator_host_hint": "Acesso remoto rotineiro a este host sem ações exclusivas do proprietário.", + "session.share_collaborator_label": "Token de colaborador", + "session.share_collaborator_token": "Token de colaborador", + "session.share_connected_with_hint": "Este workspace está conectado com esta senha.", + "session.share_desktop_app_required": "Requer o app desktop", + "session.share_desktop_required": "Requer o app desktop", + "session.share_host_url_and_token_required": "URL do host OpenWork e token são obrigatórios.", + "session.share_local_host_not_ready": "O host local do OpenWork ainda não está pronto.", + "session.share_missing_host_url": "URL do host OpenWork ausente.", + "session.share_missing_token": "Token do OpenWork ausente.", + "session.share_no_skills": "Nenhuma skill encontrada neste workspace.", + "session.share_note_direct_runtime": "O runtime do engine está em modo Direto. Alternar workers locais pode reiniciar o host e desconectar clientes. O token pode mudar após a reinicialização.", + "session.share_opencode_base_url": "URL base do OpenCode", + "session.share_openwork_workers_only": "Links de compartilhamento estão disponíveis para workers OpenWork.", + "session.share_owner_permission_hint": "Use quando o cliente remoto precisar responder a prompts de permissão.", + "session.share_password": "Senha", + "session.share_password_owner_hint": "Use quando o cliente remoto precisar responder a prompts de permissão.", + "session.share_publish_skills_failed": "Falha ao publicar o conjunto de skills", + "session.share_publish_workspace_failed": "Falha ao publicar o perfil do workspace", + "session.share_resolve_local_workspace_failed": "Não foi possível resolver este workspace no host local do OpenWork.", + "session.share_resolve_remote_workspace_failed": "Não foi possível resolver este workspace no host do OpenWork.", + "session.share_save_team_template_failed": "Falha ao salvar template de equipe", + "session.share_saved_to_org": "{name} salvo em {org}.", + "session.share_select_workspace": "Selecione um workspace primeiro.", + "session.share_set_token_hint": "Definir token nas configurações do workspace", + "session.share_sign_in_required": "Entre no OpenWork Cloud em Configurações para compartilhar com sua equipe.", + "session.share_skills_set_desc": "Conjunto completo de skills de um workspace OpenWork.", + "session.share_starting_server": "Iniciando servidor...", + "session.share_team_fallback_name": "seus templates de equipe", + "session.share_url_resolving_hint": "URL do worker está sendo resolvida; URL do host exibida como alternativa.", + "session.share_url_worker_hint": "Use em celulares ou laptops conectando a este worker.", + "session.share_worker_url": "URL do worker", + "session.share_worker_url_phones_hint": "Use em celulares ou laptops conectando a este worker.", + "session.share_worker_url_resolving_hint": "URL do worker está sendo resolvida; URL do host exibida como alternativa.", + "session.shared_folder_upload_failed": "Falha no envio para a pasta compartilhada", + "session.show_earlier": "Mostrar {count} mensagem{plural} mais antiga{plural}", + "session.status_active": "Sessão Ativa", + "session.status_compacting": "Compactando Contexto", + "session.status_delegating": "Delegando", + "session.status_gathering_context": "Coletando contexto", + "session.status_planning": "Planejando", + "session.status_ready": "Pronto", + "session.status_ready_session": "Sessão Pronta", + "session.status_running_shell": "Executando shell", + "session.status_searching_codebase": "Pesquisando código", + "session.status_searching_web": "Pesquisando na web", + "session.status_thinking": "Pensando", + "session.status_working": "Trabalhando", + "session.status_writing_file": "Escrevendo arquivo", + "session.stopped": "Parado.", + "session.stopping_run": "Parando a execução...", + "session.todo_progress": "{completed} de {total} tarefas concluídas", + "session.trying_again": "Tentando novamente...", + "session.unable_to_open_file": "Não foi possível abrir o arquivo", + "session.unable_to_open_obsidian": "Não foi possível abrir o arquivo no Obsidian", + "session.unable_to_reveal": "Não foi possível revelar o workspace", + "session.undo_label": "Reverter", + "session.undo_title": "Desfazer última mensagem", + "session.update_available": "Atualização disponível", + "session.update_available_title": "Atualização disponível {version}", + "session.update_ready": "Atualização pronta", + "session.update_ready_stop_runs_title": "Atualização pronta {version}. Pare as execuções ativas para reiniciar.", + "session.upload_connect_server": "Conecte ao servidor OpenWork para enviar arquivos para a pasta compartilhada.", + "session.uploaded_to_shared_folder": "Enviado para a pasta compartilhada.", + "session.uploaded_with_summary": "Enviado para a pasta compartilhada: {summary}", + "session.uploading_to_shared_folder": "Enviando {label} para a pasta compartilhada...", + "session.workspace_fallback": "Workspace", + "session.workspace_label": "Workspace", + "session.workspace_path_unavailable": "Caminho do workspace não disponível.", + "session.workspace_setup_desc": "Comece com um workspace guiado do OpenWork ou escolha uma pasta existente.", + "session.workspace_setup_label": "Configuração do workspace", + "session.workspace_setup_title": "Configure seu primeiro workspace", + "settings.action_download": "Baixar", + "settings.action_install": "Instalar", + "settings.actor_host": "host", + "settings.actor_remote": "remoto", + "settings.actor_unknown": "desconhecido", + "settings.advanced": "Avançado", + "settings.advanced_title": "Avançado", + "settings.api_keys_info": "As chaves de API são armazenadas localmente pelo OpenCode. Provedores baseados em variáveis de ambiente devem ser alterados no ambiente do worker e recarregados.", + "settings.appearance_hint": "Seguir o sistema ou forçar modo claro/escuro.", + "settings.appearance_title": "Aparência", + "settings.audit_error": "Erro", + "settings.audit_loading": "Carregando", + "settings.audit_log_title": "Log de auditoria", + "settings.audit_ready": "Pronto", + "settings.auto_compact": "Compactação automática de contexto", + "settings.auto_compact_desc": "Controla a compactação automática do OpenCode para este workspace. Recarregue o engine após alterar.", + "settings.auto_update_desc": "Baixar atualizações automaticamente (solicita para", + "settings.auto_update_title": "Atualização automática", + "settings.available_count": "{count} disponíveis", + "settings.background_checks_desc": "O OpenWork sempre verifica ao iniciar. Também verifica uma vez", + "settings.background_checks_title": "Verificações em segundo plano", + "settings.base_url_unavailable": "URL base indisponível", + "settings.binary_unavailable": "Binário indisponível", + "settings.cache_nothing_to_repair": "Nenhum cache do OpenCode encontrado. Nada para reparar.", + "settings.cache_repair_requires_desktop": "O reparo de cache requer o app desktop", + "settings.cache_repaired": "Cache do OpenCode reparado. Reinicie o engine se estava em execução.", + "settings.cap_browser_tools": "Ferramentas do navegador: {value}", + "settings.cap_commands": "Comandos: {value}", + "settings.cap_config": "Config: {value}", + "settings.cap_file_tools": "Ferramentas de arquivo: {value}", + "settings.cap_inbox_off": "inbox desativado", + "settings.cap_inbox_on": "inbox ativado", + "settings.cap_mcp": "MCP: {value}", + "settings.cap_outbox_off": "outbox desativado", + "settings.cap_outbox_on": "outbox ativado", + "settings.cap_plugins": "Plugins: {value}", + "settings.cap_read": "leitura", + "settings.cap_sandbox": "Sandbox: {value}", + "settings.cap_skills": "Skills: {value}", + "settings.cap_write": "escrita", + "settings.capabilities_title": "Capacidades do servidor OpenWork", + "settings.capabilities_unavailable": "Capacidades indisponíveis. Conecte com um token de cliente.", + "settings.change": "Alterar", + "settings.check_update": "Verificar", + "settings.checking_for_updates": "Verificando atualizações", + "settings.choose": "Escolher", + "settings.clear": "Limpar", + "settings.clipboard_unavailable": "Área de transferência não disponível neste ambiente.", + "settings.configure": "Configurar", + "settings.connect_opencode_hint": "Conecte ao OpenCode para carregar provedores.", + "settings.connect_provider": "Conectar provedor", + "settings.connected_count": "{count} conectados", + "settings.connection": "Conexão", + "settings.connection_failed": "Falha na conexão", + "settings.connection_title": "Conexão", + "settings.copied_debug_report": "JSON do relatório de runtime copiado.", + "settings.copy_failed": "Falha ao copiar relatório de runtime.", + "settings.copy_json": "Copiar JSON", + "settings.custom_binary_hint": "Use para apontar o OpenWork para um build local do OpenCode", + "settings.custom_binary_label": "Binário personalizado do OpenCode", + "settings.data_dir_unavailable": "Diretório de dados indisponível", + "settings.debug_commit": "Commit: {sha}", + "settings.debug_desktop_app": "App desktop: {version}", + "settings.debug_opencode_version": "OpenCode: {version}", + "settings.debug_openwork_server_version": "Servidor OpenWork: {version}", + "settings.debug_section_title": "Desenvolvedor", + "settings.deeplink_failed": "Falha ao abrir deep link.", + "settings.deeplink_hint": "Aceita openwork://, openwork-dev:// ou uma URL https://share.openworklabs.com/b/... raw suportada.", + "settings.default_model": "Modelo padrão", + "settings.delete_containers": "Removendo containers...", + "settings.delete_local_config": "Removendo estado local...", + "settings.desktop_only_hint": "Disponível no app desktop.", + "settings.dev_mode_badge": "Modo dev", + "settings.developer": "Desenvolvedor", + "settings.developer_mode_desc": "Ativa ferramentas de depuração, diagnósticos e a aba Desenvolvedor.", + "settings.developer_mode_title": "Modo desenvolvedor", + "settings.developer_panel_disabled": "Painel de desenvolvedor desativado.", + "settings.developer_panel_enabled": "Painel de desenvolvedor ativado.", + "settings.devlog_cleared": "Log de desenvolvedor limpo.", + "settings.devlog_clipboard_unavailable": "Área de transferência não disponível neste ambiente.", + "settings.devlog_copied": "Log de desenvolvedor copiado.", + "settings.devlog_copy_failed": "Falha ao copiar log de desenvolvedor.", + "settings.devlog_export_failed": "Falha ao exportar log de desenvolvedor.", + "settings.devlog_export_unavailable": "Exportação não disponível neste ambiente.", + "settings.devlog_exported": "Log de desenvolvedor exportado.", + "settings.devtools_desc": "Saúde do sidecar, capacidades e trilha de auditoria.", + "settings.devtools_title": "Ferramentas de desenvolvimento", + "settings.diag_approval": "Aprovação: {mode} ({ms}ms)", + "settings.diag_config_path": "Caminho da config: {path}", + "settings.diag_daemon_url": "Daemon: {url}", + "settings.diag_default": "padrão", + "settings.diag_health_port": "Porta de saúde: {port}", + "settings.diag_healthy_ms": "Saudável: {ms}ms", + "settings.diag_host_token_source": "Origem do token do host: {source}", + "settings.diag_last_attempt": "Última tentativa: {time}", + "settings.diag_load_sessions_ms": "Carregar sessões: {ms}ms", + "settings.diag_opencode_binary": "Binário OpenCode: {binary}", + "settings.diag_opencode_url": "OpenCode: {url}", + "settings.diag_pending_permissions_ms": "Permissões pendentes: {ms}ms", + "settings.diag_pid": "PID: {pid}", + "settings.diag_providers_ms": "Provedores: {ms}ms", + "settings.diag_read_only": "Somente leitura: {value}", + "settings.diag_reason": "Motivo: {reason}", + "settings.diag_runtime_workspace": "Workspace de runtime: {id}", + "settings.diag_selected_workspace": "Workspace selecionado: {id}", + "settings.diag_sidecar": "Sidecar: {info}", + "settings.diag_started": "Iniciado: {time}", + "settings.diag_token_source": "Origem do token: {source}", + "settings.diag_total_ms": "Total: {ms}ms", + "settings.diag_version": "Versão: {version}", + "settings.diag_workspaces": "Workspaces: {count}", + "settings.diagnostics_unavailable": "Diagnósticos indisponíveis.", + "settings.disable_developer_mode": "Desativar Modo Desenvolvedor", + "settings.disabled": "Desativado", + "settings.disconnect": "Desconectar", + "settings.disconnect_confirm_suffix": "Desconectar {resolved}? Isso remove chaves de API ou credenciais OAuth armazenadas para este provedor.", + "settings.disconnect_server": "Desconectar servidor", + "settings.disconnected_prefix": "Desconectado {resolved}.", + "settings.disconnecting": "Desconectando...", + "settings.docker_containers_desc": "Forçar remoção de containers Docker iniciados pelo OpenWork", + "settings.docker_containers_title": "Containers Docker do OpenWork", + "settings.docker_requires_desktop": "A limpeza do Docker requer o app desktop", + "settings.done": "Concluído", + "settings.downloading_bytes": "Baixando {downloaded}", + "settings.downloading_progress": "Baixando {downloaded} / {total} ({percent}%)", + "settings.enable_developer_mode": "Ativar Modo Desenvolvedor", + "settings.enable_exa": "Ativar busca web Exa", + "settings.enable_exa_desc": "Aplica-se quando o OpenWork Orchestrator inicia o OpenCode.", + "settings.enabled": "Ativado", + "settings.engine_bundled": "Integrado (recomendado)", + "settings.engine_bundled_hint": "O engine integrado é a opção mais confiável. Use Sistema", + "settings.engine_custom_binary": "Binário personalizado", + "settings.engine_desc": "Escolha como o OpenCode roda localmente.", + "settings.engine_runtime_label": "Runtime do engine", + "settings.engine_source": "Fonte do engine", + "settings.engine_source_debug": "Fonte do engine", + "settings.engine_system_path": "Instalação do sistema (PATH)", + "settings.engine_title": "Engine", + "settings.environment.add_button": "Add variable", + "settings.environment.add_title": "Add environment variable", + "settings.environment.apply_button": "Apply changes", + "settings.environment.apply_blocked_active_tasks": "Stop running tasks before applying environment changes.", + "settings.environment.apply_confirm_body": "OpenWork will restart local agents so they can use the latest environment. Running local tasks may stop.", + "settings.environment.apply_no_local_workspace": "OpenWork is not connected to a local workspace.", + "settings.environment.apply_pending_body": "Apply changes to restart local agents and make the latest values available.", + "settings.environment.apply_pending_body_manual": "Restart local agents to make the latest values available.", + "settings.environment.apply_pending_title": "Changes are saved, not active yet", + "settings.environment.apply_refresh_failed": "Changes are active, but OpenWork status did not refresh. Reopen the app if it looks stale.", + "settings.environment.apply_success": "Environment changes are active.", + "settings.environment.apply_title": "Apply environment changes?", + "settings.environment.apply_unavailable": "Apply changes is only available in the desktop app.", + "settings.environment.applying": "Applying…", + "settings.environment.cancel": "Cancel", + "settings.environment.click_to_edit": "Click to edit", + "settings.environment.close_editor": "Close editor", + "settings.environment.confirm_delete": "Delete {key}? Agents stop seeing this key after you apply changes.", + "settings.environment.delete": "Delete", + "settings.environment.delete_title": "Delete environment variable", + "settings.environment.delete_variable": "Delete {key}", + "settings.environment.deleting": "Deleting…", + "settings.environment.description": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device; changes become available after you apply them.", + "settings.environment.edit_title": "Edit environment variable", + "settings.environment.empty_body": "Add keys like ANTHROPIC_API_KEY, GOOGLE_API_KEY, ELEVENLABS_API_KEY, or GITHUB_TOKEN for services your agents and MCP servers need.", + "settings.environment.empty_title": "No environment variables yet", + "settings.environment.empty_value": "(empty)", + "settings.environment.footer_hint": "OPENWORK_ and OPENCODE_ keys are reserved for app/runtime wiring. Configure OpenCode runtime settings from your shell.", + "settings.environment.hide": "Hide", + "settings.environment.hide_value": "Hide value for {key}", + "settings.environment.key_hint": "Letters, digits, and underscores. Cannot start with a digit.", + "settings.environment.key_label": "Key", + "settings.environment.loading": "Loading…", + "settings.environment.override_hint": "Environment variables set before OpenWork starts take precedence over values saved here.", + "settings.environment.remote_workspace_hint": "This workspace is remote. Local environment variables are hidden here; use cloud LLM Providers or configure the worker host directly.", + "settings.environment.restart_required": "Saved. Apply changes to make the update available.", + "settings.environment.reveal": "Reveal", + "settings.environment.reveal_value": "Reveal value for {key}", + "settings.environment.save": "Save", + "settings.environment.saving": "Saving…", + "settings.environment.title": "Environment variables", + "settings.environment.validation_duplicate": "A variable with this name already exists.", + "settings.environment.validation_empty": "Name is required.", + "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", + "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", + "settings.environment.value_label": "Value", + "settings.exa_restart_hint": "Reinicie o OpenCode ou o orchestrator após alterar esta configuração.", + "settings.export": "Exportar", + "settings.export_failed": "Falha ao exportar relatório de runtime.", + "settings.export_unavailable": "Exportação não disponível neste ambiente.", + "settings.exported_debug_report": "JSON do relatório de runtime exportado.", + "settings.failed": "Falhou", + "settings.failed_open_providers": "Falha ao abrir provedores", + "settings.feedback_badge": "Lemos cada mensagem", + "settings.feedback_desc": "Conte-nos o que está ótimo e o que está difícil. O feedback vai direto para a equipe e nos ajuda a priorizar o que lançamos a seguir.", + "settings.feedback_title": "Ajude a moldar o OpenWork", + "settings.group_global": "Global", + "settings.group_workspace": "Workspace", + "settings.hide_titlebar": "Ocultar barra de título", + "settings.hide_titlebar_desc": "Oculta a barra de título da janela. Útil para gerenciadores de janelas tipo tiling", + "settings.join_discord": "Entrar no Discord", + "settings.language": "Idioma", + "settings.language.description": "Escolha seu idioma preferido", + "settings.last_error": "Último erro", + "settings.last_stderr": "Último stderr", + "settings.last_stdout": "Último stdout", + "settings.loading_providers": "Carregando provedores...", + "settings.logs_on_host": "Logs estão disponíveis no host.", + "settings.managed_by_env": "Gerenciado por variável de ambiente", + "settings.messaging_bridge_service": "Serviço de bridge de mensagens.", + "settings.messaging_section_desc": "Gerencie identidades e bindings do Telegram/Slack na aba Identidades.", + "settings.messaging_section_title": "Mensagens", + "settings.model": "Modelo", + "settings.model_behavior": "Comportamento do modelo", + "settings.model_behavior_desc": "Abra o seletor de modelo padrão para escolher perfis de raciocínio quando disponíveis.", + "settings.model_default": "Padrão", + "settings.model_description": "Padrões e controles de raciocínio para execuções.", + "settings.model_description_default": "Escolha entre seus provedores configurados. Esta seleção será usada para novas sessões.", + "settings.model_description_session": "Escolha entre seus provedores configurados. Esta seleção se aplica à sua próxima mensagem.", + "settings.model_fallback": "Alternativa", + "settings.model_reasoning": "Raciocínio", + "settings.model_section_desc": "Escolha o modelo de chat padrão e veja como ele raciocina.", + "settings.model_title": "Modelo", + "settings.no_access": "sem acesso", + "settings.no_active_workspace": "Nenhum workspace local ativo.", + "settings.no_audit_entries": "Nenhuma entrada de auditoria ainda.", + "settings.no_binary_selected": "Nenhum binário selecionado.", + "settings.no_custom_path_set": "Nenhum caminho personalizado definido", + "settings.no_project_directory": "Nenhum diretório de projeto", + "settings.no_stderr": "Nenhum stderr capturado ainda.", + "settings.no_stdout": "Nenhum stdout capturado ainda.", + "settings.no_worker_directory": "Nenhum diretório de projeto", + "settings.no_worker_path": "Nenhum caminho de worker disponível", + "settings.nuke_confirm_dev": "Isso é irreversível. EXCLUIRÁ todos os dados do OpenWork para este build dev e toda config, auth, cache, dados e estado isolados do OpenCode dev, e fechará o OpenWork. Continuar?", + "settings.nuke_confirm_prod": "Isso é irreversível. EXCLUIRÁ todos os dados do OpenWork para este build dev e toda config, auth, cache, dados e estado isolados do OpenCode dev, e fechará o OpenWork. Continuar?", + "settings.nuke_failed": "Falha ao remover estado do OpenWork e OpenCode.", + "settings.nuke_hint": "Use apenas quando quiser resetar completamente o app desktop e o estado do runtime OpenCode.", + "settings.nuke_success": "Estado do OpenWork e OpenCode removido. O OpenWork está fechando...", + "settings.off": "Desativado", + "settings.offline": "Offline", + "settings.on": "Ativado", + "settings.open_deeplink_action": "Abrindo...", + "settings.open_deeplink_button": "Ocultar", + "settings.open_deeplink_desc": "Cole um deeplink ou URL compartilhada do OpenWork para abrir.", + "settings.open_deeplink_title": "Abrir Deeplink", + "settings.opencode_cache": "Cache do OpenCode", + "settings.opencode_cache_description": "Repara dados em cache usados para iniciar o engine. Seguro de executar.", + "settings.opencode_engine_desc": "Runtime local para agentes, ferramentas e provedores de modelos.", + "settings.opencode_engine_label": "Engine OpenCode", + "settings.opencode_engine_sidecar_desc": "Sidecar de execução local.", + "settings.opencode_sdk_desc": "Diagnósticos de conexão da interface.", + "settings.opencode_sdk_title": "Engine OpenCode", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "URL base indisponível", + "settings.opening": "Abrir deeplink", + "settings.openwork_config_sidecar_desc": "Sidecar de configuração e aprovações.", + "settings.openwork_diagnostics_title": "Diagnósticos do servidor OpenWork", + "settings.openwork_server_desc": "Plano de controle de sessão para sincronização do app, workers e acesso remoto.", + "settings.openwork_server_label": "Servidor OpenWork", + "settings.pending_permissions": "Permissões pendentes", + "settings.production_mode_badge": "Produção", + "settings.provider_default_desc": "Use o comportamento de raciocínio padrão integrado do modelo.", + "settings.provider_default_label": "Padrão do provedor", + "settings.provider_source_config": "Config", + "settings.provider_source_custom": "Personalizado", + "settings.provider_source_env": "Ambiente", + "settings.providers_desc": "Conecte serviços para modelos e ferramentas.", + "settings.providers_title": "Provedores", + "settings.quit_hint": "O OpenWork fecha imediatamente após a limpeza para que a próxima inicialização comece de um estado local limpo para este modo.", + "settings.recent_events": "Eventos recentes", + "settings.reconnect_failed": "Falha ao reconectar. Verifique a URL/token do servidor e tente novamente.", + "settings.reconnect_server": "Reconectando...", + "settings.reconnect_server_failed": "Falha ao reconectar o servidor OpenWork.", + "settings.reconnected": "Reconectado ao servidor OpenWork.", + "settings.reconnecting": "Reconectando...", + "settings.removing_containers": "Removendo containers...", + "settings.removing_local_state": "Removendo estado local...", + "settings.repair_cache": "Reparar cache", + "settings.repairing_cache": "Reparando cache", + "settings.report_issue": "Reportar um problema", + "settings.reset": "Redefinir", + "settings.reset_app_data": "Redefinir dados do app", + "settings.reset_app_data_description": "Mais agressivo. Apaga cache + dados do OpenWork.", + "settings.reset_app_data_title": "Redefinir dados do app", + "settings.reset_app_data_warning": "Apaga cache e dados do OpenWork neste dispositivo.", + "settings.reset_button": "Redefinir", + "settings.reset_cancel": "Cancelar", + "settings.reset_config_defaults": "Redefinindo...", + "settings.reset_config_failed": "Falha ao redefinir configurações do app.", + "settings.reset_confirm_button": "Redefinir e Reiniciar", + "settings.reset_confirmation_hint": "Digite {resetWord} para confirmar. O OpenWork será reiniciado.", + "settings.reset_confirmation_label": "Confirmação", + "settings.reset_confirmation_placeholder": "Digite RESET", + "settings.reset_onboarding": "Redefinir onboarding", + "settings.reset_onboarding_description": "Apaga as preferências do OpenWork e reinicia o app.", + "settings.reset_onboarding_title": "Redefinir onboarding", + "settings.reset_onboarding_warning": "Apaga preferências locais e marcadores de onboarding do workspace no OpenWork.", + "settings.reset_openwork_desc_dev": "Com o modo dev ativo, limpa apenas o estado isolado do OpenCode dev dentro de openwork-dev-data.", + "settings.reset_openwork_desc_prod": "Com o modo dev ativo, limpa apenas o estado isolado do OpenCode dev dentro de openwork-dev-data.", + "settings.reset_openwork_title": "Redefinir estado do OpenWork + OpenCode", + "settings.reset_recovery_desc": "Limpar dados ou reiniciar o fluxo de configuração.", + "settings.reset_recovery_title": "Redefinir e Recuperação", + "settings.reset_requires_confirm": "Requer digitar RESET e reiniciará o app.", + "settings.reset_startup": "Redefinir modo de inicialização padrão", + "settings.reset_startup_pref": "Redefinir preferência de inicialização", + "settings.reset_stop_active_runs": "Pare as execuções ativas antes de redefinir.", + "settings.resetting": "Redefinindo...", + "settings.restart_blocked_message": "O OpenWork precisa reiniciar para concluir esta atualização. Para não interromper seu trabalho atual, a instalação está pausada até que suas execuções ativas terminem ou sejam paradas.", + "settings.restart_failed": "Falha ao reiniciar. Verifique os logs e tente novamente.", + "settings.restart_opencode": "Reiniciando...", + "settings.restart_openwork_server": "Reiniciando...", + "settings.restart_server_failed": "Falha ao reiniciar o servidor local.", + "settings.restarted": "Servidor local reiniciado.", + "settings.restarting": "Reiniciando...", + "settings.reveal_config": "Revelar configuração", + "settings.reveal_config_failed": "Falha ao revelar configuração do workspace.", + "settings.reveal_config_requires_desktop": "Revelar configuração requer o app desktop", + "settings.revealed_workspace_config": "Configuração do workspace revelada.", + "settings.run_sandbox_probe": "Executando sonda...", + "settings.running_probe": "Executando sonda...", + "settings.runtime_applies_hint": "Aplica-se na próxima vez que o engine iniciar ou recarregar.", + "settings.runtime_debug_desc": "Snapshot de diagnósticos legível com exportação em um clique.", + "settings.runtime_debug_title": "Relatório de depuração do runtime", + "settings.runtime_desc": "Status do seu engine local e servidor OpenWork.", + "settings.runtime_direct": "Direto (OpenCode)", + "settings.runtime_title": "Runtime", + "settings.sandbox_error": "Erro", + "settings.sandbox_export_hint": "Use Exportar no relatório de depuração do runtime acima para", + "settings.sandbox_probe_desc": "Executa uma verificação temporária de inicialização do sandbox Docker e", + "settings.sandbox_probe_errors": "Sonda do sandbox concluída com erros.", + "settings.sandbox_probe_failed": "Sonda do sandbox falhou.", + "settings.sandbox_probe_success": "Sonda do sandbox bem-sucedida. Exporte o relatório de depuração para suporte.", + "settings.sandbox_probe_title": "Sonda do sandbox", + "settings.sandbox_ready": "Pronto", + "settings.sandbox_requires_desktop": "Sonda do sandbox requer o app desktop", + "settings.sandbox_result": "Resultado: {status}", + "settings.sandbox_run_id": "ID da execução: {id}", + "settings.sandbox_stop_runs_hint": "Pare as execuções ativas antes de sondar", + "settings.search_models": "Buscar modelos…", + "settings.select_binary": "Selecionar binário do OpenCode", + "settings.select_workspace_first": "Selecione um workspace local antes de revelar a configuração.", + "settings.send_feedback": "Enviar feedback", + "settings.service_restarts_desc": "Reiniciar serviços específicos do host sem sair desta", + "settings.service_restarts_title": "Reinicialização de serviços", + "settings.session_model": "Modelo", + "settings.show_model_reasoning": "Mostrar raciocínio do modelo", + "settings.show_model_reasoning_desc": "Expandir os traços de raciocínio na interface quando o modelo os expõe.", + "settings.showing_models": "Exibindo {count} de {total}", + "settings.sidecar_config_unavailable": "Configuração do sidecar indisponível", + "settings.startup": "Inicialização", + "settings.startup_local": "Iniciar servidor local", + "settings.startup_not_set": "Conectar ao servidor", + "settings.startup_remote_warning": "A preferência de inicialização é atualmente remota. Configurações do engine", + "settings.startup_reset_hint": "Apaga sua preferência salva e exibe a seleção de conexão", + "settings.startup_server": "Conectar ao servidor", + "settings.startup_title": "Inicialização", + "settings.stop_local_server": "Parar servidor local", + "settings.stop_runs_before_cleanup": "Pare as execuções ativas antes da limpeza", + "settings.stop_runs_before_reset_config": "Pare as execuções ativas antes de redefinir a configuração", + "settings.stop_runs_to_reset": "Pare as execuções ativas para redefinir", + "settings.switch": "Alternar", + "settings.tab_advanced": "Avançado", + "settings.tab_appearance": "Aparência", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "Depuração", + "settings.tab_description_advanced": "Inspecione a saúde do runtime, estado da conexão e controles para desenvolvedores.", + "settings.tab_description_appearance": "Ajuste a aparência do OpenWork no desktop, tema do sistema e decoração do app.", + "settings.tab_description_debug": "Revise diagnósticos do runtime, logs e utilitários de depuração de baixo nível.", + "settings.tab_description_den": "Gerencie sua conexão OpenWork Cloud, workers hospedados e acesso a workspaces.", + "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", + "settings.tab_description_extensions": "Gerencie apps MCP e plugins OpenCode para este workspace.", + "settings.tab_description_general": "Conecte provedores, escolha o modelo padrão, autorize pastas e controle o workspace OpenWork selecionado e sua conexão de runtime.", + "settings.tab_description_messaging": "Configure identidades do router e comportamento da caixa de entrada nas configurações do workspace.", + "settings.tab_description_model": "Ajuste o modelo padrão, comportamento do runtime e configurações de saída do assistente.", + "settings.tab_description_recovery": "Repare estado de migração, redefina padrões do workspace e recupere configurações locais.", + "settings.tab_description_skills": "Navegue, edite e instale skills sem sair das configurações.", + "settings.tab_description_updates": "Mantenha o app atualizado com verificações silenciosas em segundo plano e controles de instalação.", + "settings.tab_environment": "Environment", + "settings.tab_extensions": "Extensões", + "settings.tab_general": "Configurações", + "settings.tab_messaging": "Mensagens", + "settings.tab_model": "Modelo", + "settings.tab_recovery": "Recuperação", + "settings.tab_skills": "Skills", + "settings.tab_updates": "Atualizações", + "settings.theme_dark": "Escuro", + "settings.theme_light": "Claro", + "settings.theme_system": "Sistema", + "settings.theme_system_hint": "O modo sistema segue automaticamente a preferência do seu SO.", + "settings.toolbar_ready_to_install": "Pronto para instalar", + "settings.update": "Atualizar", + "settings.update_available": "Atualização disponível: v", + "settings.update_available_version": "Atualização disponível: v{version}", + "settings.update_check_button": "Verificar", + "settings.update_check_failed": "Falha na verificação de atualização", + "settings.update_checking": "Verificando...", + "settings.update_download_button": "Baixar", + "settings.update_downloading": "Baixando...", + "settings.update_error": "Falha na verificação de atualização", + "settings.update_install_button": "Instalar e Reiniciar", + "settings.update_last_checked": "Última verificação {time}", + "settings.update_published": "Publicado em {date}", + "settings.update_ready": "Pronto para instalar: v", + "settings.update_ready_version": "Pronto para instalar: v{version}", + "settings.update_uptodate": "Atualizado", + "settings.updates": "Atualizações", + "settings.updates_desc": "Manter o OpenWork atualizado.", + "settings.updates_desktop_only": "Atualizações estão disponíveis apenas no app desktop.", + "settings.updates_not_supported": "Atualizações não são suportadas neste ambiente.", + "settings.updates_title": "Atualizações", + "settings.version": "Versão", + "settings.versions_desc": "Informações de build do Sidecar + desktop.", + "settings.versions_title": "Versões", + "settings.window_appearance_desc": "Personalizar aparência da janela.", + "settings.worker_id_label": "Worker {id}", + "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "Config do workspace", + "settings.workspace_debug_events_label": "Eventos de depuração do workspace", + "settings.workspace_fallback_name": "Workspace", + "share.active_cloud_org": "Organização Cloud ativa", + "share.back_hint": "Voltar às opções de compartilhamento", + "share.chooser_subtitle": "Escolha como deseja compartilhar este workspace.", + "share.close_hint": "Fechar", + "share.cloud_signin_note": "O OpenWork Cloud abre no navegador e retorna aqui após o login.", + "share.collaborator_hint": "Acesso rotineiro sem aprovações de permissão.", + "share.connect_messaging_desc": "Use este workspace pelo Slack, Telegram e outros.", + "share.connect_messaging_title": "Conectar mensagens", + "share.connection_details_label": "Detalhes da conexão", + "share.copy_hint": "Copiar", + "share.copy_link_hint": "Copiar link", + "share.create_template_link": "Criar link de template", + "share.credentials_disabled_hint": "Ative o acesso remoto e clique em Salvar para reiniciar o worker e revelar os detalhes de conexão ativos deste workspace.", + "share.field_password": "Senha", + "share.field_worker_url": "URL do worker", + "share.hide_password": "Ocultar senha", + "share.included_in_template": "Incluído neste template", + "share.option_access_desc": "Revele os detalhes de conexão ativos necessários para acessar este workspace em execução de outra máquina.", + "share.option_access_title": "Acessar workspace remotamente", + "share.option_public_desc": "Crie um link de compartilhamento que qualquer pessoa pode usar para começar a partir deste template.", + "share.option_public_title": "Template público", + "share.option_team_title": "Compartilhar com a equipe", + "share.option_template_desc": "Empacote esta configuração para que outra pessoa possa começar do mesmo ambiente.", + "share.optional_collaborator": "Acesso de colaborador opcional", + "share.public_intro": "Compartilhe este workspace como um link de template público.", + "share.publishing": "Publicando...", + "share.regenerate_link": "Regenerar link", + "share.remote_access_desc": "Desativado por padrão. Ative apenas quando quiser que este worker seja acessível de outra máquina.", + "share.remote_access_disabled": "O acesso remoto está desativado.", + "share.remote_access_enabled": "O acesso remoto está ativado.", + "share.remote_access_title": "Acesso remoto", + "share.remote_save": "Salvar", + "share.remote_save_busy": "Salvando...", + "share.reveal_password": "Revelar senha", + "share.save_to_team": "Salvar para a equipe", + "share.saving": "Salvando...", + "share.setup": "Configuração", + "share.sign_in_to_share": "Entrar para compartilhar com a equipe", + "share.subtitle_access": "Revele os detalhes de conexão necessários para acessar este workspace de outra máquina.", + "share.team_intro": "Salve este template na organização ativa do OpenWork Cloud para que colegas possam abri-lo depois pelas configurações do Cloud.", + "share.template_intro": "Compartilhe uma configuração reutilizável sem conceder acesso ao vivo a este workspace em execução.", + "share.template_item_config": "Comandos e config", + "share.template_item_config_desc": "Comandos reutilizáveis mais configuração do OpenWork/OpenCode.", + "share.template_item_settings": "Configurações do workspace", + "share.template_item_settings_desc": "O perfil compartilhado do workspace e o comportamento padrão.", + "share.template_item_skills": "Skills incluídas", + "share.template_item_skills_desc": "Skills personalizadas salvas neste workspace.", + "share.template_name_label": "Nome do template", + "share.title": "Compartilhar workspace", + "share.view_access": "Acessar workspace remotamente", + "share.warning_basic": "Compartilhe apenas com pessoas de confiança. Essas credenciais concedem acesso ao vivo a este workspace.", + "share.warning_full": "Essas credenciais concedem acesso ao vivo a este workspace. Compartilhar este workspace remotamente pode permitir que qualquer pessoa com acesso à sua rede controle seu worker.", + "share.workspace_fallback": "Workspace", + "share.workspace_template_desc": "Compartilhe a configuração base e os padrões do workspace.", + "share.workspace_template_title": "Template de workspace", + "share_skill_destination.add_to_workspace": "Adicionar ao workspace", + "share_skill_destination.adding": "Adicionando...", + "share_skill_destination.confirm_busy": "Adicionando skill...", + "share_skill_destination.confirm_button": "Adicionar skill ao workspace", + "share_skill_destination.connect_remote": "Conectar workspace remoto", + "share_skill_destination.connect_remote_desc": "Vincular um host OpenWork e selecioná-lo na lista para importar esta skill.", + "share_skill_destination.connect_remote_hint": "Vincule um host OpenWork e selecione-o na lista para importar esta skill.", + "share_skill_destination.create_worker": "Criar novo workspace", + "share_skill_destination.create_worker_desc": "Abrir o fluxo de configuração do workspace e adicionar esta skill após o novo workspace estar pronto.", + "share_skill_destination.create_worker_hint": "Abra o fluxo de configuração do workspace e adicione esta skill após o novo workspace estar pronto.", + "share_skill_destination.current_badge": "Atual", + "share_skill_destination.existing_workers": "Workspaces existentes", + "share_skill_destination.fallback_skill_name": "Skill compartilhada", + "share_skill_destination.footer_idle": "Escolha um workspace para continuar.", + "share_skill_destination.footer_selected": "Workspace selecionado:", + "share_skill_destination.local_badge": "Local", + "share_skill_destination.more_options": "Mais opções", + "share_skill_destination.new_destination": "Novo destino", + "share_skill_destination.no_workers": "Nenhum workspace está pronto ainda. Crie um ou conecte um workspace remoto para instalar esta skill.", + "share_skill_destination.remote_badge": "Remoto", + "share_skill_destination.sandbox_badge": "Sandbox", + "share_skill_destination.selected_badge": "Selecionado", + "share_skill_destination.selected_hint": "Selecionado. Revise o destino abaixo e confirme.", + "share_skill_destination.skill_label": "Skill compartilhada", + "share_skill_destination.subtitle": "Escolha um workspace existente ou crie um novo antes de importar esta skill compartilhada.", + "share_skill_destination.title": "Para onde esta skill deve ir?", + "share_skill_destination.trigger_label": "Gatilho", + "sidebar.active": "Ativo", + "sidebar.add_workspace": "Adicionar novo workspace", + "sidebar.collapse": "Recolher", + "sidebar.connect_remote": "Conectar remoto", + "sidebar.delete_session": "Excluir sessão", + "sidebar.drag_reorder": "Arrastar para reordenar", + "sidebar.edit_connection": "Editar conexão", + "sidebar.expand": "Expandir", + "sidebar.import_config": "Importar config", + "sidebar.needs_attention": "Requer atenção", + "sidebar.new_worker": "Novo worker", + "sidebar.no_workspaces": "Nenhum workspace nesta sessão ainda. Adicione um para começar.", + "sidebar.progress": "Progresso", + "sidebar.show_fewer": "Mostrar menos", + "sidebar.show_more": "Mostrar mais {count}", + "sidebar.stop_sandbox": "Parar sandbox", + "sidebar.switch": "Alternar", + "sidebar.test_connection": "Testar conexão", + "skills.add_custom_repo": "Adicionar repo GitHub personalizado", + "skills.add_git_repo": "Adicionar repo git", + "skills.add_openwork_hub": "Adicionar OpenWork Hub", + "skills.available_from_hub": "Disponível no Hub", + "skills.catalog_search_placeholder": "Buscar skills instaladas, de equipe e de hub", + "skills.cloud_add_skill": "Adicionar skill", + "skills.cloud_choose_org_detail": "Use o painel Cloud para escolher sua organização ativa e atualize esta lista.", + "skills.cloud_choose_org_hint": "Escolha uma organização em Configurações → Cloud para carregar skills da equipe.", + "skills.cloud_footer_label": "Equipe", + "skills.cloud_hub_label": "Hub: {name}", + "skills.cloud_install_need_server": "Conecte a um servidor OpenWork com acesso de escrita a skills para instalar skills de equipe neste worker.", + "skills.cloud_installed": "{name} instalado neste worker.", + "skills.cloud_installing": "Instalando {title}…", + "skills.cloud_installing_short": "Instalando", + "skills.cloud_no_search_matches": "Nenhuma skill corresponde a essa busca.", + "skills.cloud_org_empty": "Nenhuma skill da organização disponível ainda.", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "Falha ao carregar skills da organização.", + "skills.cloud_refresh": "Atualizar skills da equipe", + "skills.cloud_section_subtitle": "Skills compartilhadas com você pelo OpenWork Cloud — incluindo hubs de skills da equipe que você pode acessar.", + "skills.cloud_section_title": "Da sua organização", + "skills.cloud_shared_org": "Org", + "skills.cloud_shared_public": "Público", + "skills.cloud_sign_in": "Entrar no Cloud", + "skills.cloud_sign_in_hint": "Entre no OpenWork Cloud para navegar por skills de equipe e organização.", + "skills.copy_link_failed": "Falha ao copiar link", + "skills.create_in_chat": "Criar skill no chat", + "skills.desktop_required": "O gerenciamento de skills requer o app desktop.", + "skills.enter_plugin_name": "Digite o nome do pacote do plugin.", + "skills.failed_load_active": "Falha ao carregar plugins ativos.", + "skills.failed_load_opencode": "Falha ao carregar opencode.json", + "skills.failed_parse_opencode": "Falha ao processar opencode.json", + "skills.failed_to_load": "Falha ao carregar skills", + "skills.failed_update_opencode": "Falha ao atualizar opencode.json", + "skills.filter_all": "Todas", + "skills.filter_cloud": "Equipe", + "skills.filter_hub": "Hub", + "skills.filter_installed": "Instaladas", + "skills.from_repo": "De {owner}/{repo}", + "skills.github_repo_hint": "Insira um repo GitHub no formato owner/repo.", + "skills.host_mode_only": "Apenas workspace local", + "skills.host_only_error": "O gerenciamento de skills requer um workspace local ou servidor OpenWork conectado.", + "skills.hub_desc": "Navegue por skills compartilhadas de hubs no GitHub e adicione-as a este worker.", + "skills.hub_label": "Hub", + "skills.import": "Importar", + "skills.import_failed": "Falha na importação ({status})", + "skills.import_local": "Importar skill local", + "skills.import_local_hint": "Copiar uma pasta de skill existente para este workspace.", + "skills.import_local_skill": "Importar skill local", + "skills.imported": "Importado.", + "skills.install": "Instalar", + "skills.install_failed": "Falha na instalação da skill.", + "skills.install_name_title": "Instalar {name}", + "skills.install_skill_creator": "Instalar criador de skills", + "skills.install_skill_creator_hint": "Esta skill permite criar outras skills diretamente pelo chat.", + "skills.installed": "Skills instaladas", + "skills.installed_desc": "Skills instaladas vivem neste worker e podem ser editadas ou compartilhadas.", + "skills.installed_label": "Instalado", + "skills.installed_status": "Instalado", + "skills.installing": "Adicionar skill", + "skills.installing_prefix": "Instalando {name}…", + "skills.installing_skill_creator": "Instalando criador de skills...", + "skills.link_copied": "Link copiado", + "skills.loading": "Carregando…", + "skills.no_description": "Sem descrição ainda.", + "skills.no_hub_repo_label": "Nenhum repo de hub selecionado", + "skills.no_hub_repo_selected": "Nenhuma skill de hub disponível.", + "skills.no_hub_skills": "Nenhum repo de hub selecionado. Adicione um repo GitHub para navegar por skills.", + "skills.no_opencode_found": "Nenhum opencode.json encontrado ainda. Adicione um plugin para criar um.", + "skills.no_opencode_workspace": "Nenhum opencode.json neste workspace ainda.", + "skills.no_skills": "Nenhuma skill detectada em `.opencode/skills`, `.claude/skills` ou `~/.agents/skills`.", + "skills.no_skills_found": "Nenhuma skill encontrada ainda.", + "skills.owner_label": "Proprietário", + "skills.owner_repo_required": "Proprietário e repo são obrigatórios.", + "skills.pick_project_first": "Escolha primeiro uma pasta de projeto.", + "skills.pick_project_for_active": "Escolha uma pasta de projeto para carregar os plugins ativos.", + "skills.pick_project_for_plugins": "Escolha uma pasta de projeto para gerenciar os plugins do projeto.", + "skills.pick_workspace_first": "Escolha primeiro uma pasta de workspace.", + "skills.plugin_already_listed": "Plugin já listado no opencode.json.", + "skills.plugin_management_host_only": "O gerenciamento de plugins requer o app desktop.", + "skills.plugins_host_only": "Plugins estão disponíveis apenas no app desktop.", + "skills.ref_label": "Ref (branch/tag/commit)", + "skills.refresh": "Atualizar", + "skills.refresh_hub": "Atualizar hub", + "skills.refresh_hub_title": "Atualizar catálogo do hub", + "skills.remove_saved_repo": "Remover repo salvo", + "skills.repo_label": "Repo", + "skills.reveal_failed": "Falha ao abrir a pasta de skills.", + "skills.reveal_folder": "Abrir pasta de skills", + "skills.reveal_folder_hint": "Abrir o diretório de skills no Finder.", + "skills.save_and_load": "Salvar e carregar", + "skills.save_failed": "Falha ao salvar skill.", + "skills.select_skill_folder": "Selecionar pasta da skill", + "skills.share_back": "Voltar", + "skills.share_chooser_subtitle": "Salve na sua organização OpenWork Cloud ou publique um link de instalação público.", + "skills.share_close": "Fechar", + "skills.share_copy_link": "Copiar", + "skills.share_done": "Concluído", + "skills.share_option_public_desc": "Crie um link para qualquer pessoa instalar esta skill.", + "skills.share_option_public_title": "Link público", + "skills.share_option_team_desc": "Adicione esta skill à organização ativa do OpenWork Cloud.", + "skills.share_option_team_title": "Compartilhar com a equipe", + "skills.share_public_create": "Criar link", + "skills.share_public_creating": "Publicando…", + "skills.share_public_intro": "Publique um link público. Qualquer pessoa com a URL pode instalar esta skill.", + "skills.share_public_regenerate": "Regenerar link", + "skills.share_publisher_label": "Publicador", + "skills.share_subtitle_public": "Qualquer pessoa com o link pode instalar esta skill.", + "skills.share_subtitle_team": "Salva na sua organização para a equipe.", + "skills.share_team_choose_org": "Escolha uma organização em Configurações → Cloud antes de compartilhar.", + "skills.share_team_hub_label": "Adicionar ao hub de skills (opcional)", + "skills.share_team_hub_none": "Somente organização — sem hub", + "skills.share_team_hubs_loading": "Carregando hubs…", + "skills.share_team_intro": "Salve na organização ativa para a equipe instalar pelo Cloud.", + "skills.share_team_org_fallback": "Organização Cloud ativa", + "skills.share_team_save": "Salvar para a equipe", + "skills.share_team_saving": "Salvando…", + "skills.share_team_sign_in": "Entrar para compartilhar com a equipe", + "skills.share_team_sign_in_hint": "O OpenWork Cloud abre no navegador. Volte aqui após entrar.", + "skills.share_team_success": "Salvo em {org}. A equipe pode instalar pelas skills da organização.", + "skills.share_title": "Compartilhar skill", + "skills.shown_count": "{count} exibidas", + "skills.skill_creator_already_installed": "O criador de skills já está instalado.", + "skills.skill_creator_installed": "Criador de skills instalado.", + "skills.skill_load_failed": "Falha ao carregar skill.", + "skills.source_label": "Fonte", + "skills.subtitle": "Gerenciar skills para este workspace.", + "skills.title": "Skills", + "skills.trigger_label": "Gatilho: {trigger}", + "skills.uninstall": "Desinstalar", + "skills.uninstall_failed": "Falha ao desinstalar a skill.", + "skills.uninstall_title": "Desinstalar skill?", + "skills.uninstall_warning": "Isso excluirá permanentemente a skill `{name}` do seu workspace.", + "skills.uninstalled": "Skill removida.", + "skills.unknown_error": "Erro desconhecido", + "skills.worker_profile_desc": "Skills são as habilidades principais deste worker. Descubra-as no Hub, gerencie as instaladas e crie novas diretamente no chat.", + "status.back": "Voltar à tela anterior", + "status.connected": "Conectado", + "status.connecting": "Conectando", + "status.creating_task": "Criando nova tarefa", + "status.creating_workspace": "Criando workspace", + "status.developer_mode": "Modo desenvolvedor", + "status.disconnected": "Desconectado", + "status.disconnected_hint": "Abra as configurações para reconectar", + "status.disconnected_label": "Desconectado", + "status.disconnecting": "Desconectando", + "status.docs": "Docs", + "status.feedback": "Feedback", + "status.idle": "Ocioso", + "status.installing_opencode": "Instalando OpenCode", + "status.limited_hint": "Reconecte para restaurar todos os recursos do OpenWork", + "status.limited_mcp_hint": "{count} MCP conectados · reconecte para recursos completos", + "status.limited_mode": "Modo Limitado", + "status.live": "Ao vivo", + "status.loading_session": "Carregando sessão", + "status.mcp_connected": "{count} MCP conectados", + "status.open_docs": "Abrir documentação", + "status.openwork_ready": "OpenWork Pronto", + "status.providers_connected": "{count} provider{plural} conectado{plural}", + "status.ready_for_tasks": "Pronto para novas tarefas", + "status.reloading_engine": "Recarregando engine", + "status.restarting_engine": "Reiniciando engine", + "status.running": "Em execução", + "status.send_feedback": "Enviar feedback", + "status.settings": "Configurações", + "status.starting_engine": "Iniciando engine", + "system.cache_repair_requires_desktop": "Reparo de cache requer o app desktop.", + "system.docker_cleanup_requires_desktop": "Limpeza do Docker requer o app desktop.", + "system.reload_body_agents": "O OpenCode carrega agents na inicialização. Recarregue o engine para disponibilizar agents atualizados.", + "system.reload_body_commands": "O OpenCode carrega comandos na inicialização. Recarregue o engine para disponibilizar comandos atualizados.", + "system.reload_body_config": "O OpenCode lê opencode.json na inicialização. Recarregue o engine para aplicar mudanças de configuração.", + "system.reload_body_default": "O OpenWork detectou mudanças que exigem recarregar a instância do OpenCode.", + "system.reload_body_mcp": "O OpenCode carrega servidores MCP na inicialização. Recarregue o engine para ativar a nova conexão.", + "system.reload_body_mixed": "O OpenWork detectou mudanças na configuração do OpenCode. Recarregue o engine para aplicá-las.", + "system.reload_body_plugins": "O OpenCode carrega plugins npm na inicialização. Recarregue o engine para aplicar mudanças no opencode.json.", + "system.reload_body_skills": "O OpenCode pode cachear a descoberta de skills. Recarregue o engine para disponibilizar skills recém-instaladas.", + "system.reload_failed": "Falha ao recarregar o engine.", + "system.reload_required": "Recarregamento necessário", + "system.reload_unavailable": "Recarregamento não disponível para este worker.", + "system.stop_active_runs_before_reset": "Pare as execuções ativas antes de redefinir.", + "system.stop_runs_before_update": "Pare as execuções ativas antes de instalar uma atualização.", + "system.updates_not_supported": "Atualizações não são suportadas neste ambiente.", + "time.hours_ago": "{count}h atrás", + "time.just_now": "agora mesmo", + "time.minutes_ago": "{count}min atrás", + "time.seconds_ago": "{count}s atrás", + "workspace.loading_tasks": "Carregando tarefas...", + "workspace.local_badge": "Local", + "workspace.new_task_inline": "+ Nova tarefa", + "workspace.no_tasks": "Nenhuma tarefa ainda.", + "workspace.remote_badge": "Remoto", + "workspace.rename_description": "Atualizar o nome exibido na barra lateral.", + "workspace.rename_label": "Nome do workspace", + "workspace.rename_placeholder": "Workspace da equipe de design", + "workspace.rename_title": "Editar nome do workspace", + "workspace.sandbox_badge": "Sandbox", + "workspace.selected": "Selecionado", + "workspace.switch": "Alternar", + "workspace.switching_status_connecting": "Verificando sua conexão", + "workspace.switching_status_loading": "Carregando tarefas recentes", + "workspace.switching_status_preparing": "Preparando tudo", + "workspace.switching_subtitle": "Vamos trazer seu trabalho recente de volta.", + "workspace.switching_title": "Abrindo {name}", + "workspace.switching_title_unknown": "Abrindo workspace", + "workspace_list.add_workspace": "Adicionar workspace", + "workspace_list.connect_remote": "Conectar workspace remoto", + "workspace_list.connecting": "Conectando...", + "workspace_list.delete_session": "Excluir sessão", + "workspace_list.desktop_only_hint": "Crie workspaces locais no app desktop.", + "workspace_list.edit_connection": "Editar conexão", + "workspace_list.edit_name": "Editar nome", + "workspace_list.hide_child_sessions": "Ocultar sessões filhas", + "workspace_list.import_config": "Importar config", + "workspace_list.new_workspace": "Novo workspace", + "workspace_list.recover": "Recuperar", + "workspace_list.remove_workspace": "Remover workspace", + "workspace_list.rename_session": "Renomear sessão", + "workspace_list.reveal_explorer": "Mostrar no Explorador", + "workspace_list.reveal_finder": "Mostrar no Finder", + "workspace_list.session_actions": "Ações da sessão", + "workspace_list.share": "Compartilhar...", + "workspace_list.show_child_sessions": "Mostrar sessões filhas", + "workspace_list.show_more": "Mostrar mais {count}", + "workspace_list.show_more_fallback": "Mostrar mais", + "workspace_list.test_connection": "Testar conexão", + "workspace_list.workspace_fallback": "Workspace", + "workspace_list.workspace_options": "Opções do workspace", + "workspace_sidebar.close_sidebar": "Fechar barra lateral", + "workspace_sidebar.collapse_sidebar": "Recolher barra lateral", + "workspace_sidebar.configuration": "configuração", + "workspace_sidebar.expand_sidebar": "Expandir barra lateral", + "workspace_sidebar.extensions": "Extensões", + "workspace_sidebar.messaging": "Mensagens", +} as const; diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts new file mode 100644 index 0000000000..94ac119a12 --- /dev/null +++ b/apps/app/src/i18n/locales/th.ts @@ -0,0 +1,1989 @@ +/** + * Thai translations + * Professional terms (Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork) are NOT translated + */ + +export default { + "app.compact_command_desc": "สรุปเซสชันนี้เพื่อลดขนาดบริบท", + "app.connection_lost": "การเชื่อมต่อเซิร์ฟเวอร์ขาดหาย กรุณารีโหลด", + "app.deep_link_auth_queued": "จัดคิว deep link สำหรับ Cloud auth ใน OpenWork แล้ว", + "app.deep_link_remote_queued": "จัดคิวลิงก์ remote worker แล้ว OpenWork จะเข้าสู่ขั้นตอนเชื่อมต่อ", + "app.error.choose_folder": "เลือกโฟลเดอร์เพื่อดำเนินการต่อ", + "app.error.host_requires_local": "เลือกพื้นที่ทำงานภายในเครื่องเพื่อเริ่ม engine", + "app.error.install_failed": "ติดตั้ง OpenCode ไม่สำเร็จ ดู logs ด้านบน", + "app.error.pick_workspace_folder": "เลือกโฟลเดอร์พื้นที่ทำงานก่อน", + "app.error.remote_base_url_required": "เพิ่ม URL ของเซิร์ฟเวอร์เพื่อดำเนินการต่อ", + "app.error.tauri_required": "การดำเนินการนี้ต้องใช้รันไทม์ของแอปเดสก์ท็อป OpenWork", + "app.error_audit_load": "โหลดบันทึกการตรวจสอบไม่สำเร็จ", + "app.error_auth_failed": "การยืนยันตัวตนล้มเหลว", + "app.error_auto_compact_scope": "การบีบอัดบริบทอัตโนมัติสามารถเปลี่ยนได้เฉพาะสำหรับพื้นที่ทำงานภายในเครื่องหรือ OpenWork server ที่เขียนได้", + "app.error_cloud_signin": "เข้าสู่ระบบ OpenWork Cloud ไม่สำเร็จ", + "app.error_command_not_resolved": "ไม่สามารถ resolve คำสั่ง", + "app.error_compact_empty": "ยังไม่มีสิ่งที่จะบีบอัด", + "app.error_compact_no_session": "เลือกเซสชันที่มีข้อความก่อนรัน /compact", + "app.error_compact_no_session_id": "เลือกเซสชันก่อนบีบอัด", + "app.error_connect_first": "เชื่อมต่อ worker นี้ก่อนใช้การเปลี่ยนแปลง runtime", + "app.error_connection_failed": "เชื่อมต่อไม่สำเร็จ", + "app.error_connection_failed_url": "เชื่อมต่อไม่สำเร็จ ตรวจสอบ URL และ token", + "app.error_deep_link_unrecognized": "ลิงก์นั้นไม่ใช่ deep link หรือ share URL ที่ OpenWork รู้จัก", + "app.error_desktop_signin": "การเข้าสู่ระบบเดสก์ท็อปเสร็จสิ้น แต่ OpenWork Cloud ไม่ส่งคืนโทเค็นเซสชัน", + "app.error_not_connected": "ไม่ได้เชื่อมต่อกับเซิร์ฟเวอร์", + "app.error_pick_local_folder": "เลือกโฟลเดอร์ local worker ก่อนรีสตาร์ท local server", + "app.error_rate_limit": "เกินขีดจำกัดอัตราการใช้งาน", + "app.error_remote_access": "อัปเดตการเข้าถึงระยะไกลไม่สำเร็จ", + "app.error_request_failed": "คำขอล้มเหลว", + "app.error_reset_config": "รีเซ็ตค่าเริ่มต้น app config ไม่สำเร็จ", + "app.error_restart_local_worker": "รีสตาร์ท local worker ด้วยการตั้งค่าการแชร์ที่อัปเดตไม่สำเร็จ", + "app.error_runtime_changes": "ใช้การเปลี่ยนแปลง runtime ไม่สำเร็จ", + "app.error_session_name_required": "ต้องใส่ชื่อเซสชัน", + "app.error_update_opencode_json": "อัปเดต opencode.json ไม่สำเร็จ", + "app.import_bundle_desc": "เลือกวิธีนำเข้า bundle นี้", + "app.import_shared_bundle": "นำเข้า bundle ที่แชร์", + "app.local_disabled_reason": "สร้างพื้นที่ทำงานภายในเครื่องในแอปเดสก์ท็อป พื้นที่ทำงานระยะไกลและที่แชร์ยังใช้งานได้ที่นี่", + "app.local_worker_detail": "Worker ภายในเครื่อง", + "app.model_behavior_desc": "เลือกโมเดลก่อนเพื่อดูการควบคุมพฤติกรรมเฉพาะผู้ให้บริการ", + "app.model_behavior_title": "พฤติกรรมโมเดล", + "app.plugins_hint_disconnected": "OpenWork server ไม่พร้อมใช้งาน Plugins เป็นแบบอ่านอย่างเดียว", + "app.plugins_hint_limited": "OpenWork server ต้องใช้ token เพื่อแก้ไข plugins", + "app.plugins_hint_readonly": "OpenWork server เป็นแบบอ่านอย่างเดียวสำหรับ plugins", + "app.reload_later": "ภายหลัง", + "app.reload_now": "รีโหลดตอนนี้", + "app.reload_stop_tasks": "รีโหลดและหยุดงาน", + "app.remote_worker_detail": "Worker ระยะไกล", + "app.reset_config_ok": "รีเซ็ตค่าเริ่มต้น app config แล้ว รีสตาร์ท OpenWork หากมีการตั้งค่าเก่าเหลืออยู่", + "app.shared_setup": "ตั้งค่าที่แชร์", + "app.skill_added": "เพิ่ม skill แล้ว", + "app.skills_hint_disconnected": "OpenWork server ไม่พร้อมใช้งาน เพิ่ม URL/token ของเซิร์ฟเวอร์ใน Advanced เพื่อจัดการ skills", + "app.skills_hint_limited": "OpenWork server ต้องใช้ host token เพื่อติดตั้ง/อัปเดต skills เพิ่มใน Advanced แล้วเชื่อมต่อใหม่", + "app.skills_hint_readonly": "OpenWork server เป็นแบบอ่านอย่างเดียวสำหรับ skills เพิ่ม host token ใน Advanced เพื่อเปิดการติดตั้ง", + "app.unknown_error": "ข้อผิดพลาดที่ไม่ทราบสาเหตุ", + "app.worker_fallback": "Worker", + "blueprint.automation_body": "เริ่มจาก workflow ที่ใช้ซ้ำได้ หรือพิมพ์งานของคุณด้านล่าง", + "blueprint.automation_title": "ต้องการให้ทำอะไรอัตโนมัติ?", + "blueprint.csv_session_assistant": "ฉันช่วยสร้าง ทำความสะอาด รวม และสรุปไฟล์ CSV ได้ ต้องการทำงาน CSV แบบไหน?", + "blueprint.csv_session_title": "ไอเดียการทำงาน CSV", + "blueprint.csv_session_user": "ฉันต้องการรวมข้อมูลส่งออกจากหลายเครื่องมือเป็น CSV เดียวที่สะอาด", + "blueprint.empty_body": "เลือกจุดเริ่มต้น หรือพิมพ์ด้านล่าง", + "blueprint.empty_title": "ต้องการทำอะไร?", + "blueprint.minimal_body": "ถามเกี่ยวกับพื้นที่ทำงานนี้ หรือใช้พรอมต์เริ่มต้น", + "blueprint.minimal_title": "เริ่มต้นด้วยงาน", + "blueprint.starter_blueprint_desc": "ออกแบบ workflow ที่ใช้ซ้ำได้ด้วย skills, คำสั่ง และขั้นตอนการส่งต่อ", + "blueprint.starter_blueprint_prompt": "ช่วยออกแบบ automation blueprint ที่ใช้ซ้ำได้สำหรับพื้นที่ทำงานนี้ ถามว่าควรทำให้เป็นมาตรฐานอย่างไร แล้วเสนอ workflow", + "blueprint.starter_blueprint_title": "วางแผน automation blueprint", + "blueprint.starter_chrome_desc": "เริ่มต้น browser automation ได้ทันที", + "blueprint.starter_chrome_prompt": "ช่วยเชื่อมต่อ Chrome และทำให้งานซ้ำๆ เป็นอัตโนมัติ", + "blueprint.starter_chrome_title": "ทำ Chrome อัตโนมัติ", + "blueprint.starter_command_desc": "เปลี่ยน workflow ที่ทำซ้ำๆ เป็น slash command สำหรับพื้นที่ทำงานนี้", + "blueprint.starter_command_prompt": "ช่วยสร้าง /command ที่ใช้ซ้ำได้สำหรับพื้นที่ทำงานนี้ ถามว่าต้องการทำ workflow อะไรอัตโนมัติ แล้วร่างคำสั่ง", + "blueprint.starter_command_title": "สร้างคำสั่งที่ใช้ซ้ำได้", + "blueprint.starter_connect_openai_desc": "เพิ่ม provider OpenAI เพื่อให้โมเดล ChatGPT พร้อมใช้งานในเซสชันใหม่", + "blueprint.starter_connect_openai_title": "เชื่อมต่อ ChatGPT", + "blueprint.starter_csv_desc": "ทำความสะอาดหรือสร้างข้อมูลสเปรดชีต", + "blueprint.starter_csv_prompt": "ช่วยสร้างหรือแก้ไขไฟล์ CSV บนคอมพิวเตอร์เครื่องนี้", + "blueprint.starter_csv_title": "ทำงานกับ CSV", + "blueprint.starter_explore_desc": "สรุปไฟล์ในพื้นที่ทำงานและแนะนำงานแรกที่ควรทำ", + "blueprint.starter_explore_prompt": "สรุปพื้นที่ทำงานนี้ ชี้ไฟล์สำคัญที่สุด แล้วแนะนำงานแรกที่ดีที่สุด", + "blueprint.starter_explore_title": "สำรวจพื้นที่ทำงานนี้", + "blueprint.welcome_message": "สวัสดี ยินดีต้อนรับสู่ OpenWork!\n\nผู้คนใช้ OpenWork เขียนไฟล์ .csv บนคอมพิวเตอร์ เชื่อมต่อ Chrome เพื่อทำงานซ้ำๆ อัตโนมัติ และซิงค์ผู้ติดต่อกับ Notion\n\nขีดจำกัดอยู่ที่จินตนาการของคุณ\n\nต้องการทำอะไร?", + "blueprint.welcome_title": "ยินดีต้อนรับสู่ OpenWork", + "common.add": "เพิ่ม", + "common.cancel": "ยกเลิก", + "common.choose": "เลือก", + "common.close": "ปิด", + "common.default_parens": "(ค่าเริ่มต้น)", + "common.done": "เสร็จสิ้น", + "common.edit": "แก้ไข", + "common.hide": "ซ่อน", + "common.install": "ติดตั้ง", + "common.navigate": "นำทาง", + "common.next": "ถัดไป", + "common.off": "ปิด", + "common.on": "เปิด", + "common.path": "เส้นทาง", + "common.question": "คำถาม", + "common.refresh": "รีเฟรช", + "common.remove": "ลบ", + "common.reset": "รีเซ็ต", + "common.retry": "ลองใหม่", + "common.save": "บันทึก", + "common.select": "เลือก", + "common.show": "แสดง", + "common.something_went_wrong": "เกิดข้อผิดพลาด", + "common.submit": "ส่ง", + "common.unknown": "ไม่ทราบ", + "composer.agent_label": "Agent", + "composer.attach_files": "แนบไฟล์", + "composer.attachments_unavailable": "ไฟล์แนบไม่พร้อมใช้งาน", + "composer.behavior_label": "พฤติกรรม", + "composer.configure": "ตั้งค่า", + "composer.default_agent": "Agent เริ่มต้น", + "composer.expand_pasted": "คลิกเพื่อขยายข้อความที่วาง", + "composer.failed_read_attachment": "อ่านไฟล์แนบไม่สำเร็จ", + "composer.file_exceeds_limit": "{name} เกินขีดจำกัด 8MB", + "composer.file_kind": "ไฟล์", + "composer.file_too_large_encoding": "{name} ใหญ่เกินไปหลังเข้ารหัส ลองใช้รูปภาพที่เล็กกว่า", + "composer.image_kind": "รูปภาพ", + "composer.inserted_links_unsupported": "แทรกลิงก์สำหรับไฟล์ที่ไม่รองรับ", + "composer.loading_agents": "กำลังโหลด agents...", + "composer.loading_commands": "กำลังโหลดคำสั่ง...", + "composer.mcps_label": "MCP", + "composer.no_commands": "ไม่พบคำสั่ง", + "composer.no_matches": "ไม่พบรายการที่ตรงกัน", + "composer.placeholder": "อธิบายงานของคุณ...", + "composer.remote_worker_paste_warning": "นี่คือ remote worker Sandbox ก็เป็น remote เช่นกัน หากต้องการแชร์ไฟล์ ให้อัปโหลดไปยังโฟลเดอร์ที่แชร์ในแถบด้านข้าง", + "composer.run_task": "รันงาน", + "composer.skill_source": "Skill", + "composer.stop": "หยุด", + "composer.tools_label": "คำสั่ง, Skills และ MCP", + "composer.unsupported_attachment_type": "ประเภทไฟล์แนบไม่รองรับ", + "composer.upload_failed_local_links": "อัปโหลดไปยังโฟลเดอร์ที่แชร์ไม่สำเร็จ แทรกลิงก์ภายในเครื่องแทน", + "composer.upload_to_shared_folder": "อัปโหลดไปยังโฟลเดอร์ที่แชร์", + "composer.uploaded_multiple_files": "อัปโหลด {count} ไฟล์ไปยังโฟลเดอร์ที่แชร์และแทรกลิงก์แล้ว", + "composer.uploaded_single_file": "อัปโหลด {name} ไปยังโฟลเดอร์ที่แชร์และแทรกลิงก์แล้ว", + "config.auto_reload_desc": "รีโหลดอัตโนมัติหลัง agents/skills/commands/config เปลี่ยน (เฉพาะเมื่อว่าง)", + "config.auto_reload_title": "รีโหลดอัตโนมัติ (ภายในเครื่อง)", + "config.auto_reload_unavailable": "ใช้ได้สำหรับพื้นที่ทำงานภายในเครื่องในแอปเดสก์ท็อป", + "config.collaborator_token_disabled_hint": "เก็บไว้ล่วงหน้าสำหรับการแชร์ระยะไกล แต่การเข้าถึงระยะไกลปิดอยู่", + "config.collaborator_token_label": "โทเค็นผู้ร่วมงาน", + "config.collaborator_token_remote_hint": "การเข้าถึงระยะไกลปกติสำหรับโทรศัพท์หรือแล็ปท็อปที่เชื่อมต่อกับเซิร์ฟเวอร์นี้", + "config.connection_failed": "เชื่อมต่อล้มเหลว", + "config.connection_failed_check": "เชื่อมต่อล้มเหลว ตรวจสอบ host URL และ token", + "config.connection_status_updated": "อัปเดตสถานะการเชื่อมต่อแล้ว", + "config.connection_successful": "เชื่อมต่อสำเร็จ", + "config.copied": "คัดลอกแล้ว", + "config.copy": "คัดลอก", + "config.desktop_only_hint": "ฟีเจอร์ config บางอย่าง (แชร์ local server + messaging bridge) ต้องใช้แอปเดสก์ท็อป", + "config.diagnostics_desc": "คัดลอกสถานะรันไทม์ที่ปลอดภัยสำหรับดีบัก", + "config.diagnostics_title": "ชุดข้อมูลวินิจฉัย", + "config.enable_auto_reload_first": "เปิดรีโหลดอัตโนมัติก่อน", + "config.engine_reload_desc": "รีสตาร์ท OpenCode server สำหรับพื้นที่ทำงานนี้", + "config.engine_reload_title": "รีโหลด engine", + "config.host_admin_token_hint": "Token ภายใน host สำหรับ approvals CLI และ admin API อย่าใช้ในขั้นตอนเชื่อมต่อแอป remote", + "config.host_admin_token_label": "Token ผู้ดูแล host", + "config.host_local_only": "ภายในเครื่องเท่านั้น", + "config.host_offline": "ออฟไลน์", + "config.host_remote_enabled": "เปิดใช้งานระยะไกล", + "config.local_ip_hint": "ใช้ IP ภายในเครื่องบน Wi-Fi เดียวกันเพื่อการเชื่อมต่อที่เร็วที่สุด", + "config.mdns_hint": "ชื่อ .local จดจำง่ายกว่าแต่อาจ resolve ไม่ได้ในทุกเครือข่าย", + "config.messaging_identities_desc": "จัดการ Telegram/Slack identities และ routing ในแท็บ Identities", + "config.messaging_identities_title": "Messaging identities", + "config.not_set": "ไม่ได้ตั้งค่า", + "config.owner_token_disabled_hint": "ใช้ได้หลังจากเปิดการเข้าถึงระยะไกลสำหรับ worker นี้", + "config.owner_token_label": "โทเค็นเจ้าของ", + "config.owner_token_remote_hint": "ใช้เมื่อ remote client ต้องตอบคำขออนุญาตหรือดำเนินการที่ต้องใช้สิทธิ์เจ้าของ", + "config.reload_active_tasks_warning": "การรีโหลดจะหยุดงานที่กำลังทำงาน", + "config.reload_availability_hint": "การรีโหลดใช้ได้เฉพาะ local workers หรือ OpenWork server ที่เชื่อมต่อ", + "config.reload_connect_hint": "เชื่อมต่อ worker นี้เพื่อรีโหลด", + "config.reload_engine": "รีโหลด engine", + "config.reload_now_desc": "ใช้การอัปเดต config และเชื่อมต่อเซสชันใหม่", + "config.reload_now_title": "รีโหลดตอนนี้", + "config.reloading": "กำลังรีโหลด...", + "config.remote_access_off_hint": "การเข้าถึงระยะไกลปิดอยู่ ใช้ แชร์พื้นที่ทำงาน เพื่อเปิดก่อนเชื่อมต่อจากเครื่องอื่น", + "config.resolved_worker_url": "URL ของ worker ที่ resolve:", + "config.resume_sessions_desc": "หากรีโหลดถูกจัดคิวขณะงานกำลังทำงาน ให้ส่งข้อความ resume หลังจากนั้น", + "config.resume_sessions_title": "กลับมาทำงานต่อหลังรีโหลดอัตโนมัติ", + "config.server_needed_hint": "ต้องเชื่อมต่อ OpenWork server เพื่อซิงค์ skills, plugins และ commands", + "config.server_section_desc": "เชื่อมต่อ OpenWork server ใช้ URL พร้อม collaborator หรือ owner token จากผู้ดูแลเซิร์ฟเวอร์", + "config.server_section_title": "OpenWork server", + "config.server_sharing_desc": "แชร์รายละเอียดเหล่านี้กับอุปกรณ์ที่เชื่อถือได้ ใช้เซิร์ฟเวอร์บนเครือข่ายเดียวกันเพื่อการตั้งค่าที่เร็วที่สุด", + "config.server_sharing_menu_hint": "สำหรับลิงก์แชร์เฉพาะพื้นที่ทำงาน ใช้ แชร์... ในเมนูพื้นที่ทำงาน", + "config.server_sharing_title": "แชร์ OpenWork server", + "config.server_url_hint": "ใช้ URL ที่แชร์โดย OpenWork server ของคุณ Desktop workers ใช้ port ถาวรในช่วง 48000-51000", + "config.server_url_input_label": "URL ของ OpenWork server", + "config.server_url_label": "URL ของ OpenWork Server", + "config.starting_server": "กำลังเริ่มเซิร์ฟเวอร์…", + "config.status_connected": "เชื่อมต่อแล้ว", + "config.status_limited": "จำกัด", + "config.status_not_connected": "ยังไม่ได้เชื่อมต่อ", + "config.test_connection": "ทดสอบการเชื่อมต่อ", + "config.testing": "กำลังทดสอบ...", + "config.testing_connection": "กำลังทดสอบการเชื่อมต่อ...", + "config.token_hint": "ไม่บังคับ วางโทเค็นผู้ร่วมงานสำหรับการเข้าถึงปกติ หรือโทเค็นเจ้าของเมื่อ client ต้องตอบคำขออนุญาต", + "config.token_label": "โทเค็นผู้ร่วมงานหรือเจ้าของ", + "config.token_placeholder": "วางโทเค็นของคุณ", + "config.unavailable": "ไม่พร้อมใช้งาน", + "config.worker_id": "Worker ID:", + "config.workspace_config_desc": "การตั้งค่าเหล่านี้มีผลต่อพื้นที่ทำงานที่เลือก การดำเนินการรันไทม์ใช้กับพื้นที่ทำงานที่เชื่อมต่ออยู่", + "config.workspace_config_title": "การตั้งค่าพื้นที่ทำงาน", + "config.workspace_id_prefix": "พื้นที่ทำงาน:", + "context_panel.add_button": "เพิ่ม", + "context_panel.add_folder_hint": "เพิ่มโฟลเดอร์เพื่อให้พื้นที่ทำงานนี้อ่านและแก้ไขไฟล์นอกไดเรกทอรีรูท", + "context_panel.adding_button": "กำลังเพิ่ม...", + "context_panel.always_available": "พร้อมใช้งานเสมอ", + "context_panel.authorized_folders": "โฟลเดอร์ที่อนุญาต", + "context_panel.authorized_folders_desc": "ให้พื้นที่ทำงานนี้เข้าถึงอ่านและแก้ไขไฟล์ในไดเรกทอรีนอกรูท", + "context_panel.authorized_folders_no_access": "เชื่อมต่อพื้นที่ทำงาน OpenWork server ที่เขียนได้เพื่อแก้ไขโฟลเดอร์ที่อนุญาต", + "context_panel.browse_button": "เรียกดู", + "context_panel.config_access_unavailable": "การเข้าถึง config ของ OpenWork server ไม่พร้อมใช้งานสำหรับพื้นที่ทำงานนี้", + "context_panel.config_read_only": "OpenWork server เชื่อมต่อแบบอ่านอย่างเดียวสำหรับ config พื้นที่ทำงาน", + "context_panel.context": "บริบท", + "context_panel.folder_already_authorized": "โฟลเดอร์ได้รับอนุญาตแล้ว", + "context_panel.folders_updated": "อัปเดตโฟลเดอร์ที่อนุญาตแล้ว", + "context_panel.input_placeholder": "พิมพ์เส้นทางโฟลเดอร์เพื่ออนุญาต...", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "เชื่อมต่อแล้ว", + "context_panel.mcp_disabled": "ปิดใช้งาน", + "context_panel.mcp_disconnected": "ตัดการเชื่อมต่อ", + "context_panel.mcp_failed": "ล้มเหลว", + "context_panel.mcp_needs_auth": "ต้องยืนยันตัวตน", + "context_panel.mcp_register_client": "ลงทะเบียน client", + "context_panel.no_external_folders": "ยังไม่มีโฟลเดอร์ภายนอกที่อนุญาต", + "context_panel.no_mcp": "ไม่ได้โหลด MCP servers", + "context_panel.no_plugins": "ไม่ได้โหลด Plugins", + "context_panel.no_server_workspace": "ยังไม่ได้เลือกพื้นที่ทำงาน server ที่ใช้งาน", + "context_panel.no_skills": "ไม่ได้โหลด Skills", + "context_panel.none_yet": "ยังไม่มี", + "context_panel.plugins": "Plugins", + "context_panel.preserving_entries": "คงไว้ {count} รายการสิทธิ์ที่ไม่ใช่โฟลเดอร์", + "context_panel.preserving_entry": "คงไว้ 1 รายการสิทธิ์ที่ไม่ใช่โฟลเดอร์", + "context_panel.remove_folder": "ลบ {name}", + "context_panel.saving_folders": "กำลังบันทึกโฟลเดอร์ที่อนุญาต...", + "context_panel.server_disconnected": "OpenWork server ตัดการเชื่อมต่อแล้ว", + "context_panel.skills": "Skills", + "context_panel.working_files": "ไฟล์ที่กำลังทำงาน", + "context_panel.workspace_root_available": "รูทพื้นที่ทำงานพร้อมใช้งานแล้ว", + "context_panel.workspace_root_badge": "รูทพื้นที่ทำงาน", + "context_panel.writable_workspace_required": "ต้องมีพื้นที่ทำงาน OpenWork server ที่เขียนได้เพื่ออัปเดตโฟลเดอร์ที่อนุญาต", + "dashboard.access_token": "Access token", + "dashboard.access_token_optional_hint": "เพิ่ม token เฉพาะเมื่อ worker ต้องการ", + "dashboard.blueprints_workspace": "Blueprints", + "dashboard.blueprints_workspace_desc": "เริ่มด้วยพื้นที่ทำงานที่พร้อมสำหรับออโตเมชั่น สำหรับ skills, commands และ flows ที่แชร์ได้", + "dashboard.change": "เปลี่ยน", + "dashboard.choose_folder": "เลือกโฟลเดอร์", + "dashboard.choose_folder_continue": "เลือกโฟลเดอร์เพื่อดำเนินการต่อ", + "dashboard.choose_folder_next": "แชร์ไฟล์กับพื้นที่ทำงานของคุณ", + "dashboard.choose_preset": "เลือก Preset", + "dashboard.chooser_local_desc": "สร้างพื้นที่ทำงานบนอุปกรณ์นี้และเลือกเริ่มจากเทมเพลตทีม", + "dashboard.chooser_remote_desc": "แนบกับ OpenWork worker ที่โฮสต์เองด้วย URL และ access token", + "dashboard.chooser_shared_desc": "เรียกดู cloud workers ที่แชร์กับองค์กรของคุณและเชื่อมต่อในขั้นตอนเดียว", + "dashboard.close_settings": "ปิดการตั้งค่า", + "dashboard.cloud_signin_button": "ดำเนินการต่อกับ Cloud", + "dashboard.cloud_signin_hint": "เข้าถึง remote workers ที่แชร์กับองค์กรของคุณ", + "dashboard.cloud_signin_next": "คุณจะเลือกทีมและเชื่อมต่อกับพื้นที่ทำงานที่มีอยู่ในขั้นตอนถัดไป", + "dashboard.cloud_signin_title": "เข้าสู่ระบบ OpenWork Cloud", + "dashboard.cloud_worker": "Cloud worker", + "dashboard.commands": "คำสั่ง", + "dashboard.connect_remote_button": "เชื่อมต่อระยะไกล", + "dashboard.connected": "เชื่อมต่อแล้ว", + "dashboard.connecting": "กำลังเชื่อมต่อ...", + "dashboard.create_local_workspace_subtitle": "สร้างพื้นที่ทำงานบนอุปกรณ์นี้และเลือกเริ่มจากเทมเพลตทีม", + "dashboard.create_local_workspace_title": "พื้นที่ทำงานภายในเครื่อง", + "dashboard.create_remote_custom_subtitle": "แนบกับ OpenWork worker ที่โฮสต์เอง", + "dashboard.create_remote_custom_title": "เชื่อมต่อ remote กำหนดเอง", + "dashboard.create_remote_workspace_confirm": "เพิ่มพื้นที่ทำงาน", + "dashboard.create_remote_workspace_subtitle": "บันทึก OpenWork server เป็นพื้นที่ทำงาน", + "dashboard.create_remote_workspace_title": "เพิ่มพื้นที่ทำงานระยะไกล", + "dashboard.create_sandbox_confirm": "สร้างเป็น sandbox", + "dashboard.create_shared_subtitle_signed_in": "เรียกดู cloud workers ที่แชร์กับองค์กรของคุณและเชื่อมต่อในขั้นตอนเดียว", + "dashboard.create_shared_subtitle_signed_out": "เข้าสู่ระบบ OpenWork Cloud เพื่อเข้าถึง workers ที่แชร์กับองค์กรของคุณ", + "dashboard.create_shared_title": "พื้นที่ทำงานที่แชร์", + "dashboard.create_workspace_confirm": "สร้างพื้นที่ทำงาน", + "dashboard.create_workspace_subtitle": "เริ่มต้นพื้นที่ทำงานใหม่ตามโฟลเดอร์", + "dashboard.create_workspace_title": "สร้างพื้นที่ทำงาน", + "dashboard.creating": "กำลังสร้าง...", + "dashboard.desktop_badge": "เดสก์ท็อป", + "dashboard.display_name_label": "ชื่อที่แสดง", + "dashboard.display_name_optional": "(ไม่บังคับ)", + "dashboard.docker_debug_details": "รายละเอียด Docker debug", + "dashboard.edit_remote_workspace_confirm": "บันทึกการเชื่อมต่อ", + "dashboard.edit_remote_workspace_subtitle": "อัปเดตรายละเอียด OpenWork server สำหรับพื้นที่ทำงานนี้", + "dashboard.edit_remote_workspace_title": "แก้ไขการเชื่อมต่อระยะไกล", + "dashboard.empty_workspace": "พื้นที่ทำงานว่าง", + "dashboard.empty_workspace_desc": "เริ่มด้วยโฟลเดอร์ว่างและเพิ่มสิ่งที่ต้องการ", + "dashboard.error_choose_org": "เลือกองค์กรก่อนเปิดพื้นที่ทำงาน", + "dashboard.error_connect_worker": "เชื่อมต่อ {name} ไม่สำเร็จ", + "dashboard.error_create_template": "สร้าง {name} ไม่สำเร็จ", + "dashboard.error_load_orgs": "โหลดองค์กรไม่สำเร็จ", + "dashboard.error_load_shared_workspaces": "โหลดพื้นที่ทำงานที่แชร์ไม่สำเร็จ", + "dashboard.error_workspace_not_ready": "พื้นที่ทำงานยังไม่พร้อมเชื่อมต่อ ลองอีกครั้งในอีกสักครู่", + "dashboard.import_config": "นำเข้า config", + "dashboard.importing": "กำลังนำเข้า…", + "dashboard.modal_back": "กลับ", + "dashboard.modal_close": "ปิดหน้าต่างเพิ่มพื้นที่ทำงาน", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "ยังไม่ได้เลือกโฟลเดอร์", + "dashboard.open_cloud_dashboard": "เปิดแดชบอร์ด Cloud", + "dashboard.opening": "กำลังเปิด...", + "dashboard.openwork_host_hint": "ใช้ URL ที่แชร์โดย OpenWork server ของคุณ", + "dashboard.openwork_host_label": "URL ของ OpenWork server", + "dashboard.openwork_host_placeholder": "https://your-server.openwork.app", + "dashboard.openwork_host_token_hint": "ไม่บังคับ วางโทเค็นผู้ร่วมงานสำหรับการเข้าถึงปกติ หรือโทเค็นเจ้าของเมื่อ client ต้องตอบคำขออนุญาต", + "dashboard.openwork_host_token_label": "โทเค็นผู้ร่วมงานหรือเจ้าของ", + "dashboard.openwork_host_token_placeholder": "วางโทเค็นของคุณ", + "dashboard.recently_updated": "อัปเดตล่าสุด", + "dashboard.remote": "ระยะไกล", + "dashboard.remote_base_url_required": "เพิ่ม URL ของเซิร์ฟเวอร์เพื่อดำเนินการต่อ", + "dashboard.remote_connection_direct": "Direct", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "เว้นว่างเพื่อใช้ค่าเริ่มต้นของเซิร์ฟเวอร์", + "dashboard.remote_directory_label": "โฟลเดอร์พื้นที่ทำงาน (ไม่บังคับ)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "ชื่อที่แสดง (ไม่บังคับ)", + "dashboard.remote_display_name_placeholder": "พื้นที่ทำงานทีมออกแบบ", + "dashboard.remote_server_details_hint": "แนบกับ OpenWork worker ที่โฮสต์เอง", + "dashboard.remote_server_details_title": "รายละเอียดเซิร์ฟเวอร์ระยะไกล", + "dashboard.remote_workspace_hint": "ติดตาม OpenWork server และเชื่อมต่อใหม่ได้ทุกเมื่อ", + "dashboard.remote_workspace_title": "พื้นที่ทำงานระยะไกล", + "dashboard.repair_cache": "ซ่อมแซมแคช", + "dashboard.repairing_cache": "กำลังซ่อมแซมแคช", + "dashboard.sandbox_checking_docker": "กำลังตรวจสอบ Docker...", + "dashboard.sandbox_get_ready_action": "เตรียมระบบให้พร้อม", + "dashboard.sandbox_get_ready_desc": "รันพื้นที่ทำงานนี้ใน Docker container แบบแยกส่วนเพื่อความปลอดภัยและการทำซ้ำที่ดีขึ้น", + "dashboard.sandbox_get_ready_title": "Sandbox ต้องใช้ Docker", + "dashboard.sandbox_hide_logs": "ซ่อน logs", + "dashboard.sandbox_live_logs": "Logs แบบสด", + "dashboard.sandbox_setup": "ตั้งค่า Sandbox", + "dashboard.sandbox_show_logs": "แสดง logs", + "dashboard.search_shared_workspaces": "ค้นหาพื้นที่ทำงานที่แชร์", + "dashboard.select_folder": "เลือกโฟลเดอร์", + "dashboard.settings": "การตั้งค่า", + "dashboard.shared_workspaces_loading": "กำลังโหลดพื้นที่ทำงานที่แชร์…", + "dashboard.shared_workspaces_no_match": "ไม่พบพื้นที่ทำงานที่แชร์ที่ตรงกัน", + "dashboard.shared_workspaces_none": "ยังไม่มีพื้นที่ทำงานที่แชร์", + "dashboard.shared_workspaces_refreshing": "กำลังรีเฟรชพื้นที่ทำงาน…", + "dashboard.skills": "Skills", + "dashboard.starter_workspace": "พื้นที่ทำงานเริ่มต้น", + "dashboard.starter_workspace_desc": "ตั้งค่าไว้ล่วงหน้าเพื่อแสดงวิธีใช้ plugins, commands และ skills", + "dashboard.unknown_creator": "ไม่ทราบผู้สร้าง", + "dashboard.worker_status_attention": "ต้องตรวจสอบ", + "dashboard.worker_status_ready": "พร้อม", + "dashboard.worker_status_starting": "กำลังเริ่ม", + "dashboard.worker_status_stopped": "หยุดแล้ว", + "dashboard.worker_status_unknown": "ไม่ทราบ", + "dashboard.worker_url_hint": "วาง URL ของ OpenWork worker ที่ต้องการเชื่อมต่อ", + "dashboard.worker_url_label": "URL ของ Worker", + "dashboard.workspace_connect": "เชื่อมต่อ", + "dashboard.workspace_connect_unavailable": "การเชื่อมต่อพื้นที่ทำงานที่แชร์ไม่พร้อมใช้งานที่นี่", + "dashboard.workspace_connecting": "กำลังเชื่อมต่อ", + "dashboard.workspace_folder_hint": "เลือกว่าพื้นที่ทำงานนี้ควรอยู่ที่ไหนบนอุปกรณ์", + "dashboard.workspace_folder_title": "โฟลเดอร์พื้นที่ทำงาน", + "dashboard.workspace_not_ready_title": "พื้นที่ทำงานนี้ยังไม่พร้อมเชื่อมต่อ", + "dashboard.workspaces": "พื้นที่ทำงาน", + "den.active_org_hint": "Cloud workers และเทมเพลตทีมจะอยู่ภายใต้องค์กรที่เลือก", + "den.active_org_title": "องค์กรที่ใช้งาน", + "den.auto_reconnect_hint": "เข้าสู่ระบบในเบราว์เซอร์ให้เสร็จ แล้ว OpenWork จะเชื่อมต่อใหม่โดยอัตโนมัติ", + "den.checking_session": "กำลังตรวจสอบเซสชัน", + "den.choose_org_for_providers": "เลือกองค์กรเพื่อดู provider บน Cloud", + "den.choose_org_for_skill_hubs": "เลือกองค์กรเพื่อดู Skill Hub บน Cloud", + "den.cloud_account_hint": "จัดการบัญชีและองค์กรที่เชื่อมต่อ", + "den.cloud_account_title": "บัญชี Cloud", + "den.cloud_control_plane_open": "เปิดในเบราว์เซอร์", + "den.cloud_control_plane_reset": "รีเซ็ต", + "den.cloud_control_plane_save": "บันทึก URL", + "den.cloud_control_plane_url_hint": "โหมดนักพัฒนาเท่านั้น ใช้เพื่อเชื่อมต่อ Cloud control plane ภายในเครื่องหรือที่โฮสต์เอง การเปลี่ยนจะออกจากระบบเพื่อให้แอปโหลดข้อมูลจาก control plane ใหม่", + "den.cloud_control_plane_url_label": "URL ของ Cloud control plane", + "den.cloud_provider_detail": "{count} โมเดล · {source} provider", + "den.cloud_provider_removed_detail": "Provider ที่นำเข้านี้ไม่อยู่บน Cloud แล้ว ถอนการติดตั้งการกำหนดค่า {providerId} ในเครื่อง", + "den.cloud_provider_sync_detail": "Provider บน Cloud เปลี่ยนแปลงแล้ว ซิงค์การกำหนดค่า {source} กับ {count} โมเดลเข้า opencode.jsonc", + "den.cloud_providers_hint": "นำเข้า LLM provider ที่จัดการแล้วเข้า opencode.jsonc และใช้ข้อมูลรับรองขององค์กรในพื้นที่ทำงานนี้", + "den.cloud_providers_title": "Provider บน Cloud", + "den.cloud_section_desc": "เข้าสู่ระบบ เลือกองค์กร และเปิด Cloud workers หรือเทมเพลตทีม", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "เข้าสู่ระบบ OpenWork Cloud เพื่อให้งานยังทำงานแม้คอมพิวเตอร์เข้าสู่โหมดสลีป", + "den.cloud_workers_hint": "เปิด workers ตรงใน OpenWork ด้วยขั้นตอนเชื่อมต่อระยะไกลเดียวกับที่แอปใช้ที่อื่น", + "den.cloud_workers_title": "Cloud workers", + "den.create_account": "สร้างบัญชี", + "den.credentials_ready_badge": "ข้อมูลรับรองพร้อม", + "den.error_base_url": "ใส่ URL ของ Cloud control plane ที่ถูกต้อง (http:// หรือ https://)", + "den.error_choose_org": "เลือกองค์กรก่อนเปิด worker", + "den.error_load_orgs": "โหลดองค์กรไม่สำเร็จ", + "den.error_load_workers": "โหลด workers ไม่สำเร็จ", + "den.error_no_session": "ไม่พบเซสชัน Cloud ที่ใช้งาน", + "den.error_no_token": "การเข้าสู่ระบบเดสก์ท็อปเสร็จสิ้น แต่ OpenWork Cloud ไม่ส่งคืนโทเค็นเซสชัน", + "den.error_open_worker": "เปิด {name} ใน OpenWork ไม่สำเร็จ", + "den.error_open_worker_fallback": "เปิด {name} ไม่สำเร็จ", + "den.error_paste_valid_code": "วางลิงก์เข้าสู่ระบบ OpenWork หรือรหัสครั้งเดียวที่ถูกต้อง", + "den.error_signin_failed": "เข้าสู่ระบบ OpenWork Cloud ไม่สำเร็จ", + "den.error_worker_not_ready": "Worker ยังไม่พร้อมเปิด ลองอีกครั้งหลังการจัดเตรียมเสร็จ", + "den.finish_signin": "เข้าสู่ระบบให้เสร็จ", + "den.finishing": "กำลังเสร็จสิ้น...", + "den.hide_signin_code": "ซ่อนรหัสเข้าสู่ระบบ", + "den.import_all": "นำเข้าทั้งหมด", + "den.import_provider": "นำเข้า", + "den.import_provider_failed": "ไม่สามารถนำเข้า {name} ได้", + "den.imported_badge": "นำเข้าแล้ว", + "den.imported_provider": "นำเข้า {name} แล้ว", + "den.importing": "กำลังนำเข้า…", + "den.needs_attention": "ต้องให้ความสนใจ", + "den.no_cloud_providers": "ยังไม่มี provider บน Cloud สำหรับองค์กรนี้", + "den.no_cloud_workers": "ยังไม่มี cloud workers สำหรับองค์กรนี้ สร้างใน Cloud แล้วรีเฟรชแท็บนี้", + "den.no_org_selected": "ยังไม่ได้เลือกองค์กร", + "den.no_skill_hubs": "ยังไม่มี Skill Hub บน Cloud สำหรับองค์กรนี้", + "den.open": "เปิด", + "den.opening": "กำลังเปิด...", + "den.org_member_suffix": "(สมาชิก)", + "den.org_owner_suffix": "(เจ้าของ)", + "den.org_switched": "สลับไปยัง {name} แล้ว", + "den.out_of_sync_badge": "ไม่ตรงกัน", + "den.paste_signin_code": "วางรหัสเข้าสู่ระบบ", + "den.refresh": "รีเฟรช", + "den.reload_workspace": "โหลดพื้นที่ทำงานใหม่เพื่อใช้การเปลี่ยนแปลงการตั้งค่า", + "den.remove_provider_failed": "ไม่สามารถลบ {name} ได้", + "den.removed_from_cloud_badge": "ลบจาก Cloud แล้ว", + "den.removed_provider": "ลบ {name} แล้ว", + "den.removing": "กำลังลบ…", + "den.sign_out": "ออกจากระบบ", + "den.signed_out": "ออกจากระบบแล้ว", + "den.signin_button": "เข้าสู่ระบบ", + "den.signin_code_note": "รับลิงก์ openwork://den-auth หรือรหัสครั้งเดียว", + "den.signin_link_hint": "หากเบราว์เซอร์ไม่กลับมาที่ OpenWork โดยอัตโนมัติ ให้วางลิงก์เข้าสู่ระบบหรือรหัสครั้งเดียวจาก OpenWork Cloud ที่นี่", + "den.signin_link_label": "ลิงก์เข้าสู่ระบบหรือรหัสครั้งเดียว", + "den.signin_link_placeholder": "openwork://den-auth?... หรือรหัสที่วาง", + "den.signin_title": "เข้าสู่ระบบ OpenWork Cloud", + "den.signing_in": "กำลังดำเนินการเข้าสู่ระบบ OpenWork Cloud ให้เสร็จสิ้น...", + "den.signing_out": "กำลังออกจากระบบ...", + "den.skill_hub_detail": "นำเข้า {count} skill ที่แชร์เข้า .opencode/skills", + "den.skill_hub_imported_detail": "นำเข้า {count} skill เข้าพื้นที่ทำงานนี้แล้ว", + "den.skill_hub_removed_detail": "Hub นี้ถูกลบจาก Cloud แล้ว ถอนการติดตั้ง {importedCount} skill ที่นำเข้าจากพื้นที่ทำงานนี้", + "den.skill_hub_skills_badge": "{count} skills", + "den.skill_hub_sync_detail": "Cloud มี {liveCount} skill; พื้นที่ทำงานนี้นำเข้า {importedCount} รายการ ซิงค์เพื่ออัปเดต", + "den.skill_hubs_hint": "นำเข้า skill ทั้งหมดจาก Cloud Hub ที่แชร์เข้าพื้นที่ทำงานนี้ในขั้นตอนเดียว", + "den.skill_hubs_title": "Skill Hub", + "den.status_base_url_updated": "อัปเดต URL ของ Cloud control plane แล้ว เข้าสู่ระบบอีกครั้งเพื่อดำเนินการต่อ", + "den.status_browser_signin": "เข้าสู่ระบบในเบราว์เซอร์ให้เสร็จเพื่อเชื่อมต่อ OpenWork", + "den.status_browser_signup": "สร้างบัญชีในเบราว์เซอร์ให้เสร็จเพื่อเชื่อมต่อ OpenWork", + "den.status_cloud_signed_in_as": "เชื่อมต่อ OpenWork Cloud เป็น {email}", + "den.status_cloud_signin_done": "เชื่อมต่อ OpenWork Cloud แล้ว", + "den.status_loaded_orgs": "โหลด {count} องค์กรแล้ว", + "den.status_loaded_workers": "โหลด {count} workers สำหรับ {name} แล้ว", + "den.status_no_workers": "ไม่พบ workers สำหรับ {name}", + "den.status_opened_worker": "เปิด {name} ใน OpenWork แล้ว", + "den.status_signed_in_as": "เข้าสู่ระบบเป็น {email}", + "den.status_signed_out": "ออกจากระบบและล้างเซสชัน OpenWork Cloud บนอุปกรณ์นี้แล้ว", + "den.sync": "ซิงค์", + "den.sync_provider_failed": "ไม่สามารถซิงค์ {name} ได้", + "den.synced_provider": "ซิงค์ {name} แล้ว", + "den.syncing": "กำลังซิงค์…", + "den.uninstall": "ถอนการติดตั้ง", + "den.worker_mine_badge": "ของฉัน", + "den.worker_not_ready_title": "Worker นี้ยังไม่พร้อมเปิด", + "den.worker_provider_label": "{provider} worker", + "den.worker_secondary_cloud": "Cloud worker", + "extensions.app_count_one": "{count} แอปเชื่อมต่อแล้ว", + "extensions.app_count_many": "{count} แอปเชื่อมต่อแล้ว", + "extensions.apps_mcp_header": "แอป (MCP)", + "extensions.filter_all": "ทั้งหมด", + "extensions.filter_apps": "แอป", + "extensions.filter_plugins": "Plugins", + "extensions.plugin_count_one": "{count} ปลั๊กอิน", + "extensions.plugin_count_many": "{count} ปลั๊กอิน", + "extensions.plugins_opencode_header": "Plugins (OpenCode)", + "extensions.subtitle": "แอป (MCP) และ OpenCode plugins อยู่ในที่เดียว", + "extensions.title": "ส่วนขยาย", + "identities.agent_behavior_desc": "ไฟล์เดียวต่อพื้นที่ทำงาน เพิ่มบรรทัดแรก @agent เพื่อ route ผ่าน OpenCode agent ที่ระบุ", + "identities.agent_behavior_title": "พฤติกรรม messaging agent", + "identities.agent_created": "สร้างไฟล์ messaging agent เริ่มต้นแล้ว", + "identities.agent_file_changed": "ไฟล์ถูกเปลี่ยนจากระยะไกล รีโหลดและบันทึกใหม่", + "identities.agent_loading": "กำลังโหลดไฟล์ agent…", + "identities.agent_none": "ไม่มี", + "identities.agent_not_found": "ยังไม่พบไฟล์ agent ในพื้นที่ทำงานนี้", + "identities.agent_saved": "บันทึกพฤติกรรม messaging แล้ว", + "identities.agent_scope_status": "ขอบเขต: พื้นที่ทำงาน · สถานะ: {status} · agent ที่เลือก: {agent}", + "identities.agent_status_loaded": "โหลดแล้ว", + "identities.agent_status_missing": "ไม่พบ", + "identities.agent_worker_scope_unavailable": "ขอบเขต worker ไม่พร้อมใช้งาน", + "identities.all_channels": "ทุกช่องทาง", + "identities.app_token_label": "App token", + "identities.auto_bind_label": "ผูก peer กับไดเรกทอรีอัตโนมัติเมื่อส่งตรง", + "identities.available_channels": "ช่องทางที่ใช้ได้", + "identities.bot_token_label": "Bot token", + "identities.bot_token_placeholder": "วาง Telegram bot token จาก @BotFather", + "identities.botfather_step1_open": "1. เปิด @BotFather ใน Telegram", + "identities.botfather_step1_run": "แล้วรัน /newbot", + "identities.botfather_step3_choose": "3. เลือกชื่อและ username สำหรับบอท", + "identities.botfather_step3_or_private": "สำหรับ inbox เปิด หรือ", + "identities.botfather_step3_private": "ส่วนตัว", + "identities.botfather_step3_public": "สาธารณะ", + "identities.botfather_step3_to_require": "เพื่อกำหนด", + "identities.channel_label": "ช่องทาง", + "identities.channels_connected": "เชื่อมต่อแล้ว", + "identities.channels_label": "ช่องทาง", + "identities.configured_suffix": "ตั้งค่าแล้ว", + "identities.connect_server_desc": "Identities ใช้งานได้เมื่อเชื่อมต่อกับ OpenWork host", + "identities.connect_server_title": "เชื่อมต่อ OpenWork server", + "identities.connect_slack": "เชื่อมต่อ Slack", + "identities.connected_badge": "เชื่อมต่อแล้ว", + "identities.connecting": "กำลังเชื่อมต่อ...", + "identities.copy_bot_token_hint": "คัดลอก bot token แล้ววางด้านล่าง", + "identities.copy_code": "คัดลอกรหัส", + "identities.create_default_file": "สร้างไฟล์เริ่มต้น", + "identities.create_private_bot": "สร้างบอทส่วนตัว", + "identities.create_public_bot": "สร้างบอทสาธารณะ", + "identities.days_ago": "{days} วันที่แล้ว", + "identities.default_routing": "การ route เริ่มต้น", + "identities.directory_label": "ไดเรกทอรี (ไม่บังคับ)", + "identities.disable_messaging": "ปิด messaging", + "identities.disable_messaging_message": "การดำเนินการนี้จะปิด messaging สำหรับพื้นที่ทำงานนี้ การตั้งค่า Telegram และ Slack จะถูกซ่อนจนกว่าจะเปิด messaging อีกครั้ง และคุณจะต้องรีสตาร์ท worker เพื่อหยุด messaging sidecar อย่างสมบูรณ์", + "identities.disable_messaging_title": "ปิด messaging สำหรับ worker นี้?", + "identities.disabled_label": "ปิดใช้งาน", + "identities.disabling": "กำลังปิดใช้งาน...", + "identities.disconnect": "ตัดการเชื่อมต่อ", + "identities.dispatched_messages": "ส่ง {sent}/{attempted} ข้อความแล้ว", + "identities.enable_messaging": "เปิด messaging", + "identities.enable_messaging_risk": "Messaging อาจเปิดเผย worker นี้ต่อคำสั่งระยะไกล หากบอทเป็นสาธารณะหรือถูกบุกรุก อาจเข้าถึงไฟล์ ข้อมูลรับรอง และ API key ที่ worker นี้ใช้ได้", + "identities.enable_messaging_title": "เปิด messaging สำหรับ worker นี้?", + "identities.enabled_label": "เปิดใช้งาน", + "identities.enabling": "กำลังเปิดใช้งาน...", + "identities.health_offline": "ออฟไลน์", + "identities.health_running": "กำลังทำงาน", + "identities.health_unavailable": "ไม่พร้อมใช้งาน", + "identities.health_unknown": "ไม่ทราบ", + "identities.hours_ago": "{hours} ชม. ที่แล้ว", + "identities.identities_label": "Identities", + "identities.just_now": "เมื่อสักครู่", + "identities.last_activity": "กิจกรรมล่าสุด", + "identities.later": "ภายหลัง", + "identities.message_label": "ข้อความ", + "identities.message_routing_desc": "ควบคุมว่าบทสนทนาไหนจะส่งไปโฟลเดอร์ไหน ข้อความจะถูก route ไปยังโฟลเดอร์เริ่มต้นของ worker เว้นแต่คุณจะตั้งกฎที่นี่", + "identities.message_routing_title": "การ route ข้อความ", + "identities.messages_today": "ข้อความวันนี้", + "identities.messaging_disabled_hint": "เปิด messaging เฉพาะเมื่อคุณเข้าใจความเสี่ยงและวางแผนรักษาความปลอดภัย (เช่น Telegram pairing แบบส่วนตัว)", + "identities.messaging_disabled_restart": "ปิด messaging แล้ว รีสตาร์ท worker เพื่อหยุด messaging sidecar", + "identities.messaging_disabled_risk": "บอท messaging สามารถดำเนินการต่อ local worker ของคุณได้ หากเปิดเผยต่อสาธารณะ อาจอนุญาตให้เข้าถึงไฟล์ ข้อมูลรับรอง และ API key ที่ worker นี้ใช้ได้", + "identities.messaging_disabled_title": "Messaging ปิดอยู่เป็นค่าเริ่มต้น", + "identities.messaging_enabled_restart": "เปิด messaging แล้ว รีสตาร์ท worker เพื่อเริ่ม messaging sidecar และปลดล็อกการตั้งค่า Telegram และ Slack", + "identities.messaging_sidecar_not_running": "Messaging เปิดอยู่ในพื้นที่ทำงานนี้ แต่ messaging sidecar ยังไม่ทำงาน รีสตาร์ท worker แล้วกลับมาที่การตั้งค่า Messaging เพื่อเชื่อมต่อ Telegram หรือ Slack", + "identities.minutes_ago": "{minutes} นาทีที่แล้ว", + "identities.not_set": "ไม่ได้ตั้งค่า", + "identities.open_bot_link": "เปิด @{username} ใน Telegram", + "identities.pairing_code_copied": "คัดลอกรหัสจับคู่แล้ว", + "identities.pairing_code_copy_failed": "คัดลอกรหัสจับคู่ไม่ได้ คัดลอกด้วยตนเอง", + "identities.pairing_code_instruction_prefix": "ส่ง", + "identities.peer_id_label": "Peer ID (ไม่บังคับ)", + "identities.peer_id_placeholder_slack": "เช่น slack:U12345678", + "identities.peer_id_placeholder_telegram": "เช่น telegram:123456789", + "identities.private_label": "ส่วนตัว", + "identities.private_pairing_code": "รหัสจับคู่ส่วนตัว", + "identities.public_bot_confirm": "ใช่ ฉันเข้าใจความเสี่ยง", + "identities.public_bot_warning_message": "บอทของคุณจะเข้าถึงได้สาธารณะ ทุกคนที่เข้าถึงบอทจะสามารถเข้าถึง local worker ของคุณได้เต็มที่ รวมถึงไฟล์และ API key ที่คุณให้ไว้ หากสร้างบอทส่วนตัว คุณสามารถจำกัดผู้เข้าถึงด้วยการกำหนดให้ใช้ pairing token คุณแน่ใจหรือไม่ว่าต้องการทำบอทสาธารณะ?", + "identities.public_bot_warning_title": "ทำบอทนี้เป็นสาธารณะ?", + "identities.public_label": "สาธารณะ", + "identities.quick_setup": "ตั้งค่าด่วน", + "identities.reconnect_failed": "เชื่อมต่อใหม่ไม่สำเร็จ ตรวจสอบ URL/token ของ OpenWork แล้วลองอีกครั้ง", + "identities.reconnected": "เชื่อมต่อใหม่แล้ว", + "identities.reconnected_refreshing": "เชื่อมต่อใหม่แล้ว กำลังรีเฟรชสถานะ worker...", + "identities.reload": "รีโหลด", + "identities.repair_reconnect": "ซ่อมแซมและเชื่อมต่อใหม่", + "identities.restart_failed": "รีสตาร์ทล้มเหลว กรุณารีสตาร์ท worker จาก Settings แล้วลองอีกครั้ง", + "identities.restart_to_disable_messaging": "ปิด messaging สำหรับพื้นที่ทำงานนี้แล้ว รีสตาร์ท worker ตอนนี้เพื่อหยุด messaging sidecar", + "identities.restart_to_enable_messaging": "เปิด messaging สำหรับพื้นที่ทำงานนี้แล้ว รีสตาร์ท worker ตอนนี้เพื่อเริ่ม messaging sidecar และปลดล็อกการตั้งค่า Telegram และ Slack", + "identities.restart_worker": "รีสตาร์ท worker", + "identities.restart_worker_title": "รีสตาร์ท worker ตอนนี้?", + "identities.restarting": "กำลังรีสตาร์ท...", + "identities.routing_override_prefix": "ข้อความทั้งหมด route ไปที่", + "identities.routing_override_suffix": "(override ทำงานอยู่)", + "identities.running_label": "กำลังทำงาน", + "identities.save_behavior": "บันทึกพฤติกรรม", + "identities.saving": "กำลังบันทึก...", + "identities.send_test_button": "ส่งข้อความทดสอบ", + "identities.send_test_desc": "ตรวจสอบการเชื่อมต่อขาออก ใช้ peer ID สำหรับส่งตรง หรือเว้น peer ID ว่างเพื่อกระจายตาม bindings ในไดเรกทอรี", + "identities.send_test_title": "ส่งข้อความทดสอบ", + "identities.sending": "กำลังส่ง...", + "identities.slack_desc": "Worker ของคุณจะปรากฏเป็นบอทใน Slack channels สมาชิกทีมสามารถส่งข้อความโดยตรงหรือ mention ในเธรด", + "identities.slack_intro": "เชื่อมต่อ Slack workspace เพื่อให้สมาชิกทีมโต้ตอบกับ worker นี้ใน channels และ DM", + "identities.slack_unavailable": "Slack identities ไม่พร้อมใช้งาน", + "identities.status_active": "ใช้งานอยู่", + "identities.status_label": "สถานะ", + "identities.status_stopped": "หยุดแล้ว", + "identities.stopped_label": "หยุดแล้ว", + "identities.subtitle": "ให้ผู้คนเข้าถึง worker ของคุณผ่านแอปข้อความ เชื่อมต่อช่องทางแล้ว worker จะอ่านและตอบข้อความโดยอัตโนมัติ", + "identities.tab_general": "ทั่วไป", + "identities.telegram_bot_access_desc": "บอทสาธารณะ: แชท Telegram แรกจะเชื่อมต่ออัตโนมัติ บอทส่วนตัว: ต้องใช้รหัสจับคู่ก่อนข้อความจะรันเครื่องมือ", + "identities.telegram_delete_failed": "ลบไม่สำเร็จ", + "identities.telegram_deleted": "ลบแล้ว", + "identities.telegram_deleted_pending": "ลบแล้ว (รอใช้งาน)", + "identities.telegram_desc": "เชื่อมต่อ Telegram bot ในโหมดสาธารณะ (inbox เปิด) หรือโหมดส่วนตัว (ต้องใช้รหัสจับคู่)", + "identities.telegram_private_saved_pair": "บันทึกบอทส่วนตัวแล้ว จับคู่ด้วย /pair {code}", + "identities.telegram_save_failed": "บันทึกไม่สำเร็จ", + "identities.telegram_saved": "บันทึกแล้ว", + "identities.telegram_saved_pending": "บันทึกแล้ว (รอใช้งาน)", + "identities.telegram_saved_username": "บันทึกแล้ว (@{username})", + "identities.telegram_unavailable": "Telegram identities ไม่พร้อมใช้งาน", + "identities.title": "ช่องทาง messaging", + "identities.unsaved_changes": "มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก", + "identities.worker_offline": "Worker ออฟไลน์", + "identities.worker_online": "Worker ออนไลน์", + "identities.worker_restarted": "รีสตาร์ท worker แล้ว", + "identities.worker_restarted_refreshing": "รีสตาร์ท worker แล้ว กำลังรีเฟรชสถานะ messaging...", + "identities.worker_scope_unavailable": "ขอบเขต worker ไม่พร้อมใช้งาน", + "identities.worker_scope_unavailable_detail": "ขอบเขต worker ไม่พร้อมใช้งาน เชื่อมต่อใหม่ด้วย worker URL หรือสลับไปยัง worker ที่รู้จัก", + "identities.worker_unavailable": "Worker ไม่พร้อมใช้งาน", + "identities.workspace_id_required": "ต้องใช้ Workspace ID เพื่อจัดการ identities เชื่อมต่อใหม่ด้วย workspace URL หรือเลือกพื้นที่ทำงานที่ map ไว้บน host นี้", + "identities.workspace_scope_prefix": "ขอบเขตพื้นที่ทำงาน:", + "inbox_panel.connect_to_download": "เชื่อมต่อ worker เพื่อดาวน์โหลดไฟล์ที่แชร์", + "inbox_panel.connect_to_see": "เชื่อมต่อเพื่อดูไฟล์ที่แชร์", + "inbox_panel.connect_to_upload": "เชื่อมต่อ worker เพื่ออัปโหลด", + "inbox_panel.copy_failed": "คัดลอกไม่สำเร็จ เบราว์เซอร์อาจบล็อกการเข้าถึงคลิปบอร์ด", + "inbox_panel.download": "ดาวน์โหลด", + "inbox_panel.drop_to_upload": "ลากไฟล์มาวางที่นี่เพื่ออัปโหลด", + "inbox_panel.helper_text": "แชร์ไฟล์กับ worker นี้จากแอป", + "inbox_panel.load_failed": "โหลดโฟลเดอร์ที่แชร์ไม่สำเร็จ", + "inbox_panel.missing_file_id": "ไม่พบ ID ของไฟล์ที่แชร์", + "inbox_panel.no_files": "ยังไม่มีไฟล์ที่แชร์", + "inbox_panel.refresh_tooltip": "รีเฟรชโฟลเดอร์ที่แชร์", + "inbox_panel.shared_folder": "โฟลเดอร์ที่แชร์", + "inbox_panel.showing_first": "แสดง {count} รายการแรก", + "inbox_panel.upload_failed": "อัปโหลดไปยังโฟลเดอร์ที่แชร์ไม่สำเร็จ", + "inbox_panel.upload_needs_worker": "เชื่อมต่อ worker เพื่ออัปโหลดไฟล์ไปยังโฟลเดอร์ที่แชร์", + "inbox_panel.upload_prompt": "ลากไฟล์มาวางหรือคลิกเพื่ออัปโหลด", + "inbox_panel.upload_success": "อัปโหลดไปยังโฟลเดอร์ที่แชร์แล้ว", + "inbox_panel.uploading": "กำลังอัปโหลด...", + "inbox_panel.uploading_label": "กำลังอัปโหลด {label}...", + "mcp.activate_button": "เปิดใช้งาน", + "mcp.add_modal_subtitle": "เชื่อมต่อ MCP server กำหนดเองด้วย URL หรือคำสั่งภายในเครื่อง", + "mcp.add_modal_title": "เพิ่มแอปกำหนดเอง", + "mcp.add_server_button": "เพิ่มแอป", + "mcp.advanced": "ขั้นสูง", + "mcp.advanced_settings": "การตั้งค่าขั้นสูง", + "mcp.advanced_settings_hint": "แก้ไขไฟล์ config และจัดการการเชื่อมต่อด้วยตนเอง", + "mcp.app_connected": "แอปที่เชื่อมต่อ", + "mcp.apps_connected": "แอปที่เชื่อมต่อ", + "mcp.apps_subtitle": "เชื่อมต่อเครื่องมือที่คุณชื่นชอบเพื่อให้ OpenWork ใช้งานแทนคุณ", + "mcp.apps_title": "แอป", + "mcp.auth.already_connected": "เชื่อมต่อแล้ว", + "mcp.auth.already_connected_description": "{server} ได้ยืนยันตัวตนแล้วและพร้อมใช้งาน", + "mcp.auth.applying_changes_body": "เรากำลังรีสตาร์ท worker เพื่อให้ MCP ใหม่พร้อมยืนยันตัวตน", + "mcp.auth.applying_changes_title": "กำลังใช้การเปลี่ยนแปลงก่อนเข้าสู่ระบบ", + "mcp.auth.authorization_link": "ลิงก์อนุมัติ", + "mcp.auth.authorization_still_required": "ยังต้องการอนุมัติ ลองอีกครั้งเพื่อเริ่มขั้นตอนใหม่", + "mcp.auth.callback_invalid": "วาง callback URL หรือพารามิเตอร์ code เพื่อเสร็จสิ้น OAuth", + "mcp.auth.callback_label": "Callback URL หรือรหัส", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "ยกเลิก", + "mcp.auth.client_registration_required": "ต้องลงทะเบียน client ก่อนดำเนินการ OAuth", + "mcp.auth.complete_connection": "เชื่อมต่อให้เสร็จ", + "mcp.auth.configured_previously": "MCP อาจถูกตั้งค่าไว้แบบทั่วไปหรือในเซสชันก่อนหน้า คุณสามารถปิดหน้าต่างนี้และเริ่มใช้เครื่องมือ MCP ได้ทันที", + "mcp.auth.connect_server": "เชื่อมต่อ {server}", + "mcp.auth.copied": "คัดลอกแล้ว", + "mcp.auth.copy_link": "คัดลอกลิงก์", + "mcp.auth.done": "เสร็จสิ้น", + "mcp.auth.failed_to_start_oauth": "เริ่มขั้นตอน OAuth ไม่สำเร็จ", + "mcp.auth.follow_browser_steps": "ทำตามขั้นตอนการอนุมัติในเบราว์เซอร์", + "mcp.auth.force_stop": "บังคับหยุด", + "mcp.auth.force_stopping": "กำลังหยุด...", + "mcp.auth.im_done": "ดำเนินการเสร็จแล้ว", + "mcp.auth.invalid_refresh_token": "Refresh token ของ OAuth ไม่ถูกต้องหรือหมดอายุ ยืนยันตัวตนอีกครั้งเพื่อดำเนินการต่อ", + "mcp.auth.manual_finish_hint": "วาง callback URL (localhost:19876) หรือรหัสเพื่อเชื่อมต่อให้เสร็จ", + "mcp.auth.manual_finish_title": "เซิร์ฟเวอร์ระยะไกล?", + "mcp.auth.oauth_completed_reload": "OAuth เสร็จสิ้น รีโหลด engine เพื่อเปิดใช้งาน MCP", + "mcp.auth.oauth_failed": "การยืนยันตัวตน OAuth ล้มเหลว", + "mcp.auth.oauth_not_supported_hint": "อาจหมายความว่า:\n• MCP server ไม่ได้ประกาศความสามารถ OAuth\n• Engine ต้องรีโหลดเพื่อตรวจสอบความสามารถของเซิร์ฟเวอร์\n• ลอง: opencode mcp auth {server} จาก CLI", + "mcp.auth.open_browser_signin": "เราจะเปิดเบราว์เซอร์เพื่อเข้าสู่ระบบ", + "mcp.auth.port_forward_hint": "เคล็ดลับ: forward callback port ถ้าจำเป็น: ssh -L 19876:127.0.0.1:19876 user@host", + "mcp.auth.reauth_action": "ยืนยันตัวตน OAuth อีกครั้ง", + "mcp.auth.reauth_cli_hint": "รัน: opencode mcp auth {server}", + "mcp.auth.reauth_failed": "ยืนยันตัวตนอีกครั้งไม่สำเร็จ", + "mcp.auth.reauth_remote_hint": "ยืนยันตัวตนอีกครั้งจากเครื่องที่รัน worker นี้", + "mcp.auth.reauth_running": "กำลังยืนยันตัวตนอีกครั้ง...", + "mcp.auth.reload_blocked": "การรีโหลดหยุดชั่วคราวขณะเซสชันกำลังทำงาน หยุดการทำงานเพื่อตั้งค่าให้เสร็จ", + "mcp.auth.reload_engine_retry": "ใช้การเปลี่ยนแปลงและลองอีกครั้ง", + "mcp.auth.reload_failed": "รีโหลด worker ก่อนเข้าสู่ระบบไม่สำเร็จ", + "mcp.auth.reload_notice": "เพื่อให้มีผล OpenWork ต้องรีเฟรชบริการ worker ซึ่งอาจหยุดเซสชันที่กำลังทำงาน", + "mcp.auth.reload_remote_confirm": "เพื่อให้มีผล OpenWork ต้องรีเฟรชบริการ worker ซึ่งอาจหยุดเซสชันที่กำลังทำงาน ดำเนินการต่อ?", + "mcp.auth.reopen_browser_link": "คลิกที่นี่เพื่อเปิดเบราว์เซอร์อีกครั้ง", + "mcp.auth.request_timed_out": "คำขอหมดเวลา", + "mcp.auth.retry": "ลองอีกครั้ง", + "mcp.auth.retry_now": "ลองอีกครั้งทันที", + "mcp.auth.server_disabled": "MCP server นี้ถูกปิดใช้งาน เปิดใช้งานแล้วลองอีกครั้ง", + "mcp.auth.step1_description": "เราจะเปิดขั้นตอนเข้าสู่ระบบของ {server} โดยอัตโนมัติ", + "mcp.auth.step1_title": "กำลังเปิดเบราว์เซอร์", + "mcp.auth.step2_description": "เข้าสู่ระบบและอนุมัติการเข้าถึงเมื่อมีการแจ้ง", + "mcp.auth.step2_title": "อนุมัติ OpenWork", + "mcp.auth.step3_description": "เราจะเชื่อมต่อให้เสร็จทันทีที่การอนุมัติเสร็จสิ้น", + "mcp.auth.step3_title": "กลับมาที่นี่เมื่อเสร็จแล้ว", + "mcp.auth.try_reload_engine": "{message} ลองรีโหลด engine ก่อน", + "mcp.auth.waiting_authorization": "กำลังรอการอนุมัติในเบราว์เซอร์ของคุณ...", + "mcp.auth.waiting_for_conversation_body": "เราจะนำคุณไปยืนยันตัวตนโดยเร็วที่สุด", + "mcp.auth.waiting_for_conversation_title": "กำลังรอให้บทสนทนาเสร็จสิ้น", + "mcp.auth.waiting_for_session": "กำลังรอให้ {session} ทำงานเสร็จ", + "mcp.available_apps": "แอปที่ใช้ได้", + "mcp.cap_signin": "เข้าสู่ระบบบัญชี", + "mcp.cap_tools": "เครื่องมือ AI", + "mcp.config_file": "ไฟล์ config", + "mcp.config_load_failed": "ไม่สามารถโหลดไฟล์ config", + "mcp.config_not_loaded": "ยังไม่ได้โหลด", + "mcp.config_source": "จากไฟล์ config", + "mcp.configured": "ตั้งค่าแล้ว", + "mcp.connect": "เชื่อมต่อ", + "mcp.connect_failed": "เชื่อมต่อไม่สำเร็จ ลองอีกครั้ง", + "mcp.connect_server_first": "เชื่อมต่อเซิร์ฟเวอร์ก่อน", + "mcp.connected": "เชื่อมต่อแล้ว", + "mcp.connected_badge": "เชื่อมต่อแล้ว", + "mcp.connecting": "กำลังเชื่อมต่อ...", + "mcp.connection_failed": "มีปัญหาการเชื่อมต่อ — ลองอีกครั้ง", + "mcp.connection_type": "การเชื่อมต่อ", + "mcp.control_chrome_browser_hint": "ใน Chrome 144 หรือใหม่กว่า ทำสิ่งนี้ก่อน:", + "mcp.control_chrome_browser_step_one": "เปิด chrome://inspect/#remote-debugging", + "mcp.control_chrome_browser_step_two": "เปิดใช้งาน remote debugging", + "mcp.control_chrome_browser_step_three": "อนุญาตการเชื่อมต่อ debugging ขาเข้าเมื่อ Chrome ถาม", + "mcp.control_chrome_browser_title": "1. เปิดการเข้าถึง Chrome", + "mcp.control_chrome_connect": "เพิ่ม Control Chrome", + "mcp.control_chrome_docs": "คู่มือ MCP อย่างเป็นทางการ", + "mcp.control_chrome_edit": "แก้ไขการตั้งค่า", + "mcp.control_chrome_profile_hint": "Control Chrome ปกติจะเปิดโปรไฟล์ Chrome แยก เปิดตัวเลือกนี้หากต้องการให้ OpenWork ใช้หน้าต่าง Chrome ที่เปิดอยู่", + "mcp.control_chrome_profile_title": "2. เลือก Chrome ที่จะใช้", + "mcp.control_chrome_save": "บันทึกการตั้งค่า", + "mcp.control_chrome_setup_subtitle": "เปิดการเข้าถึง Chrome แล้วเลือกว่า OpenWork ควรใช้โปรไฟล์ใหม่หรือแนบกับ Chrome ที่คุณใช้อยู่", + "mcp.control_chrome_setup_title": "ตั้งค่า Control Chrome", + "mcp.control_chrome_toggle_hint": "เมื่อเปิดใช้งาน OpenWork จะเพิ่ม --autoConnect เพื่อให้ MCP แนบกับ Chrome ที่คุณเริ่มไว้แล้ว", + "mcp.control_chrome_toggle_label": "ใช้โปรไฟล์ Chrome ที่มีอยู่", + "mcp.control_chrome_toggle_off": "OpenWork จะเปิดโปรไฟล์ Chrome แยกสำหรับ automation", + "mcp.control_chrome_toggle_on": "OpenWork จะใช้แท็บ, คุกกี้ และข้อมูลเข้าสู่ระบบปัจจุบันของคุณ", + "mcp.custom_app_cta_hint": "เชื่อมต่อ MCP server, เครื่องมือภายใน หรือแอปที่โฮสต์ของคุณเอง", + "mcp.desktop_required": "แอปต้องใช้แอปเดสก์ท็อป", + "mcp.docs_link": "เรียนรู้เพิ่มเติม", + "mcp.file_not_found": "ยังไม่ได้สร้างไฟล์ config", + "mcp.finish_setup": "เกือบเสร็จแล้ว", + "mcp.finish_setup_hint": "แตะ เปิดใช้งาน เพื่อเชื่อมต่อแอปให้เสร็จ", + "mcp.friendly_status_issue": "มีปัญหา", + "mcp.friendly_status_needs_signin": "ต้องเข้าสู่ระบบ", + "mcp.friendly_status_offline": "ออฟไลน์", + "mcp.friendly_status_paused": "หยุดชั่วคราว", + "mcp.friendly_status_ready": "พร้อม", + "mcp.last_synced": "ซิงค์แล้ว", + "mcp.login_action": "เข้าสู่ระบบ", + "mcp.login_hint": "เชื่อมต่อบัญชีของคุณเพื่อตั้งค่าแอปนี้ให้เสร็จ", + "mcp.login_unavailable": "แอปนี้ไม่รองรับการเข้าสู่ระบบจาก OpenWork", + "mcp.logout_action": "ออกจากระบบ", + "mcp.logout_failed": "ออกจากระบบไม่สำเร็จ", + "mcp.logout_hint": "ลบข้อมูล OAuth ที่เก็บไว้ คุณจะต้องเข้าสู่ระบบอีกครั้ง", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "การดำเนินการนี้จะลบข้อมูล OAuth ที่เก็บไว้สำหรับ {server} คุณจะต้องเข้าสู่ระบบอีกครั้งเพื่อใช้แอปนี้", + "mcp.logout_modal_title": "ออกจากระบบแอปนี้?", + "mcp.logout_success": "ออกจากระบบ {server} แล้ว", + "mcp.logout_working": "กำลังออกจากระบบ...", + "mcp.name_required": "ใส่ชื่อเซิร์ฟเวอร์", + "mcp.no_apps_hint": "เชื่อมต่อแอปด้านบนเพื่อเริ่มต้น", + "mcp.no_apps_yet": "ยังไม่มีแอปที่เชื่อมต่อ", + "mcp.oauth": "เข้าสู่ระบบ", + "mcp.oauth_optional_hint": "ใช้ OAuth ในเบราว์เซอร์เพื่อเชื่อมต่อบัญชีของคุณ", + "mcp.oauth_optional_label": "แอปนี้ต้องเข้าสู่ระบบ", + "mcp.one_click_connect": "เชื่อมต่อด้วยคลิกเดียว", + "mcp.open_file": "เปิดไฟล์", + "mcp.opening_label": "กำลังเปิด...", + "mcp.pick_workspace_error": "เลือกโฟลเดอร์พื้นที่ทำงานก่อน", + "mcp.pick_workspace_first": "เลือกโฟลเดอร์พื้นที่ทำงานก่อน", + "mcp.quick_connect_chrome_desc": "ควบคุมแท็บ Chrome ด้วย browser automation", + "mcp.quick_connect_chrome_title": "ควบคุม Chrome", + "mcp.quick_connect_context7_desc": "ค้นหาเอกสารผลิตภัณฑ์ด้วยบริบทที่สมบูรณ์ยิ่งขึ้น", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "วางแผน sprint และจัดการ ticket ได้เร็วขึ้น", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "ซิงค์หน้า ฐานข้อมูล และเอกสารโปรเจกต์", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "ติดตามรีลีสและแก้ไขข้อผิดพลาดใน production", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "ตรวจสอบการชำระเงิน ใบแจ้งหนี้ และการสมัครสมาชิก", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "หยุดงานที่กำลังทำงานเพื่อเปิดใช้งาน", + "mcp.reload_banner_description": "แตะ เปิดใช้งาน เพื่อเชื่อมต่อแอปให้เสร็จ", + "mcp.reload_banner_description_blocked": "มีงานกำลังทำงานอยู่ หยุดก่อน แล้วเปิดใช้งาน", + "mcp.remote_workspace_url_hint": "Remote workers เชื่อมต่อได้เร็วที่สุดกับ MCP server แบบ URL", + "mcp.remove_app": "ลบ", + "mcp.remove_failed": "ลบแอปไม่สำเร็จ", + "mcp.remove_modal_message": "คุณแน่ใจหรือไม่ว่าต้องการลบ {server}? คุณสามารถเพิ่มกลับได้ในภายหลัง", + "mcp.remove_modal_title": "ลบแอป", + "mcp.reveal_config_failed": "ไม่สามารถเปิดไฟล์ config", + "mcp.reveal_in_finder": "เปิดในตัวจัดการไฟล์", + "mcp.scope_global": "ทุกพื้นที่ทำงาน", + "mcp.scope_project": "พื้นที่ทำงานนี้", + "mcp.server_command": "คำสั่ง", + "mcp.server_command_hint": "คำสั่ง shell สำหรับเริ่มเซิร์ฟเวอร์", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "ชื่อแอป", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "ประเภท", + "mcp.server_url": "URL ของเซิร์ฟเวอร์", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "การเข้าสู่ระบบ", + "mcp.tap_to_connect": "แตะเพื่อเชื่อมต่อ", + "mcp.technical_details": "รายละเอียดทางเทคนิค", + "mcp.type_cloud": "Cloud (เข้าสู่ระบบด้วยบัญชีของคุณ)", + "mcp.type_local": "ภายในเครื่อง (ทำงานบนอุปกรณ์นี้)", + "mcp.type_local_cmd": "ภายในเครื่อง (คำสั่ง)", + "mcp.type_remote": "ระยะไกล (URL)", + "mcp.url_or_command_required": "ใส่ URL สำหรับระยะไกล หรือคำสั่งสำหรับเซิร์ฟเวอร์ภายในเครื่อง", + "mcp.your_apps": "แอปของคุณ", + "message.tool_request_label": "คำขอ", + "message.tool_result_label": "ผลลัพธ์", + "message.waiting_subagent": "กำลังรอ transcript ของ subagent", + "message_list.copy_message": "คัดลอกข้อความ", + "message_list.open_session": "เปิดเซสชัน", + "message_list.step_updates_progress": "อัปเดตความคืบหน้า", + "message_list.subagent_loading_transcript": "กำลังโหลด transcript", + "message_list.subagent_message_count": "{count} ข้อความ", + "message_list.subagent_running": "กำลังทำงาน", + "message_list.subagent_session_fallback": "เซสชัน Subagent", + "message_list.subagent_type_task": "งาน {agentType}", + "message_list.subagent_waiting_transcript": "กำลังรอ transcript", + "message_list.tool_checked_url": "ตรวจสอบ {url}", + "message_list.tool_checked_web_fallback": "ตรวจสอบหน้าเว็บ", + "message_list.tool_delegate_agent": "มอบหมาย {agent}", + "message_list.tool_delegate_task_fallback": "มอบหมายงาน", + "message_list.tool_load_skill_fallback": "โหลด skill", + "message_list.tool_load_skill_named": "โหลด skill {name}", + "message_list.tool_read_todo": "อ่านรายการสิ่งที่ต้องทำ", + "message_list.tool_reviewed_file": "ตรวจสอบ {file}", + "message_list.tool_reviewed_file_fallback": "ตรวจสอบไฟล์", + "message_list.tool_reviewed_files_fallback": "ตรวจสอบไฟล์", + "message_list.tool_reviewed_path": "ตรวจสอบ {path}", + "message_list.tool_run_command": "รัน {command}", + "message_list.tool_run_command_fallback": "รันคำสั่ง", + "message_list.tool_searched_code_fallback": "ค้นหาโค้ด", + "message_list.tool_searched_pattern": "ค้นหา {pattern}", + "message_list.tool_update_file": "อัปเดต {file}", + "message_list.tool_update_file_fallback": "อัปเดตไฟล์", + "message_list.tool_update_todo": "อัปเดตรายการสิ่งที่ต้องทำ", + "message_list.tool_updated_file": "อัปเดต {file}", + "message_list.tool_updated_file_fallback": "อัปเดตไฟล์", + "model_behavior.desc_builtin": "โมเดลนี้กำหนดเส้นทางการใช้เหตุผลเองและไม่แสดงโปรไฟล์ที่นี่", + "model_behavior.desc_generic": "ใช้โปรไฟล์ {label}", + "model_behavior.desc_high": "ใช้เวลามากขึ้นในการใช้เหตุผลก่อนตอบ", + "model_behavior.desc_high_anthropic": "ใช้งบประมาณ extended thinking มาตรฐาน", + "model_behavior.desc_low": "ใช้การใช้เหตุผลเบาก่อนตอบ", + "model_behavior.desc_low_google": "ใช้งบประมาณการใช้เหตุผลเบาขึ้นเพื่อตอบสนองเร็วขึ้น", + "model_behavior.desc_max": "ใช้โปรไฟล์การใช้เหตุผลที่ลึกที่สุดของ provider", + "model_behavior.desc_max_anthropic": "ใช้งบประมาณ extended thinking สูงสุดที่มี", + "model_behavior.desc_medium": "สมดุลระหว่างความเร็วและความลึกของการใช้เหตุผล", + "model_behavior.desc_minimal": "ใช้การใช้เหตุผลเพียงเล็กน้อย", + "model_behavior.desc_none": "ให้ความสำคัญกับความเร็วด้วยเส้นทางการใช้เหตุผลเบาที่สุด", + "model_behavior.desc_standard": "โมเดลนี้ไม่มีการควบคุมการใช้เหตุผลเพิ่มเติม", + "model_behavior.label_balanced": "สมดุล", + "model_behavior.label_builtin": "ในตัว", + "model_behavior.label_deep": "ลึก", + "model_behavior.label_extended": "ขยาย", + "model_behavior.label_fast": "เร็ว", + "model_behavior.label_light": "เบา", + "model_behavior.label_maximum": "สูงสุด", + "model_behavior.label_quick": "รวดเร็ว", + "model_behavior.label_standard": "มาตรฐาน", + "model_behavior.title_builtin_reasoning": "การใช้เหตุผลในตัว", + "model_behavior.title_extended_thinking": "Extended thinking", + "model_behavior.title_reasoning_budget": "งบประมาณการใช้เหตุผล", + "model_behavior.title_reasoning_effort": "ระดับการใช้เหตุผล", + "model_behavior.title_standard_generation": "การสร้างแบบมาตรฐาน", + "model_picker.chat_model_desc": "เลือกโมเดลสำหรับแชทนี้ หากโมเดลรองรับ reasoning profiles ให้ตั้งค่าบนการ์ดของโมเดล", + "model_picker.chat_model_title": "โมเดลแชท", + "model_picker.connect_provider_hint": "เชื่อมต่อผู้ให้บริการนี้เพื่อเรียกดูและบันทึกโมเดล", + "model_picker.default_model_desc": "เลือกโมเดลเริ่มต้นสำหรับแชทใหม่ แล้วปรับ reasoning profiles บนการ์ดก่อนกด เสร็จสิ้น", + "model_picker.default_model_title": "โมเดลเริ่มต้น", + "model_picker.model_count": "{count} โมเดล", + "model_picker.model_count_one": "1 โมเดล", + "model_picker.more_providers": "ผู้ให้บริการเพิ่มเติม", + "model_picker.no_results": "ไม่พบโมเดลที่ตรงกับการค้นหา", + "model_picker.other_connected_models": "โมเดลที่เชื่อมต่ออื่นๆ", + "model_picker.recommended": "แนะนำ", + "onboarding.access_label": "การเข้าถึง", + "onboarding.add": "เพิ่ม", + "onboarding.add_folder_path": "เพิ่มเส้นทางโฟลเดอร์", + "onboarding.advanced_settings": "การตั้งค่าขั้นสูง", + "onboarding.attach": "เชื่อมต่อ", + "onboarding.attach_description": "เชื่อมต่อกับเซสชันที่มีอยู่บนอุปกรณ์นี้", + "onboarding.authorize_folder": "อนุญาตโฟลเดอร์", + "onboarding.back": "กลับ", + "onboarding.checking_cli": "กำลังตรวจสอบ OpenCode CLI...", + "onboarding.choose_workspace_folder": "เลือกโฟลเดอร์พื้นที่ทำงาน", + "onboarding.cli_checking": "กำลังตรวจสอบการติดตั้ง...", + "onboarding.cli_install_commands": "ติดตั้ง OpenCode ด้วยคำสั่งด้านล่าง แล้วรีสตาร์ท OpenWork", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "OpenCode CLI ต้องอัปเดตสำหรับ serve", + "onboarding.cli_not_found": "ไม่พบ OpenCode CLI", + "onboarding.cli_not_found_hint": "ไม่พบ ติดตั้งเพื่อรัน local server", + "onboarding.cli_ready": "OpenCode CLI พร้อมใช้งาน", + "onboarding.cli_recheck": "ตรวจสอบใหม่", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "ติดตั้งแล้ว", + "onboarding.create_first_workspace": "สร้างพื้นที่ทำงานแรกของคุณ", + "onboarding.create_workspace": "สร้างพื้นที่ทำงาน", + "onboarding.engine_running": "Engine กำลังทำงานอยู่แล้ว", + "onboarding.folders_allowed": "อนุญาต {count} โฟลเดอร์", + "onboarding.getting_ready": "กำลังเตรียมทุกอย่าง", + "onboarding.install": "ติดตั้ง OpenCode", + "onboarding.install_instruction": "ติดตั้ง OpenCode เพื่อเปิดใช้งาน local server (ไม่ต้องใช้ terminal)", + "onboarding.last_checked": "ตรวจสอบล่าสุด {time}", + "onboarding.manage_access_hint": "คุณสามารถจัดการการเข้าถึงในการตั้งค่าขั้นสูง", + "onboarding.open_settings": "เปิดการตั้งค่า", + "onboarding.open_settings_hint": "ต้องการตัวเลือก engine หรือการเข้าถึง? เปิดการตั้งค่า", + "onboarding.pick": "เลือก", + "onboarding.ready_message": "OpenCode พร้อมเริ่ม local server", + "onboarding.remember_choice": "จดจำตัวเลือกสำหรับครั้งถัดไป", + "onboarding.remote_workspace_action": "เชื่อมต่อ", + "onboarding.remote_workspace_card_description": "เชื่อมต่อ OpenWork server เพื่อเข้าถึงพื้นที่ทำงานที่แชร์", + "onboarding.remote_workspace_card_title": "เชื่อมต่อพื้นที่ทำงานระยะไกล", + "onboarding.remote_workspace_description": "เชื่อมต่อ OpenWork server เพื่อเข้าถึงพื้นที่ทำงานจากที่ใดก็ได้", + "onboarding.remote_workspace_title": "เชื่อมต่อ OpenWork server", + "onboarding.remove": "ลบ", + "onboarding.resolved_path": "เส้นทางที่ resolve", + "onboarding.run_local": "รันภายในเครื่อง", + "onboarding.run_local_description": "OpenWork รัน OpenCode ภายในเครื่องและเก็บงานของคุณเป็นส่วนตัว", + "onboarding.search_notes": "บันทึกการค้นหา", + "onboarding.searching_host": "กำลังเชื่อมต่อ OpenWork server...", + "onboarding.serve_help": "ผลลัพธ์ serve --help", + "onboarding.show_search_notes": "แสดงบันทึกการค้นหา", + "onboarding.start": "เริ่ม OpenWork", + "onboarding.starting_host": "กำลังเริ่ม OpenWork server...", + "onboarding.theme_current": "ปัจจุบัน: {mode}", + "onboarding.theme_dark": "มืด", + "onboarding.theme_label": "ธีม", + "onboarding.theme_light": "สว่าง", + "onboarding.theme_system": "ตามระบบ", + "onboarding.verifying": "กำลังตรวจสอบ secure handshake", + "onboarding.version": "เวอร์ชัน", + "onboarding.welcome_title": "วันนี้คุณต้องการรัน OpenWork อย่างไร?", + "onboarding.windows_install_instruction": "ติดตั้ง OpenCode สำหรับ Windows แล้วรีสตาร์ท OpenWork ตรวจสอบว่า opencode.exe อยู่ใน PATH", + "onboarding.workspace_folder_label": "พื้นที่ทำงานคือโฟลเดอร์ที่มี skills, plugins และ commands เป็นของตนเอง", + "plugins.add": "เพิ่ม", + "plugins.add_hint": "เพิ่มชื่อแพ็กเกจ npm เช่น opencode-wakatime", + "plugins.add_label": "เพิ่ม plugin", + "plugins.added": "เพิ่มแล้ว", + "plugins.config": "การตั้งค่า", + "plugins.config_label": "การตั้งค่า", + "plugins.desc": "จัดการ `opencode.json` สำหรับโปรเจกต์หรือ OpenCode plugins ทั่วไป", + "plugins.empty": "ยังไม่มี plugins ที่ตั้งค่า", + "plugins.enabled": "เปิดใช้งาน", + "plugins.hide_setup": "ซ่อนการตั้งค่า", + "plugins.not_loaded": "ยังไม่ได้โหลด", + "plugins.not_loaded_yet": "ยังไม่ได้โหลด", + "plugins.remove": "ลบ", + "plugins.scope_global": "ทั่วไป", + "plugins.scope_project": "โปรเจกต์", + "plugins.setup": "ตั้งค่า", + "plugins.suggested": "Plugins ที่แนะนำ", + "plugins.suggested_heading": "Plugins ที่แนะนำ", + "plugins.title": "OpenCode plugins", + "providers.api_key_label": "API key", + "providers.api_key_required": "ต้องใส่ API key", + "providers.auth_failed": "การยืนยันตัวตนล้มเหลว", + "providers.connect_failed": "เชื่อมต่อผู้ให้บริการไม่สำเร็จ", + "providers.disabled_in_config_suffix": "และปิดใช้งานใน OpenCode config แล้ว", + "providers.disconnect_failed": "ตัดการเชื่อมต่อผู้ให้บริการไม่สำเร็จ", + "providers.disconnected_prefix": "ตัดการเชื่อมต่อแล้ว", + "providers.load_failed": "โหลดผู้ให้บริการไม่สำเร็จ", + "providers.no_oauth_prefix": "ไม่มีขั้นตอน OAuth สำหรับ", + "providers.no_providers_available": "ไม่มีผู้ให้บริการที่ใช้ได้", + "providers.not_connected": "ไม่ได้เชื่อมต่อกับเซิร์ฟเวอร์", + "providers.not_oauth_flow_prefix": "วิธีการยืนยันตัวตนที่เลือกไม่ใช่ OAuth flow สำหรับ", + "providers.oauth_failed": "ดำเนินการ OAuth ไม่สำเร็จ", + "providers.oauth_method_required": "ต้องใช้วิธี OAuth", + "providers.provider_error": "ข้อผิดพลาดของผู้ให้บริการ ({provider})", + "providers.provider_id_required": "ต้องใส่ Provider ID", + "providers.rate_limit_exceeded": "เกินขีดจำกัดอัตราการใช้งาน", + "providers.removal_unsupported": "Client นี้ไม่รองรับการลบการยืนยันตัวตนของผู้ให้บริการ", + "providers.request_failed": "คำขอล้มเหลว", + "providers.save_api_key_failed": "บันทึก API key ไม่สำเร็จ", + "providers.still_connected_suffix": " แต่ worker ยังรายงานว่าเชื่อมต่ออยู่ ล้าง API key หรือข้อมูล OAuth ที่เหลือแล้วรีสตาร์ท worker เพื่อตัดการเชื่อมต่อทั้งหมด", + "providers.unknown_provider": "ผู้ให้บริการที่ไม่รู้จัก", + "providers.use_api_key_suffix": "ใช้ API key แทน", + "question_modal.custom_answer_label": "หรือพิมพ์คำตอบเอง", + "question_modal.custom_answer_placeholder": "พิมพ์คำตอบที่นี่...", + "question_modal.question_counter": "คำถามที่ {current} จาก {total}", + "session.allow_for_session": "อนุญาตตลอดเซสชัน", + "session.allow_once": "อนุญาตครั้งเดียว", + "session.api_key_saved": "บันทึก API key แล้ว", + "session.attachments_add_token": "เพิ่มโทเค็นเซิร์ฟเวอร์เพื่อแนบไฟล์", + "session.attachments_connect_server": "เชื่อมต่อ OpenWork server เพื่อแนบไฟล์", + "session.back": "กลับ", + "session.close_quick_actions": "ปิดการดำเนินการด่วน", + "session.close_search": "ปิดการค้นหา", + "session.cmd_compact_detail": "ส่งคำสั่งบีบอัดไปยัง OpenCode สำหรับเซสชันนี้", + "session.cmd_compact_detail_empty": "ยังไม่มีข้อความผู้ใช้ที่จะบีบอัด", + "session.cmd_compact_meta": "บีบอัด", + "session.cmd_compact_title": "บีบอัดบทสนทนา", + "session.cmd_current_workspace": "พื้นที่ทำงานปัจจุบัน", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "โมเดล", + "session.cmd_model_meta": "เปิด", + "session.cmd_model_title": "เปลี่ยนโมเดล", + "session.cmd_new_session_detail": "เริ่มงานใหม่ในพื้นที่ทำงานปัจจุบัน", + "session.cmd_new_session_meta": "สร้าง", + "session.cmd_new_session_title": "สร้างเซสชันใหม่", + "session.cmd_provider_detail": "เปิดขั้นตอนเชื่อมต่อผู้ให้บริการ", + "session.cmd_provider_meta": "เปิด", + "session.cmd_provider_title": "เชื่อมต่อผู้ให้บริการ", + "session.cmd_rename_detail_fallback": "ตั้งชื่อเซสชันที่เลือกให้ชัดเจนขึ้น", + "session.cmd_rename_meta": "เปลี่ยนชื่อ", + "session.cmd_rename_title": "เปลี่ยนชื่อเซสชันปัจจุบัน", + "session.cmd_sessions_detail": "{count} เซสชันที่ใช้ได้ข้ามพื้นที่ทำงาน", + "session.cmd_sessions_meta": "ข้าม", + "session.cmd_sessions_title": "ค้นหาเซสชัน", + "session.cmd_switch": "สลับ", + "session.compacted": "บีบอัดเซสชันแล้ว", + "session.compacting": "กำลังบีบอัดบริบทเซสชัน...", + "session.compacting_auto": "OpenCode กำลังบีบอัดเซสชันนี้โดยอัตโนมัติ", + "session.compacting_manual": "OpenCode กำลังบีบอัดเซสชันนี้", + "session.compaction_finished": "OpenCode บีบอัดบริบทเซสชันเสร็จแล้ว", + "session.compaction_started": "OpenCode เริ่มบีบอัดบริบทเซสชัน", + "session.conflict_sync_toast": "เกิดความขัดแย้งในการซิงค์ {path} บันทึกการเปลี่ยนแปลงในเครื่องไปที่ {conflictPath}", + "session.connect_failed": "เชื่อมต่อไม่สำเร็จ", + "session.connect_to_sync": "เชื่อมต่อ OpenWork server เพื่อซิงค์ไฟล์ระยะไกล", + "session.create_or_connect_workspace": "สร้างหรือเชื่อมต่อพื้นที่ทำงาน", + "session.create_workspace_desc": "เปิดตัวสร้างพื้นที่ทำงานและเลือกวิธีเริ่มต้น", + "session.create_workspace_title": "สร้างพื้นที่ทำงาน", + "session.default_agent": "Agent เริ่มต้น", + "session.default_title": "เซสชันใหม่", + "session.delete": "ลบ", + "session.delete_named_session_message": "การดำเนินการนี้จะลบ \"{title}\" และข้อความทั้งหมดอย่างถาวร", + "session.delete_session_generic": "การดำเนินการนี้จะลบเซสชันที่เลือกและข้อความทั้งหมดอย่างถาวร", + "session.delete_session_title": "ลบเซสชัน?", + "session.deleted": "ลบเซสชันแล้ว", + "session.deleting": "กำลังลบ...", + "session.deny": "ปฏิเสธ", + "session.details": "รายละเอียด", + "session.details_label": "รายละเอียด", + "session.doom_loop_label": "Doom Loop", + "session.doom_loop_message": "OpenCode ตรวจพบการเรียกเครื่องมือซ้ำด้วยข้อมูลเดิมและถามว่าควรดำเนินการต่อหลังจากล้มเหลวซ้ำ", + "session.doom_loop_note": "ปฏิเสธเพื่อหยุดลูป หรืออนุญาตหากต้องการให้ agent ลองต่อ", + "session.doom_loop_repeated_call_label": "การเรียกซ้ำ", + "session.doom_loop_repeated_tool_call": "การเรียกเครื่องมือซ้ำ", + "session.doom_loop_title": "ตรวจพบ Doom Loop", + "session.doom_loop_tool_label": "เครื่องมือ", + "session.downloading": "กำลังดาวน์โหลด", + "session.downloading_percent": "กำลังดาวน์โหลด {percent}%", + "session.downloading_update_title": "กำลังดาวน์โหลดอัปเดต {version}", + "session.export_already_running": "การส่งออกกำลังทำงานอยู่", + "session.export_desktop_only": "การส่งออกใช้งานได้เฉพาะในแอปเดสก์ท็อป", + "session.export_desktop_only_local": "การส่งออกใช้งานได้สำหรับ local workers ในแอปเดสก์ท็อป", + "session.export_local_only": "การส่งออกรองรับเฉพาะ local workers", + "session.failed_to_compact": "บีบอัดเซสชันไม่สำเร็จ", + "session.failed_to_create_session": "สร้างเซสชันไม่สำเร็จ", + "session.failed_to_delete": "ลบเซสชันไม่สำเร็จ", + "session.failed_to_load_agents": "โหลด agents ไม่สำเร็จ", + "session.failed_to_load_providers": "โหลดผู้ให้บริการไม่สำเร็จ", + "session.failed_to_redo": "ทำซ้ำไม่สำเร็จ", + "session.failed_to_save_api_key": "บันทึก API key ไม่สำเร็จ", + "session.failed_to_stop": "หยุดไม่สำเร็จ", + "session.failed_to_undo": "เลิกทำไม่สำเร็จ", + "session.file_open_desktop_only": "การเปิดไฟล์ใช้งานได้เฉพาะในแอปเดสก์ท็อป", + "session.file_open_failed": "เปิดไฟล์ไม่สำเร็จ", + "session.file_open_remote_unavailable": "การเปิดไฟล์ไม่พร้อมใช้งานสำหรับพื้นที่ทำงานระยะไกล", + "session.flyout_file_modified": "ไฟล์ถูกแก้ไข", + "session.flyout_new_task": "งานใหม่", + "session.install_update": "ติดตั้งอัปเดต", + "session.jump_to_latest": "ข้ามไปล่าสุด", + "session.jump_to_start": "ข้ามไปต้นข้อความ", + "session.load_earlier": "โหลดข้อความก่อนหน้า", + "session.loading_detail": "กำลังดึงข้อความล่าสุดสำหรับงานนี้", + "session.loading_earlier": "กำลังโหลดข้อความก่อนหน้า...", + "session.loading_session": "กำลังโหลดเซสชัน", + "session.loading_title": "กำลังโหลดเซสชัน", + "session.menu_label": "เมนู", + "session.model": "โมเดล", + "session.model_fallback": "โมเดล", + "session.new_task": "งานใหม่", + "session.next_match": "ผลลัพธ์ถัดไป", + "session.no_matches": "ไม่พบรายการที่ตรงกัน", + "session.no_matches_command": "ไม่พบรายการที่ตรงกัน", + "session.no_session_selected": "ยังไม่ได้เลือกเซสชัน", + "session.nothing_to_compact": "ยังไม่มีสิ่งที่จะบีบอัด", + "session.nothing_to_redo": "ไม่มีสิ่งที่จะทำซ้ำ", + "session.nothing_to_retry": "ยังไม่มีสิ่งที่จะลองใหม่", + "session.nothing_to_undo": "ยังไม่มีสิ่งที่จะเลิกทำ", + "session.oauth_failed": "OAuth ล้มเหลว", + "session.obsidian_worker_relative_only": "เปิดได้เฉพาะไฟล์ที่สัมพันธ์กับ worker ใน Obsidian", + "session.open": "เปิด", + "session.palette_hint_navigate": "ใช้ปุ่มลูกศรเพื่อนำทาง", + "session.palette_hint_run": "Enter เพื่อรัน · Esc เพื่อปิด", + "session.palette_placeholder_actions": "ค้นหาการดำเนินการ", + "session.palette_placeholder_sessions": "ค้นหาตามชื่อเซสชันหรือพื้นที่ทำงาน", + "session.palette_title_actions": "การดำเนินการด่วน", + "session.palette_title_sessions": "ค้นหาเซสชัน", + "session.permission_detail_command": "คำสั่ง", + "session.permission_detail_cwd": "ไดเรกทอรีทำงาน", + "session.permission_detail_description": "คำอธิบาย", + "session.permission_detail_diff": "ส่วนต่าง", + "session.permission_detail_file": "ไฟล์", + "session.permission_detail_files": "ไฟล์", + "session.permission_detail_agent": "เอเจนต์", + "session.permission_detail_parent_directory": "ไดเรกทอรีหลัก", + "session.permission_detail_path": "พาธ", + "session.permission_detail_query": "คำค้นหา", + "session.permission_detail_target": "เป้าหมาย", + "session.permission_detail_tool": "เครื่องมือ", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "แก้ไขไฟล์", + "session.permission_kind_external_directory": "ไดเรกทอรีภายนอก", + "session.permission_kind_question": "คำถาม", + "session.permission_kind_read": "อ่านไฟล์", + "session.permission_kind_skill": "Skill", + "session.permission_kind_task": "งานย่อย", + "session.permission_kind_todowrite": "เขียนรายการงาน", + "session.permission_label": "การอนุญาต", + "session.permission_message": "OpenCode กำลังขออนุญาตเพื่อดำเนินการต่อ", + "session.permission_message_bash": "ตรวจสอบขอบเขตคำสั่งก่อนอนุญาตให้ OpenCode ทำต่อ", + "session.permission_message_edit": "ตรวจสอบไฟล์และส่วนต่างก่อนอนุญาตให้ OpenCode แก้ไข", + "session.permission_message_external_directory": "ตรวจสอบโฟลเดอร์ก่อนอนุญาตให้เข้าถึงนอก workspace", + "session.permission_message_read": "ตรวจสอบขอบเขตไฟล์ที่ร้องขอก่อนอนุญาตให้เข้าถึง", + "session.permission_message_task": "ตรวจสอบงานย่อยที่ร้องขอก่อนอนุญาตให้เริ่ม", + "session.permission_metadata_unavailable": "ไม่สามารถแสดงเมตาดาต้าได้", + "session.permission_required": "ต้องการอนุญาต", + "session.permission_review_label": "ตรวจสอบ", + "session.permission_scope_empty": "ไม่มีขอบเขตเฉพาะ", + "session.permission_decision_hint": "อนุญาตครั้งเดียวสำหรับคำขอนี้ หรืออนุญาตตลอดเซสชันเมื่อคุณเชื่อถือขอบเขตนี้", + "session.permission_title_bash": "เรียกใช้คำสั่ง shell?", + "session.permission_title_edit": "แก้ไขไฟล์?", + "session.permission_title_external_directory": "เข้าถึงโฟลเดอร์ภายนอก?", + "session.permission_title_generic": "อนุมัติ {permission}?", + "session.permission_title_read": "อ่านไฟล์?", + "session.permission_title_task": "เริ่มงานย่อย?", + "session.phase_responding": "กำลังตอบกลับ", + "session.phase_retrying": "กำลังลองใหม่", + "session.phase_run_failed": "การทำงานล้มเหลว", + "session.phase_sending": "กำลังส่ง", + "session.pick_folder_desc": "เลือกโฟลเดอร์โปรเจกต์หรือบันทึกที่มีอยู่ แล้ว OpenWork จะใช้เป็นพื้นที่ทำงาน", + "session.pick_folder_title": "เลือกโฟลเดอร์ที่ต้องการทำงาน", + "session.pick_workspace_to_open": "เลือกพื้นที่ทำงานเพื่อเปิดไฟล์", + "session.prev_match": "ผลลัพธ์ก่อนหน้า", + "session.provider_auth_in_progress": "การยืนยันตัวตนผู้ให้บริการกำลังดำเนินอยู่", + "session.provider_connected": "เชื่อมต่อผู้ให้บริการแล้ว", + "session.quick_actions_label": "การดำเนินการด่วน", + "session.quick_actions_title": "การดำเนินการด่วน (Ctrl/Cmd+K)", + "session.redo_aria_label": "ทำซ้ำข้อความที่ย้อนกลับ", + "session.redo_label": "ทำซ้ำ", + "session.redo_title": "ทำซ้ำข้อความที่ย้อนกลับ", + "session.remote_sync_failed": "ซิงค์ไฟล์ระยะไกลไม่สำเร็จ", + "session.rename_description": "อัปเดตชื่อสำหรับเซสชันนี้", + "session.rename_label": "ชื่อเซสชัน", + "session.rename_placeholder": "ใส่ชื่อใหม่", + "session.rename_title": "เปลี่ยนชื่อเซสชัน", + "session.resize_workspace_column": "ปรับขนาดคอลัมน์พื้นที่ทำงาน", + "session.restart_update_title": "รีสตาร์ทเพื่อใช้อัปเดต {version}", + "session.restored_message": "กู้คืนข้อความที่ย้อนกลับแล้ว", + "session.reveal": "เปิดในตัวจัดการไฟล์", + "session.reveal_desktop_only": "การเปิดในตัวจัดการไฟล์ใช้งานได้เฉพาะในแอปเดสก์ท็อป", + "session.revert_label": "ย้อนกลับ", + "session.reverted_last_message": "ย้อนกลับข้อความผู้ใช้ล่าสุดแล้ว", + "session.run": "รัน", + "session.scope_label": "ขอบเขต", + "session.search_conversation_label": "ค้นหาในบทสนทนา", + "session.search_conversation_title": "ค้นหาในบทสนทนา (Ctrl/Cmd+F)", + "session.search_next": "ถัดไป", + "session.search_placeholder": "ค้นหาในแชทนี้", + "session.search_position": "{current} จาก {total}", + "session.search_prev": "ก่อนหน้า", + "session.share_active_cloud_org": "องค์กร Cloud ที่ใช้งาน", + "session.share_choose_org": "เลือกองค์กรใน Settings -> Cloud ก่อนแชร์กับทีม", + "session.share_collaborator_hint": "การเข้าถึงระยะไกลปกติเมื่อไม่ต้องการสิทธิ์ของเจ้าของ", + "session.share_collaborator_host_hint": "การเข้าถึงระยะไกลปกติสำหรับ host นี้โดยไม่ต้องใช้สิทธิ์ของเจ้าของ", + "session.share_collaborator_label": "โทเค็นผู้ร่วมงาน", + "session.share_collaborator_token": "โทเค็นผู้ร่วมงาน", + "session.share_connected_with_hint": "พื้นที่ทำงานนี้เชื่อมต่ออยู่ด้วยรหัสผ่านนี้", + "session.share_desktop_app_required": "ต้องใช้แอปเดสก์ท็อป", + "session.share_desktop_required": "ต้องใช้แอปเดสก์ท็อป", + "session.share_host_url_and_token_required": "ต้องใช้ URL ของ OpenWork host และโทเค็น", + "session.share_local_host_not_ready": "OpenWork host ภายในเครื่องยังไม่พร้อม", + "session.share_missing_host_url": "ไม่พบ URL ของ OpenWork host", + "session.share_missing_token": "ไม่พบโทเค็น OpenWork", + "session.share_no_skills": "ไม่พบ skills ในพื้นที่ทำงานนี้", + "session.share_note_direct_runtime": "Engine runtime ถูกตั้งเป็น Direct การสลับ local workers อาจรีสตาร์ท host และตัดการเชื่อมต่อ clients โทเค็นอาจเปลี่ยนหลังรีสตาร์ท", + "session.share_opencode_base_url": "URL ฐาน OpenCode", + "session.share_openwork_workers_only": "ลิงก์บริการแชร์ใช้ได้สำหรับ OpenWork workers", + "session.share_owner_permission_hint": "ใช้เมื่อ remote client ต้องตอบคำขออนุญาต", + "session.share_password": "รหัสผ่าน", + "session.share_password_owner_hint": "ใช้เมื่อ remote client ต้องตอบคำขออนุญาต", + "session.share_publish_skills_failed": "เผยแพร่ชุด skills ไม่สำเร็จ", + "session.share_publish_workspace_failed": "เผยแพร่โปรไฟล์พื้นที่ทำงานไม่สำเร็จ", + "session.share_resolve_local_workspace_failed": "ไม่สามารถระบุพื้นที่ทำงานนี้บน OpenWork host ภายในเครื่อง", + "session.share_resolve_remote_workspace_failed": "ไม่สามารถระบุพื้นที่ทำงานนี้บน OpenWork host", + "session.share_save_team_template_failed": "บันทึกเทมเพลตทีมไม่สำเร็จ", + "session.share_saved_to_org": "บันทึก {name} ไปยัง {org} แล้ว", + "session.share_select_workspace": "เลือกพื้นที่ทำงานก่อน", + "session.share_set_token_hint": "ตั้งโทเค็นในการตั้งค่าพื้นที่ทำงาน", + "session.share_sign_in_required": "เข้าสู่ระบบ OpenWork Cloud ใน Settings เพื่อแชร์กับทีม", + "session.share_skills_set_desc": "ชุด skills ครบถ้วนจากพื้นที่ทำงาน OpenWork", + "session.share_starting_server": "กำลังเริ่มเซิร์ฟเวอร์...", + "session.share_team_fallback_name": "เทมเพลตทีมของคุณ", + "session.share_url_resolving_hint": "URL ของ Worker กำลัง resolve; แสดง host URL เป็นตัวสำรอง", + "session.share_url_worker_hint": "ใช้บนโทรศัพท์หรือแล็ปท็อปที่เชื่อมต่อกับ worker นี้", + "session.share_worker_url": "URL ของ Worker", + "session.share_worker_url_phones_hint": "ใช้บนโทรศัพท์หรือแล็ปท็อปที่เชื่อมต่อกับ worker นี้", + "session.share_worker_url_resolving_hint": "URL ของ Worker กำลัง resolve; แสดง host URL เป็นตัวสำรอง", + "session.shared_folder_upload_failed": "อัปโหลดไปยังโฟลเดอร์ที่แชร์ไม่สำเร็จ", + "session.show_earlier": "แสดง {count} ข้อความก่อนหน้า", + "session.status_active": "เซสชันกำลังทำงาน", + "session.status_compacting": "กำลังบีบอัดบริบท", + "session.status_delegating": "กำลังมอบหมาย", + "session.status_gathering_context": "กำลังรวบรวมบริบท", + "session.status_planning": "กำลังวางแผน", + "session.status_ready": "พร้อม", + "session.status_ready_session": "เซสชันพร้อม", + "session.status_running_shell": "กำลังรัน shell", + "session.status_searching_codebase": "กำลังค้นหาโค้ด", + "session.status_searching_web": "กำลังค้นหาเว็บ", + "session.status_thinking": "กำลังคิด", + "session.status_working": "กำลังทำงาน", + "session.status_writing_file": "กำลังเขียนไฟล์", + "session.stopped": "หยุดแล้ว", + "session.stopping_run": "กำลังหยุดการทำงาน...", + "session.todo_progress": "{completed} จาก {total} งานเสร็จแล้ว", + "session.trying_again": "กำลังลองใหม่...", + "session.unable_to_open_file": "ไม่สามารถเปิดไฟล์", + "session.unable_to_open_obsidian": "ไม่สามารถเปิดไฟล์ใน Obsidian", + "session.unable_to_reveal": "ไม่สามารถเปิดพื้นที่ทำงาน", + "session.undo_label": "ย้อนกลับ", + "session.undo_title": "เลิกทำข้อความล่าสุด", + "session.update_available": "มีอัปเดต", + "session.update_available_title": "มีอัปเดต {version}", + "session.update_ready": "อัปเดตพร้อมแล้ว", + "session.update_ready_stop_runs_title": "อัปเดตพร้อม {version} หยุดงานที่กำลังทำงานเพื่อรีสตาร์ท", + "session.upload_connect_server": "เชื่อมต่อ OpenWork server เพื่ออัปโหลดไฟล์ไปยังโฟลเดอร์ที่แชร์", + "session.uploaded_to_shared_folder": "อัปโหลดไปยังโฟลเดอร์ที่แชร์แล้ว", + "session.uploaded_with_summary": "อัปโหลดไปยังโฟลเดอร์ที่แชร์: {summary}", + "session.uploading_to_shared_folder": "กำลังอัปโหลด {label} ไปยังโฟลเดอร์ที่แชร์...", + "session.workspace_fallback": "พื้นที่ทำงาน", + "session.workspace_label": "พื้นที่ทำงาน", + "session.workspace_path_unavailable": "เส้นทางพื้นที่ทำงานไม่พร้อมใช้งาน", + "session.workspace_setup_desc": "เริ่มด้วยพื้นที่ทำงาน OpenWork แบบมีคำแนะนำ หรือเลือกโฟลเดอร์ที่มีอยู่ที่ต้องการทำงาน", + "session.workspace_setup_label": "ตั้งค่าพื้นที่ทำงาน", + "session.workspace_setup_title": "ตั้งค่าพื้นที่ทำงานแรกของคุณ", + "settings.action_download": "ดาวน์โหลด", + "settings.action_install": "ติดตั้ง", + "settings.actor_host": "host", + "settings.actor_remote": "ระยะไกล", + "settings.actor_unknown": "ไม่ทราบ", + "settings.advanced": "ขั้นสูง", + "settings.advanced_title": "ขั้นสูง", + "settings.api_keys_info": "API key ถูกเก็บไว้ในเครื่องโดย OpenCode ผู้ให้บริการที่ใช้ environment ต้องเปลี่ยนในสภาพแวดล้อม worker แล้วรีโหลด", + "settings.appearance_hint": "ตามระบบหรือบังคับโหมดสว่าง/มืด", + "settings.appearance_title": "ธีม", + "settings.audit_error": "ข้อผิดพลาด", + "settings.audit_loading": "กำลังโหลด", + "settings.audit_log_title": "บันทึกการตรวจสอบ", + "settings.audit_ready": "พร้อม", + "settings.auto_compact": "บีบอัดบริบทอัตโนมัติ", + "settings.auto_compact_desc": "ควบคุม compaction.auto ของ OpenCode สำหรับพื้นที่ทำงานนี้ รีโหลด engine หลังเปลี่ยน", + "settings.auto_update_desc": "ดาวน์โหลดอัปเดตอัตโนมัติ (จะถามก่อนติดตั้ง)", + "settings.auto_update_title": "อัปเดตอัตโนมัติ", + "settings.available_count": "{count} พร้อมใช้งาน", + "settings.background_checks_desc": "OpenWork ตรวจสอบเสมอตอนเปิดแอป ตรวจสอบเพิ่มอีกวันละครั้ง", + "settings.background_checks_title": "ตรวจสอบในพื้นหลัง", + "settings.base_url_unavailable": "URL ฐานไม่พร้อมใช้งาน", + "settings.binary_unavailable": "Binary ไม่พร้อมใช้งาน", + "settings.cache_nothing_to_repair": "ไม่พบแคช OpenCode ไม่มีอะไรต้องซ่อมแซม", + "settings.cache_repair_requires_desktop": "การซ่อมแซมแคชต้องใช้แอปเดสก์ท็อป", + "settings.cache_repaired": "ซ่อมแซมแคช OpenCode แล้ว รีสตาร์ท engine หากกำลังทำงาน", + "settings.cap_browser_tools": "เครื่องมือเบราว์เซอร์: {value}", + "settings.cap_commands": "คำสั่ง: {value}", + "settings.cap_config": "Config: {value}", + "settings.cap_file_tools": "เครื่องมือไฟล์: {value}", + "settings.cap_inbox_off": "inbox ปิด", + "settings.cap_inbox_on": "inbox เปิด", + "settings.cap_mcp": "MCP: {value}", + "settings.cap_outbox_off": "outbox ปิด", + "settings.cap_outbox_on": "outbox เปิด", + "settings.cap_plugins": "Plugins: {value}", + "settings.cap_read": "อ่าน", + "settings.cap_sandbox": "Sandbox: {value}", + "settings.cap_skills": "Skills: {value}", + "settings.cap_write": "เขียน", + "settings.capabilities_title": "ความสามารถของ OpenWork server", + "settings.capabilities_unavailable": "ความสามารถไม่พร้อมใช้งาน เชื่อมต่อด้วย client token", + "settings.change": "เปลี่ยน", + "settings.check_update": "ตรวจสอบ", + "settings.checking_for_updates": "กำลังตรวจสอบอัปเดต", + "settings.choose": "เลือก", + "settings.clear": "ล้าง", + "settings.clipboard_unavailable": "คลิปบอร์ดไม่พร้อมใช้งานในสภาพแวดล้อมนี้", + "settings.configure": "ตั้งค่า", + "settings.connect_opencode_hint": "เชื่อมต่อ OpenCode เพื่อโหลดผู้ให้บริการ", + "settings.connect_provider": "เชื่อมต่อผู้ให้บริการ", + "settings.connected_count": "{count} เชื่อมต่อแล้ว", + "settings.connection": "การเชื่อมต่อ", + "settings.connection_failed": "เชื่อมต่อล้มเหลว", + "settings.connection_title": "การเชื่อมต่อ", + "settings.copied_debug_report": "คัดลอก JSON รายงานรันไทม์แล้ว", + "settings.copy_failed": "คัดลอกรายงานรันไทม์ไม่สำเร็จ", + "settings.copy_json": "คัดลอก JSON", + "settings.custom_binary_hint": "ใช้เพื่อชี้ OpenWork ไปที่ OpenCode build ภายในเครื่อง", + "settings.custom_binary_label": "OpenCode binary กำหนดเอง", + "settings.data_dir_unavailable": "ไดเรกทอรีข้อมูลไม่พร้อมใช้งาน", + "settings.debug_commit": "Commit: {sha}", + "settings.debug_desktop_app": "แอปเดสก์ท็อป: {version}", + "settings.debug_opencode_version": "OpenCode: {version}", + "settings.debug_openwork_server_version": "OpenWork server: {version}", + "settings.debug_section_title": "นักพัฒนา", + "settings.deeplink_failed": "เปิด deep link ไม่สำเร็จ", + "settings.deeplink_hint": "รับ openwork://, openwork-dev:// หรือ URL https://share.openworklabs.com/b/... ที่รองรับ", + "settings.default_model": "โมเดลเริ่มต้น", + "settings.delete_containers": "กำลังลบ containers...", + "settings.delete_local_config": "กำลังลบสถานะภายในเครื่อง...", + "settings.desktop_only_hint": "ใช้ได้ในแอปเดสก์ท็อป", + "settings.dev_mode_badge": "โหมดนักพัฒนา", + "settings.developer": "นักพัฒนา", + "settings.developer_mode_desc": "เปิดเครื่องมือดีบัก การวินิจฉัย และแท็บนักพัฒนา", + "settings.developer_mode_title": "โหมดนักพัฒนา", + "settings.developer_panel_disabled": "ปิดแผงนักพัฒนาแล้ว", + "settings.developer_panel_enabled": "เปิดแผงนักพัฒนาแล้ว", + "settings.devlog_cleared": "ล้าง log นักพัฒนาแล้ว", + "settings.devlog_clipboard_unavailable": "คลิปบอร์ดไม่พร้อมใช้งานในสภาพแวดล้อมนี้", + "settings.devlog_copied": "คัดลอก log นักพัฒนาแล้ว", + "settings.devlog_copy_failed": "ไม่สามารถคัดลอก log นักพัฒนาได้", + "settings.devlog_export_failed": "ไม่สามารถส่งออก log นักพัฒนาได้", + "settings.devlog_export_unavailable": "การส่งออกไม่พร้อมใช้งานในสภาพแวดล้อมนี้", + "settings.devlog_exported": "ส่งออก log นักพัฒนาแล้ว", + "settings.devtools_desc": "สถานะ sidecar, ความสามารถ และบันทึกการตรวจสอบ", + "settings.devtools_title": "เครื่องมือนักพัฒนา", + "settings.diag_approval": "การอนุมัติ: {mode} ({ms}ms)", + "settings.diag_config_path": "เส้นทาง config: {path}", + "settings.diag_daemon_url": "Daemon: {url}", + "settings.diag_default": "ค่าเริ่มต้น", + "settings.diag_health_port": "Health port: {port}", + "settings.diag_healthy_ms": "Healthy: {ms}ms", + "settings.diag_host_token_source": "แหล่ง host token: {source}", + "settings.diag_last_attempt": "ครั้งล่าสุด: {time}", + "settings.diag_load_sessions_ms": "โหลดเซสชัน: {ms}ms", + "settings.diag_opencode_binary": "OpenCode binary: {binary}", + "settings.diag_opencode_url": "OpenCode: {url}", + "settings.diag_pending_permissions_ms": "สิทธิ์ที่รอ: {ms}ms", + "settings.diag_pid": "PID: {pid}", + "settings.diag_providers_ms": "ผู้ให้บริการ: {ms}ms", + "settings.diag_read_only": "อ่านอย่างเดียว: {value}", + "settings.diag_reason": "เหตุผล: {reason}", + "settings.diag_runtime_workspace": "Runtime workspace: {id}", + "settings.diag_selected_workspace": "พื้นที่ทำงานที่เลือก: {id}", + "settings.diag_sidecar": "Sidecar: {info}", + "settings.diag_started": "เริ่ม: {time}", + "settings.diag_token_source": "แหล่ง token: {source}", + "settings.diag_total_ms": "ทั้งหมด: {ms}ms", + "settings.diag_version": "เวอร์ชัน: {version}", + "settings.diag_workspaces": "พื้นที่ทำงาน: {count}", + "settings.diagnostics_unavailable": "ข้อมูลวินิจฉัยไม่พร้อมใช้งาน", + "settings.disable_developer_mode": "ปิดโหมดนักพัฒนา", + "settings.disabled": "ปิดใช้งาน", + "settings.disconnect": "ตัดการเชื่อมต่อ", + "settings.disconnect_confirm_suffix": "ตัดการเชื่อมต่อ {resolved}? การดำเนินการนี้จะลบ API key หรือข้อมูล OAuth ที่เก็บไว้สำหรับผู้ให้บริการนี้", + "settings.disconnect_server": "ตัดการเชื่อมต่อเซิร์ฟเวอร์", + "settings.disconnected_prefix": "ตัดการเชื่อมต่อ {resolved} แล้ว", + "settings.disconnecting": "กำลังตัดการเชื่อมต่อ...", + "settings.docker_containers_desc": "บังคับลบ Docker containers ที่ OpenWork เปิดใช้", + "settings.docker_containers_title": "Docker containers ของ OpenWork", + "settings.docker_requires_desktop": "การล้าง Docker ต้องใช้แอปเดสก์ท็อป", + "settings.done": "เสร็จสิ้น", + "settings.downloading_bytes": "กำลังดาวน์โหลด {downloaded}", + "settings.downloading_progress": "กำลังดาวน์โหลด {downloaded} / {total} ({percent}%)", + "settings.enable_developer_mode": "เปิดโหมดนักพัฒนา", + "settings.enable_exa": "เปิด Exa web search", + "settings.enable_exa_desc": "ใช้งานเมื่อ OpenWork Orchestrator เปิด OpenCode", + "settings.enabled": "เปิดใช้งาน", + "settings.engine_bundled": "แบบรวม (แนะนำ)", + "settings.engine_bundled_hint": "Engine แบบรวมเป็นตัวเลือกที่เสถียรที่สุด ใช้ System", + "settings.engine_custom_binary": "Binary กำหนดเอง", + "settings.engine_desc": "เลือกวิธีรัน OpenCode ภายในเครื่อง", + "settings.engine_runtime_label": "Engine runtime", + "settings.engine_source": "แหล่งที่มาของ engine", + "settings.engine_source_debug": "แหล่ง engine", + "settings.engine_system_path": "ติดตั้งในระบบ (PATH)", + "settings.engine_title": "Engine", + "settings.environment.add_button": "Add variable", + "settings.environment.add_title": "Add environment variable", + "settings.environment.apply_button": "Apply changes", + "settings.environment.apply_blocked_active_tasks": "Stop running tasks before applying environment changes.", + "settings.environment.apply_confirm_body": "OpenWork will restart local agents so they can use the latest environment. Running local tasks may stop.", + "settings.environment.apply_no_local_workspace": "OpenWork is not connected to a local workspace.", + "settings.environment.apply_pending_body": "Apply changes to restart local agents and make the latest values available.", + "settings.environment.apply_pending_body_manual": "Restart local agents to make the latest values available.", + "settings.environment.apply_pending_title": "Changes are saved, not active yet", + "settings.environment.apply_refresh_failed": "Changes are active, but OpenWork status did not refresh. Reopen the app if it looks stale.", + "settings.environment.apply_success": "Environment changes are active.", + "settings.environment.apply_title": "Apply environment changes?", + "settings.environment.apply_unavailable": "Apply changes is only available in the desktop app.", + "settings.environment.applying": "Applying…", + "settings.environment.cancel": "Cancel", + "settings.environment.click_to_edit": "Click to edit", + "settings.environment.close_editor": "Close editor", + "settings.environment.confirm_delete": "Delete {key}? Agents stop seeing this key after you apply changes.", + "settings.environment.delete": "Delete", + "settings.environment.delete_title": "Delete environment variable", + "settings.environment.delete_variable": "Delete {key}", + "settings.environment.deleting": "Deleting…", + "settings.environment.description": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device; changes become available after you apply them.", + "settings.environment.edit_title": "Edit environment variable", + "settings.environment.empty_body": "Add keys like ANTHROPIC_API_KEY, GOOGLE_API_KEY, ELEVENLABS_API_KEY, or GITHUB_TOKEN for services your agents and MCP servers need.", + "settings.environment.empty_title": "No environment variables yet", + "settings.environment.empty_value": "(empty)", + "settings.environment.footer_hint": "OPENWORK_ and OPENCODE_ keys are reserved for app/runtime wiring. Configure OpenCode runtime settings from your shell.", + "settings.environment.hide": "Hide", + "settings.environment.hide_value": "Hide value for {key}", + "settings.environment.key_hint": "Letters, digits, and underscores. Cannot start with a digit.", + "settings.environment.key_label": "Key", + "settings.environment.loading": "Loading…", + "settings.environment.override_hint": "Environment variables set before OpenWork starts take precedence over values saved here.", + "settings.environment.remote_workspace_hint": "This workspace is remote. Local environment variables are hidden here; use cloud LLM Providers or configure the worker host directly.", + "settings.environment.restart_required": "Saved. Apply changes to make the update available.", + "settings.environment.reveal": "Reveal", + "settings.environment.reveal_value": "Reveal value for {key}", + "settings.environment.save": "Save", + "settings.environment.saving": "Saving…", + "settings.environment.title": "Environment variables", + "settings.environment.validation_duplicate": "A variable with this name already exists.", + "settings.environment.validation_empty": "Name is required.", + "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", + "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", + "settings.environment.value_label": "Value", + "settings.exa_restart_hint": "รีสตาร์ท OpenCode หรือ orchestrator หลังเปลี่ยนการตั้งค่านี้", + "settings.export": "ส่งออก", + "settings.export_failed": "ส่งออกรายงานรันไทม์ไม่สำเร็จ", + "settings.export_unavailable": "การส่งออกไม่พร้อมใช้งานในสภาพแวดล้อมนี้", + "settings.exported_debug_report": "ส่งออก JSON รายงานรันไทม์แล้ว", + "settings.failed": "ล้มเหลว", + "settings.failed_open_providers": "เปิดผู้ให้บริการไม่สำเร็จ", + "settings.feedback_badge": "เราอ่านทุกข้อความ", + "settings.feedback_desc": "บอกสิ่งที่ชอบและสิ่งที่ยังไม่ดีพอ ความคิดเห็นส่งตรงถึงทีมและช่วยให้เรารู้ว่าจะพัฒนาอะไรต่อไป", + "settings.feedback_title": "ช่วยพัฒนา OpenWork", + "settings.group_global": "ทั่วไป", + "settings.group_workspace": "พื้นที่ทำงาน", + "settings.hide_titlebar": "ซ่อนแถบชื่อหน้าต่าง", + "settings.hide_titlebar_desc": "ซ่อนแถบชื่อหน้าต่าง เหมาะสำหรับ tiling window", + "settings.join_discord": "เข้าร่วม Discord", + "settings.language": "ภาษา", + "settings.language.description": "เลือกภาษาที่คุณต้องการ", + "settings.last_error": "ข้อผิดพลาดล่าสุด", + "settings.last_stderr": "Stderr ล่าสุด", + "settings.last_stdout": "Stdout ล่าสุด", + "settings.loading_providers": "กำลังโหลดผู้ให้บริการ...", + "settings.logs_on_host": "Logs อยู่บน host", + "settings.managed_by_env": "จัดการโดยตัวแปรสภาพแวดล้อม", + "settings.messaging_bridge_service": "บริการ messaging bridge", + "settings.messaging_section_desc": "จัดการ Telegram/Slack identities และ bindings ในแท็บ Identities", + "settings.messaging_section_title": "ข้อความ", + "settings.model": "โมเดล", + "settings.model_behavior": "พฤติกรรมโมเดล", + "settings.model_behavior_desc": "เปิดตัวเลือกโมเดลเริ่มต้นเพื่อเลือก reasoning profiles เมื่อมีให้ใช้", + "settings.model_default": "ค่าเริ่มต้น", + "settings.model_description": "ค่าเริ่มต้น + การควบคุม thinking สำหรับการทำงาน", + "settings.model_description_default": "เลือกจากผู้ให้บริการที่ตั้งค่าไว้ การเลือกนี้จะถูกใช้สำหรับเซสชันใหม่", + "settings.model_description_session": "เลือกจากผู้ให้บริการที่ตั้งค่าไว้ การเลือกนี้ใช้สำหรับข้อความถัดไปของคุณ", + "settings.model_fallback": "สำรอง", + "settings.model_reasoning": "การใช้เหตุผล", + "settings.model_section_desc": "เลือกโมเดลแชทเริ่มต้นและตรวจสอบวิธีการใช้เหตุผล", + "settings.model_title": "โมเดล", + "settings.no_access": "ไม่มีสิทธิ์เข้าถึง", + "settings.no_active_workspace": "ไม่มีพื้นที่ทำงานภายในเครื่องที่ใช้งาน", + "settings.no_audit_entries": "ยังไม่มีรายการตรวจสอบ", + "settings.no_binary_selected": "ยังไม่ได้เลือก binary", + "settings.no_custom_path_set": "ยังไม่ได้ตั้งเส้นทางกำหนดเอง", + "settings.no_project_directory": "ไม่มีไดเรกทอรีโปรเจกต์", + "settings.no_stderr": "ยังไม่มี stderr", + "settings.no_stdout": "ยังไม่มี stdout", + "settings.no_worker_directory": "ไม่มีไดเรกทอรีโปรเจกต์", + "settings.no_worker_path": "ไม่มีเส้นทาง worker", + "settings.nuke_confirm_dev": "การดำเนินการนี้ไม่สามารถเลิกทำได้ จะลบข้อมูล OpenWork ทั้งหมดสำหรับ dev build นี้และ OpenCode dev config, auth, cache, data และ state ทั้งหมด แล้วปิด OpenWork ดำเนินการต่อ?", + "settings.nuke_confirm_prod": "การดำเนินการนี้ไม่สามารถเลิกทำได้ จะลบข้อมูล OpenWork ทั้งหมดสำหรับ dev build นี้และ OpenCode dev config, auth, cache, data และ state ทั้งหมด แล้วปิด OpenWork ดำเนินการต่อ?", + "settings.nuke_failed": "ลบสถานะ OpenWork และ OpenCode ไม่สำเร็จ", + "settings.nuke_hint": "ใช้เฉพาะเมื่อต้องการรีเซ็ตแอปเดสก์ท็อปและสถานะ OpenCode runtime ทั้งหมด", + "settings.nuke_success": "ลบสถานะ OpenWork และ OpenCode แล้ว OpenWork กำลังปิด...", + "settings.off": "ปิด", + "settings.offline": "ออฟไลน์", + "settings.on": "เปิด", + "settings.open_deeplink_action": "กำลังเปิด...", + "settings.open_deeplink_button": "ซ่อน", + "settings.open_deeplink_desc": "วาง deeplink หรือ URL แชร์ของ OpenWork เพื่อเปิด", + "settings.open_deeplink_title": "เปิด Deeplink", + "settings.opencode_cache": "แคช OpenCode", + "settings.opencode_cache_description": "ซ่อมแซมข้อมูลแคชที่ใช้เริ่ม engine ทำได้อย่างปลอดภัย", + "settings.opencode_engine_desc": "รันไทม์ภายในเครื่องสำหรับ agents, tools และผู้ให้บริการโมเดล", + "settings.opencode_engine_label": "OpenCode engine", + "settings.opencode_engine_sidecar_desc": "Sidecar สำหรับรันภายในเครื่อง", + "settings.opencode_sdk_desc": "ข้อมูลวินิจฉัยการเชื่อมต่อ UI", + "settings.opencode_sdk_title": "OpenCode engine", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "URL ฐานไม่พร้อมใช้งาน", + "settings.opening": "เปิด deeplink", + "settings.openwork_config_sidecar_desc": "Sidecar สำหรับ config และ approvals", + "settings.openwork_diagnostics_title": "ข้อมูลวินิจฉัย OpenWork server", + "settings.openwork_server_desc": "แผงควบคุมเซสชันสำหรับซิงค์แอป, workers และระยะไกล", + "settings.openwork_server_label": "OpenWork server", + "settings.pending_permissions": "สิทธิ์ที่รอดำเนินการ", + "settings.production_mode_badge": "โหมด Production", + "settings.provider_default_desc": "ใช้พฤติกรรม reasoning ค่าเริ่มต้นของโมเดล", + "settings.provider_default_label": "ค่าเริ่มต้นของผู้ให้บริการ", + "settings.provider_source_config": "Config", + "settings.provider_source_custom": "กำหนดเอง", + "settings.provider_source_env": "Environment", + "settings.providers_desc": "เชื่อมต่อบริการสำหรับโมเดลและเครื่องมือ", + "settings.providers_title": "ผู้ให้บริการ", + "settings.quit_hint": "OpenWork จะปิดทันทีหลังล้างข้อมูลเพื่อให้การเปิดครั้งถัดไปเริ่มจากสถานะว่างสำหรับโหมดนี้", + "settings.recent_events": "เหตุการณ์ล่าสุด", + "settings.reconnect_failed": "เชื่อมต่อใหม่ไม่สำเร็จ ตรวจสอบ URL/token ของเซิร์ฟเวอร์แล้วลองอีกครั้ง", + "settings.reconnect_server": "กำลังเชื่อมต่อใหม่...", + "settings.reconnect_server_failed": "เชื่อมต่อ OpenWork server ใหม่ไม่สำเร็จ", + "settings.reconnected": "เชื่อมต่อ OpenWork server ใหม่แล้ว", + "settings.reconnecting": "กำลังเชื่อมต่อใหม่...", + "settings.removing_containers": "กำลังลบ containers...", + "settings.removing_local_state": "กำลังลบสถานะภายในเครื่อง...", + "settings.repair_cache": "ซ่อมแซมแคช", + "settings.repairing_cache": "กำลังซ่อมแซมแคช", + "settings.report_issue": "รายงานปัญหา", + "settings.reset": "รีเซ็ต", + "settings.reset_app_data": "รีเซ็ตข้อมูลแอป", + "settings.reset_app_data_description": "รุนแรงกว่า ล้างแคชและข้อมูลแอปของ OpenWork", + "settings.reset_app_data_title": "รีเซ็ตข้อมูลแอป", + "settings.reset_app_data_warning": "ล้างแคชและข้อมูลแอป OpenWork บนอุปกรณ์นี้", + "settings.reset_button": "รีเซ็ต", + "settings.reset_cancel": "ยกเลิก", + "settings.reset_config_defaults": "กำลังรีเซ็ต...", + "settings.reset_config_failed": "รีเซ็ต app config ไม่สำเร็จ", + "settings.reset_confirm_button": "รีเซ็ตและรีสตาร์ท", + "settings.reset_confirmation_hint": "พิมพ์ {resetWord} เพื่อยืนยัน OpenWork จะรีสตาร์ท", + "settings.reset_confirmation_label": "การยืนยัน", + "settings.reset_confirmation_placeholder": "พิมพ์ RESET", + "settings.reset_onboarding": "รีเซ็ต onboarding", + "settings.reset_onboarding_description": "ล้างค่าตั้งค่า OpenWork และรีสตาร์ทแอป", + "settings.reset_onboarding_title": "รีเซ็ต onboarding", + "settings.reset_onboarding_warning": "ล้างค่าตั้งค่า OpenWork และตัวบ่งชี้ onboarding ของพื้นที่ทำงาน", + "settings.reset_openwork_desc_dev": "เมื่อเปิดโหมดนักพัฒนา จะล้างเฉพาะ OpenCode dev state ภายใน openwork-dev-data", + "settings.reset_openwork_desc_prod": "เมื่อเปิดโหมดนักพัฒนา จะล้างเฉพาะ OpenCode dev state ภายใน openwork-dev-data", + "settings.reset_openwork_title": "รีเซ็ตสถานะ OpenWork + OpenCode", + "settings.reset_recovery_desc": "ล้างข้อมูลหรือรีสตาร์ทขั้นตอนตั้งค่า", + "settings.reset_recovery_title": "รีเซ็ตและกู้คืน", + "settings.reset_requires_confirm": "ต้องพิมพ์ RESET และจะรีสตาร์ทแอป", + "settings.reset_startup": "รีเซ็ตโหมดเริ่มต้น", + "settings.reset_startup_pref": "รีเซ็ตค่าเริ่มต้นการเปิดใช้งาน", + "settings.reset_stop_active_runs": "หยุดงานที่กำลังทำงานก่อนรีเซ็ต", + "settings.resetting": "กำลังรีเซ็ต...", + "settings.restart_blocked_message": "OpenWork ต้องรีสตาร์ทเพื่อเสร็จสิ้นการอัปเดตนี้ เพื่อไม่ให้รบกวนงานปัจจุบัน การติดตั้งจะหยุดชั่วคราวจนกว่างานที่กำลังทำงานจะเสร็จหรือคุณหยุดงาน", + "settings.restart_failed": "รีสตาร์ทล้มเหลว ตรวจสอบ logs และลองอีกครั้ง", + "settings.restart_opencode": "กำลังรีสตาร์ท...", + "settings.restart_openwork_server": "กำลังรีสตาร์ท...", + "settings.restart_server_failed": "รีสตาร์ท local server ไม่สำเร็จ", + "settings.restarted": "รีสตาร์ท local server แล้ว", + "settings.restarting": "กำลังรีสตาร์ท...", + "settings.reveal_config": "เปิดไฟล์ config", + "settings.reveal_config_failed": "เปิดไฟล์ config พื้นที่ทำงานไม่สำเร็จ", + "settings.reveal_config_requires_desktop": "การเปิดไฟล์ config ต้องใช้แอปเดสก์ท็อป", + "settings.revealed_workspace_config": "เปิดไฟล์ config พื้นที่ทำงานแล้ว", + "settings.run_sandbox_probe": "กำลังรัน probe...", + "settings.running_probe": "กำลังรัน probe...", + "settings.runtime_applies_hint": "มีผลครั้งถัดไปที่ engine เริ่มหรือรีโหลด", + "settings.runtime_debug_desc": "สแนปช็อตข้อมูลวินิจฉัยที่อ่านได้พร้อมส่งออกด้วยคลิกเดียว", + "settings.runtime_debug_title": "รายงานดีบักรันไทม์", + "settings.runtime_desc": "สถานะ engine ภายในเครื่องและ OpenWork server", + "settings.runtime_direct": "Direct (OpenCode)", + "settings.runtime_title": "รันไทม์", + "settings.sandbox_error": "ข้อผิดพลาด", + "settings.sandbox_export_hint": "ใช้ส่งออกในรายงานดีบักรันไทม์ด้านบนเพื่อ", + "settings.sandbox_probe_desc": "รันการตรวจสอบ Docker sandbox แบบชั่วคราวและ", + "settings.sandbox_probe_errors": "Sandbox probe เสร็จสิ้นพร้อมข้อผิดพลาด", + "settings.sandbox_probe_failed": "Sandbox probe ล้มเหลว", + "settings.sandbox_probe_success": "Sandbox probe สำเร็จ ส่งออกรายงานดีบักสำหรับ support", + "settings.sandbox_probe_title": "Sandbox probe", + "settings.sandbox_ready": "พร้อม", + "settings.sandbox_requires_desktop": "Sandbox probe ต้องใช้แอปเดสก์ท็อป", + "settings.sandbox_result": "ผลลัพธ์: {status}", + "settings.sandbox_run_id": "Run ID: {id}", + "settings.sandbox_stop_runs_hint": "หยุดงานที่กำลังทำงานก่อน probe", + "settings.search_models": "ค้นหาโมเดล…", + "settings.select_binary": "เลือก OpenCode binary", + "settings.select_workspace_first": "เลือกพื้นที่ทำงานภายในเครื่องก่อนเปิดไฟล์ config", + "settings.send_feedback": "ส่งความคิดเห็น", + "settings.service_restarts_desc": "รีสตาร์ทบริการ host เฉพาะโดยไม่ต้องออกจาก", + "settings.service_restarts_title": "รีสตาร์ทบริการ", + "settings.session_model": "โมเดล", + "settings.show_model_reasoning": "แสดงการใช้เหตุผลของโมเดล", + "settings.show_model_reasoning_desc": "ขยาย reasoning traces ใน UI เมื่อโมเดลรองรับ", + "settings.showing_models": "แสดง {count} จาก {total}", + "settings.sidecar_config_unavailable": "Config ของ sidecar ไม่พร้อมใช้งาน", + "settings.startup": "การเริ่มต้น", + "settings.startup_local": "เริ่ม local server", + "settings.startup_not_set": "เชื่อมต่อ server", + "settings.startup_remote_warning": "ค่าเริ่มต้นการเปิดใช้งานเป็นระยะไกล การตั้งค่า engine", + "settings.startup_reset_hint": "ล้างค่าที่บันทึกไว้และแสดงหน้าจอเชื่อมต่อ", + "settings.startup_server": "เชื่อมต่อ server", + "settings.startup_title": "การเริ่มต้น", + "settings.stop_local_server": "หยุด local server", + "settings.stop_runs_before_cleanup": "หยุดงานที่กำลังทำงานก่อนล้างข้อมูล", + "settings.stop_runs_before_reset_config": "หยุดงานที่กำลังทำงานก่อนรีเซ็ต config", + "settings.stop_runs_to_reset": "หยุดงานที่กำลังทำงานเพื่อรีเซ็ต", + "settings.switch": "สลับ", + "settings.tab_advanced": "ขั้นสูง", + "settings.tab_appearance": "ธีม", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "ดีบัก", + "settings.tab_description_advanced": "ตรวจสอบสถานะรันไทม์ การเชื่อมต่อ และตัวควบคุมสำหรับนักพัฒนา", + "settings.tab_description_appearance": "ปรับรูปลักษณ์ OpenWork ทั้งเดสก์ท็อป ธีมระบบ และ app chrome", + "settings.tab_description_debug": "ตรวจสอบข้อมูลวินิจฉัยรันไทม์ logs และเครื่องมือดีบักระดับต่ำ", + "settings.tab_description_den": "จัดการการเชื่อมต่อ OpenWork Cloud, workers ที่โฮสต์ และการเข้าถึงพื้นที่ทำงาน", + "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", + "settings.tab_description_extensions": "จัดการแอป MCP และ OpenCode plugins สำหรับพื้นที่ทำงานนี้", + "settings.tab_description_general": "เชื่อมต่อผู้ให้บริการ เลือกโมเดลเริ่มต้น อนุญาตโฟลเดอร์ และควบคุมพื้นที่ทำงาน OpenWork ที่เลือกพร้อมการเชื่อมต่อรันไทม์", + "settings.tab_description_messaging": "ตั้งค่า router identities และพฤติกรรม inbox จากการตั้งค่าพื้นที่ทำงาน", + "settings.tab_description_model": "ปรับโมเดลเริ่มต้น พฤติกรรมรันไทม์ และการตั้งค่าผลลัพธ์ของ assistant", + "settings.tab_description_recovery": "ซ่อมแซมสถานะ migration รีเซ็ตค่าเริ่มต้นพื้นที่ทำงาน และกู้คืนการตั้งค่าภายในเครื่อง", + "settings.tab_description_skills": "เรียกดู แก้ไข และติดตั้ง skills โดยไม่ต้องออกจากการตั้งค่า", + "settings.tab_description_updates": "อัปเดตแอปให้เป็นปัจจุบันด้วยการตรวจสอบในพื้นหลังและตัวควบคุมการติดตั้ง", + "settings.tab_environment": "Environment", + "settings.tab_extensions": "ส่วนขยาย", + "settings.tab_general": "การตั้งค่า", + "settings.tab_messaging": "ข้อความ", + "settings.tab_model": "โมเดล", + "settings.tab_recovery": "การกู้คืน", + "settings.tab_skills": "Skills", + "settings.tab_updates": "การอัปเดต", + "settings.theme_dark": "มืด", + "settings.theme_light": "สว่าง", + "settings.theme_system": "ตามระบบ", + "settings.theme_system_hint": "โหมดตามระบบจะตามค่าตั้งค่า OS ของคุณโดยอัตโนมัติ", + "settings.toolbar_ready_to_install": "พร้อมติดตั้ง", + "settings.update": "อัปเดต", + "settings.update_available": "มีอัปเดต: v", + "settings.update_available_version": "มีอัปเดต: v{version}", + "settings.update_check_button": "ตรวจสอบ", + "settings.update_check_failed": "ตรวจสอบอัปเดตไม่สำเร็จ", + "settings.update_checking": "กำลังตรวจสอบ...", + "settings.update_download_button": "ดาวน์โหลด", + "settings.update_downloading": "กำลังดาวน์โหลด...", + "settings.update_error": "ตรวจสอบอัปเดตไม่สำเร็จ", + "settings.update_install_button": "ติดตั้งและรีสตาร์ท", + "settings.update_last_checked": "ตรวจสอบล่าสุด {time}", + "settings.update_published": "เผยแพร่ {date}", + "settings.update_ready": "พร้อมติดตั้ง: v", + "settings.update_ready_version": "พร้อมติดตั้ง: v{version}", + "settings.update_uptodate": "เป็นเวอร์ชันล่าสุดแล้ว", + "settings.updates": "การอัปเดต", + "settings.updates_desc": "อัปเดต OpenWork ให้เป็นเวอร์ชันล่าสุด", + "settings.updates_desktop_only": "การอัปเดตใช้งานได้เฉพาะในแอปเดสก์ท็อป", + "settings.updates_not_supported": "ไม่รองรับการอัปเดตในสภาพแวดล้อมนี้", + "settings.updates_title": "การอัปเดต", + "settings.version": "เวอร์ชัน", + "settings.versions_desc": "ข้อมูล build ของ Sidecar + เดสก์ท็อป", + "settings.versions_title": "เวอร์ชัน", + "settings.window_appearance_desc": "ปรับแต่งรูปลักษณ์หน้าต่าง", + "settings.worker_id_label": "Worker {id}", + "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "การตั้งค่าพื้นที่ทำงาน", + "settings.workspace_debug_events_label": "เหตุการณ์ดีบักพื้นที่ทำงาน", + "settings.workspace_fallback_name": "พื้นที่ทำงาน", + "share.active_cloud_org": "องค์กร Cloud ที่ใช้งาน", + "share.back_hint": "กลับไปตัวเลือกการแชร์", + "share.chooser_subtitle": "เลือกวิธีแชร์พื้นที่ทำงานนี้", + "share.close_hint": "ปิด", + "share.cloud_signin_note": "OpenWork Cloud จะเปิดในเบราว์เซอร์และกลับมาที่นี่หลังเข้าสู่ระบบ", + "share.collaborator_hint": "การเข้าถึงปกติโดยไม่ต้องอนุมัติสิทธิ์", + "share.connect_messaging_desc": "ใช้พื้นที่ทำงานนี้จาก Slack, Telegram และอื่น ๆ", + "share.connect_messaging_title": "เชื่อมต่อระบบข้อความ", + "share.connection_details_label": "รายละเอียดการเชื่อมต่อ", + "share.copy_hint": "คัดลอก", + "share.copy_link_hint": "คัดลอกลิงก์", + "share.create_template_link": "สร้างลิงก์เทมเพลต", + "share.credentials_disabled_hint": "เปิดการเข้าถึงระยะไกลแล้วคลิกบันทึกเพื่อรีสตาร์ท worker และแสดงรายละเอียดการเชื่อมต่อสำหรับพื้นที่ทำงานนี้", + "share.field_password": "รหัสผ่าน", + "share.field_worker_url": "URL ของ Worker", + "share.hide_password": "ซ่อนรหัสผ่าน", + "share.included_in_template": "รวมอยู่ในเทมเพลตนี้", + "share.option_access_desc": "แสดงรายละเอียดการเชื่อมต่อสำหรับเข้าถึงพื้นที่ทำงานนี้จากเครื่องอื่น", + "share.option_access_title": "เข้าถึงพื้นที่ทำงานระยะไกล", + "share.option_public_desc": "สร้างลิงก์แชร์ที่ทุกคนสามารถใช้เริ่มจากเทมเพลตนี้ได้", + "share.option_public_title": "เทมเพลตสาธารณะ", + "share.option_team_title": "แชร์กับทีม", + "share.option_template_desc": "รวมการตั้งค่านี้เพื่อให้คนอื่นเริ่มจากสภาพแวดล้อมเดียวกัน", + "share.optional_collaborator": "การเข้าถึงผู้ร่วมงาน (ไม่บังคับ)", + "share.public_intro": "แชร์พื้นที่ทำงานนี้เป็นลิงก์เทมเพลตสาธารณะ", + "share.publishing": "กำลังเผยแพร่...", + "share.regenerate_link": "สร้างลิงก์ใหม่", + "share.remote_access_desc": "ปิดเป็นค่าเริ่มต้น เปิดเฉพาะเมื่อต้องการให้ worker นี้เข้าถึงได้จากเครื่องอื่น", + "share.remote_access_disabled": "การเข้าถึงระยะไกลปิดอยู่", + "share.remote_access_enabled": "การเข้าถึงระยะไกลเปิดอยู่", + "share.remote_access_title": "การเข้าถึงระยะไกล", + "share.remote_save": "บันทึก", + "share.remote_save_busy": "กำลังบันทึก...", + "share.reveal_password": "แสดงรหัสผ่าน", + "share.save_to_team": "บันทึกไปยังทีม", + "share.saving": "กำลังบันทึก...", + "share.setup": "ตั้งค่า", + "share.sign_in_to_share": "เข้าสู่ระบบเพื่อแชร์กับทีม", + "share.subtitle_access": "แสดงรายละเอียดการเชื่อมต่อสำหรับเข้าถึงพื้นที่ทำงานนี้จากเครื่องอื่น", + "share.team_intro": "บันทึกเทมเพลตนี้ไปยังองค์กร OpenWork Cloud ที่ใช้งานเพื่อให้เพื่อนร่วมทีมเปิดได้ภายหลังจากการตั้งค่า Cloud", + "share.template_intro": "แชร์การตั้งค่าที่ใช้ซ้ำได้โดยไม่ต้องให้สิทธิ์เข้าถึงพื้นที่ทำงานที่กำลังทำงาน", + "share.template_item_config": "คำสั่งและ config", + "share.template_item_config_desc": "คำสั่งที่ใช้ซ้ำได้พร้อม config ของ OpenWork/OpenCode", + "share.template_item_settings": "การตั้งค่าพื้นที่ทำงาน", + "share.template_item_settings_desc": "โปรไฟล์พื้นที่ทำงานที่แชร์และพฤติกรรมเริ่มต้น", + "share.template_item_skills": "Skills ที่รวมไว้", + "share.template_item_skills_desc": "Skills กำหนดเองที่บันทึกในพื้นที่ทำงานนี้", + "share.template_name_label": "ชื่อเทมเพลต", + "share.title": "แชร์พื้นที่ทำงาน", + "share.view_access": "เข้าถึงพื้นที่ทำงานระยะไกล", + "share.warning_basic": "แชร์กับคนที่เชื่อถือได้เท่านั้น ข้อมูลรับรองนี้ให้สิทธิ์เข้าถึงพื้นที่ทำงานแบบสด", + "share.warning_full": "ข้อมูลรับรองนี้ให้สิทธิ์เข้าถึงพื้นที่ทำงานแบบสด การแชร์พื้นที่ทำงานระยะไกลอาจอนุญาตให้ทุกคนที่เข้าถึงเครือข่ายควบคุม worker ของคุณ", + "share.workspace_fallback": "พื้นที่ทำงาน", + "share.workspace_template_desc": "แชร์การตั้งค่าหลักและค่าเริ่มต้นของพื้นที่ทำงาน", + "share.workspace_template_title": "เทมเพลตพื้นที่ทำงาน", + "share_skill_destination.add_to_workspace": "เพิ่มไปยังพื้นที่ทำงาน", + "share_skill_destination.adding": "กำลังเพิ่ม...", + "share_skill_destination.confirm_busy": "กำลังเพิ่ม skill...", + "share_skill_destination.confirm_button": "เพิ่ม skill ไปยังพื้นที่ทำงาน", + "share_skill_destination.connect_remote": "เชื่อมต่อพื้นที่ทำงานระยะไกล", + "share_skill_destination.connect_remote_desc": "แนบ OpenWork host จากนั้นเลือกจากรายการเพื่อนำเข้า skill นี้", + "share_skill_destination.connect_remote_hint": "เชื่อมต่อ OpenWork host แล้วเลือกจากรายการเพื่อนำเข้า skill นี้", + "share_skill_destination.create_worker": "สร้างพื้นที่ทำงานใหม่", + "share_skill_destination.create_worker_desc": "เปิดขั้นตอนสร้างพื้นที่ทำงาน แล้วเพิ่ม skill หลังจากพื้นที่ทำงานใหม่พร้อมใช้งาน", + "share_skill_destination.create_worker_hint": "เปิดขั้นตอนสร้างพื้นที่ทำงาน แล้วเพิ่ม skill หลังจากพื้นที่ทำงานใหม่พร้อม", + "share_skill_destination.current_badge": "ปัจจุบัน", + "share_skill_destination.existing_workers": "พื้นที่ทำงานที่มีอยู่", + "share_skill_destination.fallback_skill_name": "Skill ที่แชร์", + "share_skill_destination.footer_idle": "เลือกพื้นที่ทำงานเพื่อดำเนินการต่อ", + "share_skill_destination.footer_selected": "พื้นที่ทำงานที่เลือก:", + "share_skill_destination.local_badge": "ภายในเครื่อง", + "share_skill_destination.more_options": "ตัวเลือกเพิ่มเติม", + "share_skill_destination.new_destination": "ปลายทางใหม่", + "share_skill_destination.no_workers": "ยังไม่มีพื้นที่ทำงานพร้อมใช้งาน สร้างใหม่หรือเชื่อมต่อพื้นที่ทำงานระยะไกลเพื่อติดตั้ง skill นี้", + "share_skill_destination.remote_badge": "ระยะไกล", + "share_skill_destination.sandbox_badge": "Sandbox", + "share_skill_destination.selected_badge": "เลือกแล้ว", + "share_skill_destination.selected_hint": "เลือกแล้ว ตรวจสอบปลายทางด้านล่าง แล้วยืนยัน", + "share_skill_destination.skill_label": "Skill ที่แชร์", + "share_skill_destination.subtitle": "เลือกพื้นที่ทำงานที่มีอยู่หรือสร้างใหม่ก่อนนำเข้า skill ที่แชร์", + "share_skill_destination.title": "Skill นี้ควรไปที่ไหน?", + "share_skill_destination.trigger_label": "ทริกเกอร์", + "sidebar.active": "ใช้งานอยู่", + "sidebar.add_workspace": "เพิ่มพื้นที่ทำงานใหม่", + "sidebar.collapse": "ยุบ", + "sidebar.connect_remote": "เชื่อมต่อระยะไกล", + "sidebar.delete_session": "ลบเซสชัน", + "sidebar.drag_reorder": "ลากเพื่อเรียงลำดับใหม่", + "sidebar.edit_connection": "แก้ไขการเชื่อมต่อ", + "sidebar.expand": "ขยาย", + "sidebar.import_config": "นำเข้า config", + "sidebar.needs_attention": "ต้องให้ความสนใจ", + "sidebar.new_worker": "Worker ใหม่", + "sidebar.no_workspaces": "ยังไม่มีพื้นที่ทำงานในเซสชันนี้ เพิ่มเพื่อเริ่มต้น", + "sidebar.progress": "ความคืบหน้า", + "sidebar.show_fewer": "แสดงน้อยลง", + "sidebar.show_more": "แสดงเพิ่ม {count} รายการ", + "sidebar.stop_sandbox": "หยุด sandbox", + "sidebar.switch": "สลับ", + "sidebar.test_connection": "ทดสอบการเชื่อมต่อ", + "skills.add_custom_repo": "เพิ่ม GitHub repo กำหนดเอง", + "skills.add_git_repo": "เพิ่ม git repo", + "skills.add_openwork_hub": "เพิ่ม OpenWork Hub", + "skills.available_from_hub": "พร้อมใช้งานจาก Hub", + "skills.catalog_search_placeholder": "ค้นหา skills ที่ติดตั้ง, ทีม และ hub", + "skills.cloud_add_skill": "เพิ่ม skill", + "skills.cloud_choose_org_detail": "ใช้แผง Cloud เพื่อเลือกองค์กรที่ใช้งาน แล้วรีเฟรชรายการนี้", + "skills.cloud_choose_org_hint": "เลือกองค์กรใน Settings → Cloud เพื่อโหลด skills ของทีม", + "skills.cloud_footer_label": "ทีม", + "skills.cloud_hub_label": "Hub: {name}", + "skills.cloud_install_need_server": "เชื่อมต่อ OpenWork server ที่มีสิทธิ์เขียน skills เพื่อติดตั้ง skills ของทีมบน worker นี้", + "skills.cloud_installed": "ติดตั้ง {name} บน worker นี้แล้ว", + "skills.cloud_installing": "กำลังติดตั้ง {title}…", + "skills.cloud_installing_short": "กำลังติดตั้ง", + "skills.cloud_no_search_matches": "ไม่พบ skills ที่ตรงกับการค้นหา", + "skills.cloud_org_empty": "ยังไม่มี skills ขององค์กร", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "โหลด skills ขององค์กรไม่สำเร็จ", + "skills.cloud_refresh": "รีเฟรช skills ของทีม", + "skills.cloud_section_subtitle": "Skills ที่แชร์กับคุณผ่าน OpenWork Cloud — รวมถึง skill hub ของทีมที่คุณเข้าถึงได้", + "skills.cloud_section_title": "จากองค์กรของคุณ", + "skills.cloud_shared_org": "องค์กร", + "skills.cloud_shared_public": "สาธารณะ", + "skills.cloud_sign_in": "เข้าสู่ระบบ Cloud", + "skills.cloud_sign_in_hint": "เข้าสู่ระบบ OpenWork Cloud เพื่อเรียกดู skills ของทีมและองค์กร", + "skills.copy_link_failed": "คัดลอกลิงก์ไม่สำเร็จ", + "skills.create_in_chat": "สร้าง skill ในแชท", + "skills.desktop_required": "การจัดการ Skills ต้องใช้แอปเดสก์ท็อป", + "skills.enter_plugin_name": "ใส่ชื่อแพ็กเกจ plugin", + "skills.failed_load_active": "โหลด plugins ที่ใช้งานไม่สำเร็จ", + "skills.failed_load_opencode": "โหลด opencode.json ไม่สำเร็จ", + "skills.failed_parse_opencode": "แปลง opencode.json ไม่สำเร็จ", + "skills.failed_to_load": "โหลด skills ไม่สำเร็จ", + "skills.failed_update_opencode": "อัปเดต opencode.json ไม่สำเร็จ", + "skills.filter_all": "ทั้งหมด", + "skills.filter_cloud": "ทีม", + "skills.filter_hub": "Hub", + "skills.filter_installed": "ติดตั้งแล้ว", + "skills.from_repo": "จาก {owner}/{repo}", + "skills.github_repo_hint": "ป้อน repo GitHub ในรูปแบบ owner/repo", + "skills.host_mode_only": "เฉพาะพื้นที่ทำงานภายในเครื่อง", + "skills.host_only_error": "การจัดการ Skills ต้องใช้พื้นที่ทำงานภายในเครื่องหรือ OpenWork server ที่เชื่อมต่อ", + "skills.hub_desc": "เรียกดู skills ที่แชร์จาก GitHub-backed hubs และเพิ่มลงใน worker นี้", + "skills.hub_label": "Hub", + "skills.import": "นำเข้า", + "skills.import_failed": "นำเข้าไม่สำเร็จ ({status})", + "skills.import_local": "นำเข้า skill ภายในเครื่อง", + "skills.import_local_hint": "คัดลอกโฟลเดอร์ skill ที่มีอยู่ไปยังพื้นที่ทำงานนี้", + "skills.import_local_skill": "นำเข้า skill ภายในเครื่อง", + "skills.imported": "นำเข้าแล้ว", + "skills.install": "ติดตั้ง", + "skills.install_failed": "ติดตั้ง skill ไม่สำเร็จ", + "skills.install_name_title": "ติดตั้ง {name}", + "skills.install_skill_creator": "ติดตั้ง skill creator", + "skills.install_skill_creator_hint": "Skill นี้ช่วยให้คุณสร้าง skills อื่นจากภายในแชท", + "skills.installed": "Skills ที่ติดตั้ง", + "skills.installed_desc": "Skills ที่ติดตั้งอยู่บน worker นี้ สามารถแก้ไขหรือแชร์ได้", + "skills.installed_label": "ติดตั้งแล้ว", + "skills.installed_status": "ติดตั้งแล้ว", + "skills.installing": "เพิ่ม skill", + "skills.installing_prefix": "กำลังติดตั้ง {name}…", + "skills.installing_skill_creator": "กำลังติดตั้ง skill creator...", + "skills.link_copied": "คัดลอกลิงก์แล้ว", + "skills.loading": "กำลังโหลด…", + "skills.no_description": "ยังไม่มีคำอธิบาย", + "skills.no_hub_repo_label": "ยังไม่ได้เลือก hub repo", + "skills.no_hub_repo_selected": "ไม่มี hub skills ที่ใช้ได้", + "skills.no_hub_skills": "ยังไม่ได้เลือก hub repo เพิ่ม GitHub repo เพื่อเรียกดู skills", + "skills.no_opencode_found": "ยังไม่พบ opencode.json เพิ่ม plugin เพื่อสร้าง", + "skills.no_opencode_workspace": "ยังไม่มี opencode.json ในพื้นที่ทำงานนี้", + "skills.no_skills": "ไม่พบ skills ใน `.opencode/skills`, `.claude/skills` หรือ `~/.agents/skills`", + "skills.no_skills_found": "ยังไม่พบ skills", + "skills.owner_label": "เจ้าของ", + "skills.owner_repo_required": "ต้องใส่เจ้าของและ repo", + "skills.pick_project_first": "เลือกโฟลเดอร์โปรเจกต์ก่อน", + "skills.pick_project_for_active": "เลือกโฟลเดอร์โปรเจกต์เพื่อโหลด plugins ที่ใช้งาน", + "skills.pick_project_for_plugins": "เลือกโฟลเดอร์โปรเจกต์เพื่อจัดการ plugins ของโปรเจกต์", + "skills.pick_workspace_first": "เลือกโฟลเดอร์พื้นที่ทำงานก่อน", + "skills.plugin_already_listed": "Plugin มีอยู่ใน opencode.json แล้ว", + "skills.plugin_management_host_only": "การจัดการ plugins ต้องใช้แอปเดสก์ท็อป", + "skills.plugins_host_only": "Plugins ใช้งานได้เฉพาะในแอปเดสก์ท็อป", + "skills.ref_label": "Ref (branch/tag/commit)", + "skills.refresh": "รีเฟรช", + "skills.refresh_hub": "รีเฟรช hub", + "skills.refresh_hub_title": "รีเฟรชแค็ตตาล็อก hub", + "skills.remove_saved_repo": "ลบ repo ที่บันทึก", + "skills.repo_label": "Repo", + "skills.reveal_failed": "เปิดโฟลเดอร์ skills ไม่สำเร็จ", + "skills.reveal_folder": "เปิดโฟลเดอร์ skills", + "skills.reveal_folder_hint": "เปิดโฟลเดอร์ skills ในตัวจัดการไฟล์", + "skills.save_and_load": "บันทึกและโหลด", + "skills.save_failed": "บันทึก skill ไม่สำเร็จ", + "skills.select_skill_folder": "เลือกโฟลเดอร์ skill", + "skills.share_back": "กลับ", + "skills.share_chooser_subtitle": "บันทึกไปยังองค์กร OpenWork Cloud หรือเผยแพร่ลิงก์ติดตั้งสาธารณะ", + "skills.share_close": "ปิด", + "skills.share_copy_link": "คัดลอก", + "skills.share_done": "เสร็จสิ้น", + "skills.share_option_public_desc": "สร้างลิงก์ที่ทุกคนสามารถใช้ติดตั้ง skill นี้ได้", + "skills.share_option_public_title": "ลิงก์สาธารณะ", + "skills.share_option_team_desc": "เพิ่ม skill นี้ไปยังองค์กร OpenWork Cloud ที่ใช้งาน", + "skills.share_option_team_title": "แชร์กับทีม", + "skills.share_public_create": "สร้างลิงก์", + "skills.share_public_creating": "กำลังเผยแพร่…", + "skills.share_public_intro": "เผยแพร่ลิงก์สาธารณะ ทุกคนที่มี URL สามารถติดตั้ง skill นี้ได้", + "skills.share_public_regenerate": "สร้างลิงก์ใหม่", + "skills.share_publisher_label": "ผู้เผยแพร่", + "skills.share_subtitle_public": "ทุกคนที่มีลิงก์สามารถติดตั้ง skill นี้ได้", + "skills.share_subtitle_team": "จัดเก็บในองค์กรสำหรับเพื่อนร่วมทีม", + "skills.share_team_choose_org": "เลือกองค์กรใน Settings → Cloud ก่อนแชร์กับทีม", + "skills.share_team_hub_label": "เพิ่มไปยัง skill hub (ไม่บังคับ)", + "skills.share_team_hub_none": "เฉพาะองค์กร — ไม่อยู่ใน hub", + "skills.share_team_hubs_loading": "กำลังโหลด hubs…", + "skills.share_team_intro": "บันทึก skill นี้ไปยังองค์กรที่ใช้งานเพื่อให้เพื่อนร่วมทีมติดตั้งจาก Cloud ได้", + "skills.share_team_org_fallback": "องค์กร Cloud ที่ใช้งาน", + "skills.share_team_save": "บันทึกไปยังทีม", + "skills.share_team_saving": "กำลังบันทึก…", + "skills.share_team_sign_in": "เข้าสู่ระบบเพื่อแชร์กับทีม", + "skills.share_team_sign_in_hint": "OpenWork Cloud จะเปิดในเบราว์เซอร์ กลับมาที่นี่หลังเข้าสู่ระบบ", + "skills.share_team_success": "บันทึกไปยัง {org} แล้ว เพื่อนร่วมทีมสามารถติดตั้งจาก skills ขององค์กร", + "skills.share_title": "แชร์ skill", + "skills.shown_count": "แสดง {count} รายการ", + "skills.skill_creator_already_installed": "ติดตั้ง skill creator แล้ว", + "skills.skill_creator_installed": "ติดตั้ง skill creator แล้ว", + "skills.skill_load_failed": "โหลด skill ไม่สำเร็จ", + "skills.source_label": "แหล่ง", + "skills.subtitle": "จัดการ Skills สำหรับพื้นที่ทำงานนี้", + "skills.title": "Skills", + "skills.trigger_label": "ทริกเกอร์: {trigger}", + "skills.uninstall": "ถอนการติดตั้ง", + "skills.uninstall_failed": "ถอนการติดตั้ง skill ไม่สำเร็จ", + "skills.uninstall_title": "ถอนการติดตั้ง skill?", + "skills.uninstall_warning": "การดำเนินการนี้จะลบ skill `{name}` ออกจากพื้นที่ทำงานอย่างถาวร", + "skills.uninstalled": "ลบ skill แล้ว", + "skills.unknown_error": "ข้อผิดพลาดที่ไม่ทราบสาเหตุ", + "skills.worker_profile_desc": "Skills คือความสามารถหลักของ worker นี้ ค้นพบจาก Hub จัดการที่ติดตั้ง และสร้างใหม่ในแชทได้โดยตรง", + "status.back": "กลับไปหน้าจอก่อนหน้า", + "status.connected": "เชื่อมต่อแล้ว", + "status.connecting": "กำลังเชื่อมต่อ", + "status.creating_task": "กำลังสร้างงานใหม่", + "status.creating_workspace": "กำลังสร้างพื้นที่ทำงาน", + "status.developer_mode": "โหมดนักพัฒนา", + "status.disconnected": "ตัดการเชื่อมต่อ", + "status.disconnected_hint": "เปิดการตั้งค่าเพื่อเชื่อมต่อใหม่", + "status.disconnected_label": "ตัดการเชื่อมต่อ", + "status.disconnecting": "กำลังตัดการเชื่อมต่อ", + "status.docs": "เอกสาร", + "status.feedback": "ข้อเสนอแนะ", + "status.idle": "ว่าง", + "status.installing_opencode": "กำลังติดตั้ง OpenCode", + "status.limited_hint": "เชื่อมต่อใหม่เพื่อคืนค่าฟีเจอร์ OpenWork ทั้งหมด", + "status.limited_mcp_hint": "เชื่อมต่อ {count} MCP · เชื่อมต่อใหม่เพื่อฟีเจอร์ทั้งหมด", + "status.limited_mode": "โหมดจำกัด", + "status.live": "ขณะนี้", + "status.loading_session": "กำลังโหลดเซสชัน", + "status.mcp_connected": "เชื่อมต่อ {count} MCP", + "status.open_docs": "เปิดเอกสาร", + "status.openwork_ready": "OpenWork พร้อม", + "status.providers_connected": "เชื่อมต่อ {count} ผู้ให้บริการ", + "status.ready_for_tasks": "พร้อมรับงานใหม่", + "status.reloading_engine": "กำลังรีโหลด engine", + "status.restarting_engine": "กำลังรีสตาร์ท engine", + "status.running": "กำลังทำงาน", + "status.send_feedback": "ส่งข้อเสนอแนะ", + "status.settings": "การตั้งค่า", + "status.starting_engine": "กำลังเริ่ม engine", + "system.cache_repair_requires_desktop": "การซ่อมแคชต้องใช้แอปเดสก์ท็อป", + "system.docker_cleanup_requires_desktop": "การล้าง Docker ต้องใช้แอปเดสก์ท็อป", + "system.reload_body_agents": "OpenCode โหลด agent ตอนเริ่มต้น โหลด engine ใหม่เพื่อให้ agent ที่อัปเดตพร้อมใช้งาน", + "system.reload_body_commands": "OpenCode โหลดคำสั่งตอนเริ่มต้น โหลด engine ใหม่เพื่อให้คำสั่งที่อัปเดตพร้อมใช้งาน", + "system.reload_body_config": "OpenCode อ่าน opencode.json ตอนเริ่มต้น โหลด engine ใหม่เพื่อใช้การเปลี่ยนแปลงการตั้งค่า", + "system.reload_body_default": "OpenWork ตรวจพบการเปลี่ยนแปลงที่ต้องโหลด OpenCode ใหม่", + "system.reload_body_mcp": "OpenCode โหลด MCP server ตอนเริ่มต้น โหลด engine ใหม่เพื่อเปิดใช้งานการเชื่อมต่อใหม่", + "system.reload_body_mixed": "OpenWork ตรวจพบการเปลี่ยนแปลงการตั้งค่า OpenCode โหลด engine ใหม่เพื่อใช้งาน", + "system.reload_body_plugins": "OpenCode โหลดปลั๊กอิน npm ตอนเริ่มต้น โหลด engine ใหม่เพื่อใช้การเปลี่ยนแปลง opencode.json", + "system.reload_body_skills": "OpenCode อาจแคชการค้นหา skill โหลด engine ใหม่เพื่อให้ skill ที่ติดตั้งใหม่พร้อมใช้งาน", + "system.reload_failed": "ไม่สามารถโหลด engine ใหม่ได้", + "system.reload_required": "ต้องโหลดใหม่", + "system.reload_unavailable": "ไม่สามารถโหลดใหม่สำหรับ worker นี้", + "system.stop_active_runs_before_reset": "หยุดการทำงานที่กำลังดำเนินอยู่ก่อนรีเซ็ต", + "system.stop_runs_before_update": "หยุดการทำงานที่กำลังดำเนินอยู่ก่อนติดตั้งอัปเดต", + "system.updates_not_supported": "ไม่รองรับการอัปเดตในสภาพแวดล้อมนี้", + "time.hours_ago": "{count} ชั่วโมงที่แล้ว", + "time.just_now": "เมื่อสักครู่", + "time.minutes_ago": "{count} นาทีที่แล้ว", + "time.seconds_ago": "{count} วินาทีที่แล้ว", + "workspace.loading_tasks": "กำลังโหลดงาน...", + "workspace.local_badge": "ภายในเครื่อง", + "workspace.new_task_inline": "+ งานใหม่", + "workspace.no_tasks": "ยังไม่มีงาน", + "workspace.remote_badge": "ระยะไกล", + "workspace.rename_description": "อัปเดตชื่อที่แสดงในแถบด้านข้าง", + "workspace.rename_label": "ชื่อพื้นที่ทำงาน", + "workspace.rename_placeholder": "พื้นที่ทำงานทีมออกแบบ", + "workspace.rename_title": "แก้ไขชื่อพื้นที่ทำงาน", + "workspace.sandbox_badge": "Sandbox", + "workspace.selected": "เลือกแล้ว", + "workspace.switch": "สลับ", + "workspace.switching_status_connecting": "กำลังตรวจสอบการเชื่อมต่อ", + "workspace.switching_status_loading": "กำลังโหลดงานล่าสุด", + "workspace.switching_status_preparing": "กำลังเตรียมข้อมูล", + "workspace.switching_subtitle": "เรากำลังโหลดงานล่าสุดของคุณกลับมา", + "workspace.switching_title": "กำลังเปิด {name}", + "workspace.switching_title_unknown": "กำลังเปิดพื้นที่ทำงาน", + "workspace_list.add_workspace": "เพิ่มพื้นที่ทำงาน", + "workspace_list.connect_remote": "เชื่อมต่อพื้นที่ทำงานระยะไกล", + "workspace_list.connecting": "กำลังเชื่อมต่อ...", + "workspace_list.delete_session": "ลบเซสชัน", + "workspace_list.desktop_only_hint": "สร้างพื้นที่ทำงานภายในเครื่องในแอปเดสก์ท็อป", + "workspace_list.edit_connection": "แก้ไขการเชื่อมต่อ", + "workspace_list.edit_name": "แก้ไขชื่อ", + "workspace_list.hide_child_sessions": "ซ่อนเซสชันย่อย", + "workspace_list.import_config": "นำเข้า config", + "workspace_list.new_workspace": "พื้นที่ทำงานใหม่", + "workspace_list.recover": "กู้คืน", + "workspace_list.remove_workspace": "ลบพื้นที่ทำงาน", + "workspace_list.rename_session": "เปลี่ยนชื่อเซสชัน", + "workspace_list.reveal_explorer": "เปิดใน Explorer", + "workspace_list.reveal_finder": "เปิดใน Finder", + "workspace_list.session_actions": "การดำเนินการเซสชัน", + "workspace_list.share": "แชร์...", + "workspace_list.show_child_sessions": "แสดงเซสชันย่อย", + "workspace_list.show_more": "แสดงเพิ่ม {count} รายการ", + "workspace_list.show_more_fallback": "แสดงเพิ่มเติม", + "workspace_list.test_connection": "ทดสอบการเชื่อมต่อ", + "workspace_list.workspace_fallback": "พื้นที่ทำงาน", + "workspace_list.workspace_options": "ตัวเลือกพื้นที่ทำงาน", + "workspace_sidebar.close_sidebar": "ปิดแถบด้านข้าง", + "workspace_sidebar.collapse_sidebar": "ยุบแถบด้านข้าง", + "workspace_sidebar.configuration": "การตั้งค่า", + "workspace_sidebar.expand_sidebar": "ขยายแถบด้านข้าง", + "workspace_sidebar.extensions": "ส่วนขยาย", + "workspace_sidebar.messaging": "ข้อความ", +} as const; diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts new file mode 100644 index 0000000000..1076b9ebc2 --- /dev/null +++ b/apps/app/src/i18n/locales/vi.ts @@ -0,0 +1,1989 @@ +/** + * Tiếng Việt translations + * Thuật ngữ chuyên môn KHÔNG dịch: Skills, Plugins, Commands, Sessions, OpenCode, OpenPackage, OpenWork, MCPs, OAuth, MCP + */ + +export default { + "app.compact_command_desc": "Tóm tắt phiên này để giảm kích thước ngữ cảnh.", + "app.connection_lost": "Mất kết nối máy chủ. Vui lòng tải lại.", + "app.deep_link_auth_queued": "Đã xếp hàng liên kết xác thực Cloud cho OpenWork.", + "app.deep_link_remote_queued": "Đã xếp hàng liên kết worker từ xa. OpenWork sẽ chuyển sang luồng kết nối.", + "app.error.choose_folder": "Chọn thư mục để tiếp tục.", + "app.error.host_requires_local": "Chọn workspace nội bộ để khởi động engine.", + "app.error.install_failed": "Cài đặt OpenCode thất bại. Xem nhật ký ở trên.", + "app.error.pick_workspace_folder": "Vui lòng chọn thư mục workspace trước.", + "app.error.remote_base_url_required": "Vui lòng nhập URL máy chủ để tiếp tục.", + "app.error.tauri_required": "Thao tác này yêu cầu môi trường ứng dụng máy tính OpenWork.", + "app.error_audit_load": "Tải nhật ký kiểm toán thất bại.", + "app.error_auth_failed": "Xác thực thất bại", + "app.error_auto_compact_scope": "Tự động thu gọn ngữ cảnh chỉ có thể thay đổi cho workspace nội bộ hoặc workspace OpenWork có quyền ghi.", + "app.error_cloud_signin": "Đăng nhập OpenWork Cloud thất bại.", + "app.error_command_not_resolved": "Command chưa được xử lý.", + "app.error_compact_empty": "Chưa có gì để thu gọn.", + "app.error_compact_no_session": "Chọn phiên có tin nhắn trước khi chạy /compact.", + "app.error_compact_no_session_id": "Chọn phiên trước khi thu gọn.", + "app.error_connect_first": "Kết nối worker này trước khi áp dụng thay đổi runtime.", + "app.error_connection_failed": "Kết nối thất bại", + "app.error_connection_failed_url": "Kết nối thất bại. Kiểm tra URL và token.", + "app.error_deep_link_unrecognized": "Liên kết này không phải deep link hoặc URL chia sẻ OpenWork hợp lệ.", + "app.error_desktop_signin": "Đăng nhập desktop hoàn tất, nhưng OpenWork Cloud không trả về token phiên.", + "app.error_not_connected": "Chưa kết nối máy chủ", + "app.error_pick_local_folder": "Chọn thư mục worker nội bộ trước khi khởi động lại máy chủ cục bộ.", + "app.error_rate_limit": "Vượt quá giới hạn tốc độ", + "app.error_remote_access": "Cập nhật truy cập từ xa thất bại.", + "app.error_request_failed": "Yêu cầu thất bại", + "app.error_reset_config": "Đặt lại cấu hình mặc định thất bại.", + "app.error_restart_local_worker": "Khởi động lại worker nội bộ với cài đặt chia sẻ mới thất bại.", + "app.error_runtime_changes": "Áp dụng thay đổi runtime thất bại.", + "app.error_session_name_required": "Tên phiên là bắt buộc", + "app.error_update_opencode_json": "Cập nhật opencode.json thất bại", + "app.import_bundle_desc": "Chọn cách nhập gói này.", + "app.import_shared_bundle": "Nhập gói được chia sẻ", + "app.local_disabled_reason": "Tạo workspace nội bộ trong ứng dụng desktop. Workspace từ xa và chia sẻ vẫn hoạt động ở đây.", + "app.local_worker_detail": "Worker nội bộ", + "app.model_behavior_desc": "Chọn model trước để xem các điều khiển hành vi theo provider.", + "app.model_behavior_title": "Hành vi model", + "app.plugins_hint_disconnected": "Máy chủ OpenWork không khả dụng. Plugins chỉ đọc.", + "app.plugins_hint_limited": "Máy chủ OpenWork cần token để chỉnh sửa plugins.", + "app.plugins_hint_readonly": "Máy chủ OpenWork chỉ đọc cho plugins.", + "app.reload_later": "Để sau", + "app.reload_now": "Tải lại ngay", + "app.reload_stop_tasks": "Tải lại & Dừng task", + "app.remote_worker_detail": "Worker từ xa", + "app.reset_config_ok": "Đã đặt lại cấu hình mặc định. Khởi động lại OpenWork nếu còn cài đặt cũ.", + "app.shared_setup": "Thiết lập chia sẻ", + "app.skill_added": "Đã thêm skill", + "app.skills_hint_disconnected": "Máy chủ OpenWork không khả dụng. Thêm URL/token máy chủ trong Nâng cao để quản lý skills.", + "app.skills_hint_limited": "Máy chủ OpenWork cần token chủ sở hữu để cài đặt/cập nhật skills. Thêm trong Nâng cao và kết nối lại.", + "app.skills_hint_readonly": "Máy chủ OpenWork chỉ đọc cho skills. Thêm token chủ sở hữu trong Nâng cao để bật cài đặt.", + "app.unknown_error": "Lỗi không xác định", + "app.worker_fallback": "Worker", + "blueprint.automation_body": "Bắt đầu từ luồng tái sử dụng hoặc nhập task bên dưới.", + "blueprint.automation_title": "Bạn muốn tự động hóa gì?", + "blueprint.csv_session_assistant": "Tôi có thể giúp bạn tạo, dọn dẹp, ghép nối và tóm tắt tệp CSV. Bạn muốn tự động hóa loại công việc CSV nào?", + "blueprint.csv_session_title": "Ý tưởng làm việc với CSV", + "blueprint.csv_session_user": "Tôi muốn ghép dữ liệu xuất từ nhiều công cụ thành một tệp CSV gọn gàng.", + "blueprint.empty_body": "Chọn điểm bắt đầu hoặc nhập bên dưới.", + "blueprint.empty_title": "Bạn muốn làm gì?", + "blueprint.minimal_body": "Đặt câu hỏi về workspace này hoặc dùng prompt khởi đầu.", + "blueprint.minimal_title": "Bắt đầu với một task", + "blueprint.starter_blueprint_desc": "Thiết kế luồng công việc tái sử dụng gồm skills, commands và bước chuyển giao.", + "blueprint.starter_blueprint_prompt": "Giúp tôi thiết kế một automation blueprint tái sử dụng cho workspace này. Hỏi tôi cần chuẩn hóa gì, rồi đề xuất luồng công việc.", + "blueprint.starter_blueprint_title": "Lập kế hoạch automation blueprint", + "blueprint.starter_chrome_desc": "Bắt đầu cuộc trò chuyện tự động hóa trình duyệt ngay.", + "blueprint.starter_chrome_prompt": "Giúp tôi kết nối Chrome và tự động hóa một tác vụ lặp lại.", + "blueprint.starter_chrome_title": "Tự động hóa Chrome", + "blueprint.starter_command_desc": "Biến luồng công việc lặp lại thành slash command cho workspace này.", + "blueprint.starter_command_prompt": "Giúp tôi tạo một /command tái sử dụng cho workspace này. Hỏi tôi muốn tự động hóa luồng nào, rồi soạn command.", + "blueprint.starter_command_title": "Tạo command tái sử dụng", + "blueprint.starter_connect_openai_desc": "Thêm provider OpenAI để các model ChatGPT sẵn sàng trong phiên mới.", + "blueprint.starter_connect_openai_title": "Kết nối ChatGPT", + "blueprint.starter_csv_desc": "Dọn dẹp hoặc tạo dữ liệu bảng tính.", + "blueprint.starter_csv_prompt": "Giúp tôi tạo hoặc chỉnh sửa tệp CSV trên máy tính này.", + "blueprint.starter_csv_title": "Làm việc với CSV", + "blueprint.starter_explore_desc": "Tóm tắt các tệp và gợi ý task đầu tiên nên làm.", + "blueprint.starter_explore_prompt": "Tóm tắt workspace này, chỉ ra các tệp quan trọng nhất và gợi ý task đầu tiên nên làm.", + "blueprint.starter_explore_title": "Khám phá workspace này", + "blueprint.welcome_message": "Xin chào, chào mừng bạn đến với OpenWork!\n\nMọi người dùng chúng tôi để tạo tệp .csv trên máy tính, kết nối Chrome và tự động hóa tác vụ lặp lại, cùng đồng bộ liên hệ với Notion.\n\nNhưng giới hạn duy nhất là trí tưởng tượng của bạn.\n\nBạn muốn làm gì?", + "blueprint.welcome_title": "Chào mừng đến với OpenWork", + "common.add": "Thêm", + "common.cancel": "Hủy", + "common.choose": "Chọn", + "common.close": "Đóng", + "common.default_parens": "(mặc định)", + "common.done": "Xong", + "common.edit": "Sửa", + "common.hide": "Ẩn", + "common.install": "Cài đặt", + "common.navigate": "điều hướng", + "common.next": "Tiếp", + "common.off": "Tắt", + "common.on": "Bật", + "common.path": "Đường dẫn", + "common.question": "Câu hỏi", + "common.refresh": "Làm mới", + "common.remove": "Xóa", + "common.reset": "Đặt lại", + "common.retry": "Thử lại", + "common.save": "Lưu", + "common.select": "chọn", + "common.show": "Hiện", + "common.something_went_wrong": "Đã xảy ra lỗi", + "common.submit": "Gửi", + "common.unknown": "Không rõ", + "composer.agent_label": "Agent", + "composer.attach_files": "Đính kèm tệp", + "composer.attachments_unavailable": "Đính kèm không khả dụng.", + "composer.behavior_label": "Hành vi", + "composer.configure": "Cấu hình", + "composer.default_agent": "Agent mặc định", + "composer.expand_pasted": "Nhấn để mở rộng văn bản đã dán", + "composer.failed_read_attachment": "Đọc tệp đính kèm thất bại", + "composer.file_exceeds_limit": "{name} vượt quá giới hạn 8MB.", + "composer.file_kind": "Tệp", + "composer.file_too_large_encoding": "{name} quá lớn sau khi mã hóa. Thử ảnh nhỏ hơn.", + "composer.image_kind": "Hình ảnh", + "composer.inserted_links_unsupported": "Đã chèn liên kết cho các tệp không được hỗ trợ.", + "composer.loading_agents": "Đang tải agents...", + "composer.loading_commands": "Đang tải commands...", + "composer.mcps_label": "MCP", + "composer.no_commands": "Không tìm thấy command.", + "composer.no_matches": "Không tìm thấy kết quả.", + "composer.placeholder": "Mô tả task của bạn...", + "composer.remote_worker_paste_warning": "Đây là worker từ xa. Sandbox cũng ở xa. Để chia sẻ tệp, tải lên Thư mục chia sẻ trong thanh bên.", + "composer.run_task": "Chạy task", + "composer.skill_source": "Skill", + "composer.stop": "Dừng", + "composer.tools_label": "Commands, skills và MCP", + "composer.unsupported_attachment_type": "Loại tệp đính kèm không được hỗ trợ.", + "composer.upload_failed_local_links": "Không thể tải lên thư mục chia sẻ. Đã chèn liên kết cục bộ thay thế.", + "composer.upload_to_shared_folder": "Tải lên thư mục chia sẻ", + "composer.uploaded_multiple_files": "Đã tải {count} tệp lên thư mục chia sẻ và chèn liên kết.", + "composer.uploaded_single_file": "Đã tải {name} lên thư mục chia sẻ và chèn liên kết.", + "config.auto_reload_desc": "Tự động tải lại sau khi agents/skills/commands/cấu hình thay đổi (chỉ khi rảnh).", + "config.auto_reload_title": "Tự động tải lại (nội bộ)", + "config.auto_reload_unavailable": "Khả dụng cho workspace nội bộ trong ứng dụng desktop.", + "config.collaborator_token_disabled_hint": "Đã lưu sẵn cho chia sẻ từ xa, nhưng truy cập từ xa hiện đang tắt.", + "config.collaborator_token_label": "Token cộng tác", + "config.collaborator_token_remote_hint": "Truy cập từ xa thông thường cho điện thoại hoặc laptop kết nối máy chủ này.", + "config.connection_failed": "Kết nối thất bại.", + "config.connection_failed_check": "Kết nối thất bại. Kiểm tra URL máy chủ và token.", + "config.connection_status_updated": "Đã cập nhật trạng thái kết nối.", + "config.connection_successful": "Kết nối thành công.", + "config.copied": "Đã sao chép", + "config.copy": "Sao chép", + "config.desktop_only_hint": "Một số tính năng cấu hình (chia sẻ máy chủ nội bộ + cầu nối nhắn tin) yêu cầu ứng dụng desktop.", + "config.diagnostics_desc": "Sao chép trạng thái runtime đã được lọc để gỡ lỗi.", + "config.diagnostics_title": "Gói chẩn đoán", + "config.enable_auto_reload_first": "Bật tự động tải lại trước", + "config.engine_reload_desc": "Khởi động lại máy chủ OpenCode cho workspace này.", + "config.engine_reload_title": "Tải lại engine", + "config.host_admin_token_hint": "Token nội bộ chỉ dành cho máy chủ, dùng cho CLI phê duyệt và API quản trị. Không sử dụng trong luồng kết nối ứng dụng từ xa.", + "config.host_admin_token_label": "Token quản trị máy chủ", + "config.host_local_only": "Chỉ nội bộ", + "config.host_offline": "Ngoại tuyến", + "config.host_remote_enabled": "Đã bật truy cập từ xa", + "config.local_ip_hint": "Dùng IP nội bộ trên cùng mạng Wi-Fi để kết nối nhanh nhất.", + "config.mdns_hint": "Tên .local dễ nhớ hơn nhưng có thể không hoạt động trên mọi mạng.", + "config.messaging_identities_desc": "Quản lý danh tính Telegram/Slack và định tuyến trong tab Danh tính.", + "config.messaging_identities_title": "Danh tính nhắn tin", + "config.not_set": "Chưa đặt", + "config.owner_token_disabled_hint": "Chỉ cần thiết sau khi bạn bật truy cập từ xa cho worker này.", + "config.owner_token_label": "Token chủ sở hữu", + "config.owner_token_remote_hint": "Dùng khi client từ xa cần trả lời yêu cầu quyền hoặc thực hiện thao tác chủ sở hữu.", + "config.reload_active_tasks_warning": "Tải lại sẽ dừng các task đang chạy.", + "config.reload_availability_hint": "Tải lại chỉ khả dụng cho worker nội bộ hoặc máy chủ OpenWork đã kết nối.", + "config.reload_connect_hint": "Kết nối worker này để tải lại.", + "config.reload_engine": "Tải lại engine", + "config.reload_now_desc": "Áp dụng cập nhật cấu hình và kết nối lại phiên của bạn.", + "config.reload_now_title": "Tải lại ngay", + "config.reloading": "Đang tải lại...", + "config.remote_access_off_hint": "Truy cập từ xa đang tắt. Dùng Chia sẻ workspace để bật trước khi kết nối từ máy khác.", + "config.resolved_worker_url": "URL worker đã xác định:", + "config.resume_sessions_desc": "Nếu tải lại được xếp hàng khi task đang chạy, gửi tin nhắn tiếp tục sau đó.", + "config.resume_sessions_title": "Tiếp tục phiên sau khi tự động tải lại", + "config.server_needed_hint": "Cần kết nối máy chủ OpenWork để đồng bộ skills, plugins và commands.", + "config.server_section_desc": "Kết nối máy chủ OpenWork. Dùng URL cùng token cộng tác hoặc chủ sở hữu từ quản trị viên.", + "config.server_section_title": "Máy chủ OpenWork", + "config.server_sharing_desc": "Chia sẻ thông tin này với thiết bị tin cậy. Giữ máy chủ trên cùng mạng để thiết lập nhanh nhất.", + "config.server_sharing_menu_hint": "Để chia sẻ theo workspace, dùng Chia sẻ... trong menu workspace.", + "config.server_sharing_title": "Chia sẻ máy chủ OpenWork", + "config.server_url_hint": "Dùng URL được cung cấp bởi máy chủ OpenWork. Worker desktop nội bộ dùng cổng cố định trong khoảng 48000-51000.", + "config.server_url_input_label": "URL máy chủ OpenWork", + "config.server_url_label": "URL máy chủ OpenWork", + "config.starting_server": "Đang khởi động máy chủ…", + "config.status_connected": "Đã kết nối", + "config.status_limited": "Giới hạn", + "config.status_not_connected": "Chưa kết nối", + "config.test_connection": "Kiểm tra kết nối", + "config.testing": "Đang kiểm tra...", + "config.testing_connection": "Đang kiểm tra kết nối...", + "config.token_hint": "Tùy chọn. Dùng token cộng tác cho truy cập thông thường hoặc token chủ sở hữu khi client này phải trả lời yêu cầu quyền.", + "config.token_label": "Token cộng tác hoặc chủ sở hữu", + "config.token_placeholder": "Dán token của bạn", + "config.unavailable": "Không khả dụng", + "config.worker_id": "ID Worker:", + "config.workspace_config_desc": "Các cài đặt này ảnh hưởng đến workspace đã chọn. Thao tác runtime áp dụng cho workspace đang kết nối.", + "config.workspace_config_title": "Cấu hình workspace", + "config.workspace_id_prefix": "Workspace:", + "context_panel.add_button": "Thêm", + "context_panel.add_folder_hint": "Thêm thư mục để workspace có thể đọc và chỉnh sửa tệp ngoài thư mục gốc.", + "context_panel.adding_button": "Đang thêm...", + "context_panel.always_available": "Luôn khả dụng", + "context_panel.authorized_folders": "Thư mục được phép", + "context_panel.authorized_folders_desc": "Cấp quyền cho workspace này đọc và chỉnh sửa tệp trong thư mục bên ngoài thư mục gốc.", + "context_panel.authorized_folders_no_access": "Kết nối workspace máy chủ OpenWork có quyền ghi để chỉnh sửa thư mục được phép.", + "context_panel.browse_button": "Duyệt", + "context_panel.config_access_unavailable": "Quyền truy cập cấu hình máy chủ OpenWork không khả dụng cho workspace này.", + "context_panel.config_read_only": "Máy chủ OpenWork kết nối chỉ đọc cho cấu hình workspace.", + "context_panel.context": "Ngữ cảnh", + "context_panel.folder_already_authorized": "Thư mục đã được cấp quyền.", + "context_panel.folders_updated": "Đã cập nhật thư mục được phép.", + "context_panel.input_placeholder": "Nhập đường dẫn thư mục để cấp quyền...", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "Đã kết nối", + "context_panel.mcp_disabled": "Đã tắt", + "context_panel.mcp_disconnected": "Đã ngắt kết nối", + "context_panel.mcp_failed": "Thất bại", + "context_panel.mcp_needs_auth": "Cần xác thực", + "context_panel.mcp_register_client": "Đăng ký client", + "context_panel.no_external_folders": "Chưa cấp quyền thư mục bên ngoài", + "context_panel.no_mcp": "Chưa tải MCP server nào.", + "context_panel.no_plugins": "Chưa tải plugin nào.", + "context_panel.no_server_workspace": "Chưa chọn workspace máy chủ đang hoạt động.", + "context_panel.no_skills": "Chưa tải skill nào.", + "context_panel.none_yet": "Chưa có.", + "context_panel.plugins": "Plugins", + "context_panel.preserving_entries": "Giữ nguyên {count} mục quyền không phải thư mục.", + "context_panel.preserving_entry": "Giữ nguyên 1 mục quyền không phải thư mục.", + "context_panel.remove_folder": "Xóa {name}", + "context_panel.saving_folders": "Đang lưu thư mục được phép...", + "context_panel.server_disconnected": "Máy chủ OpenWork đã ngắt kết nối.", + "context_panel.skills": "Skills", + "context_panel.working_files": "Tệp đang làm việc", + "context_panel.workspace_root_available": "Thư mục gốc workspace đã khả dụng.", + "context_panel.workspace_root_badge": "Thư mục gốc workspace", + "context_panel.writable_workspace_required": "Cần workspace máy chủ OpenWork có quyền ghi để cập nhật thư mục được phép.", + "dashboard.access_token": "Token truy cập", + "dashboard.access_token_optional_hint": "Chỉ thêm token nếu worker yêu cầu.", + "dashboard.blueprints_workspace": "Blueprints", + "dashboard.blueprints_workspace_desc": "Bắt đầu với workspace sẵn sàng cho automation, skills tái sử dụng và luồng công việc chung.", + "dashboard.change": "Thay đổi", + "dashboard.choose_folder": "Chọn thư mục", + "dashboard.choose_folder_continue": "Chọn thư mục để tiếp tục.", + "dashboard.choose_folder_next": "Chia sẻ tệp với workspace của bạn.", + "dashboard.choose_preset": "Chọn mẫu", + "dashboard.chooser_local_desc": "Tạo workspace trên thiết bị này và tùy chọn bắt đầu từ mẫu nhóm.", + "dashboard.chooser_remote_desc": "Kết nối worker OpenWork tự lưu trữ bằng URL và token truy cập.", + "dashboard.chooser_shared_desc": "Duyệt worker Cloud được chia sẻ với tổ chức và kết nối chỉ một bước.", + "dashboard.close_settings": "Đóng cài đặt", + "dashboard.cloud_signin_button": "Tiếp tục với Cloud", + "dashboard.cloud_signin_hint": "Truy cập worker từ xa được chia sẻ với tổ chức của bạn.", + "dashboard.cloud_signin_next": "Bạn sẽ chọn nhóm và kết nối workspace hiện có ở bước tiếp theo.", + "dashboard.cloud_signin_title": "Đăng nhập OpenWork Cloud", + "dashboard.cloud_worker": "Worker Cloud", + "dashboard.commands": "Commands", + "dashboard.connect_remote_button": "Kết nối từ xa", + "dashboard.connected": "Đã kết nối", + "dashboard.connecting": "Đang kết nối...", + "dashboard.create_local_workspace_subtitle": "Tạo workspace trên thiết bị này và tùy chọn bắt đầu từ mẫu nhóm.", + "dashboard.create_local_workspace_title": "Workspace nội bộ", + "dashboard.create_remote_custom_subtitle": "Kết nối worker OpenWork tự lưu trữ.", + "dashboard.create_remote_custom_title": "Kết nối từ xa tùy chỉnh", + "dashboard.create_remote_workspace_confirm": "Thêm Workspace", + "dashboard.create_remote_workspace_subtitle": "Lưu máy chủ OpenWork làm workspace.", + "dashboard.create_remote_workspace_title": "Thêm Workspace từ xa", + "dashboard.create_sandbox_confirm": "Tạo dạng sandbox", + "dashboard.create_shared_subtitle_signed_in": "Duyệt worker Cloud được chia sẻ với tổ chức và kết nối chỉ một bước.", + "dashboard.create_shared_subtitle_signed_out": "Đăng nhập OpenWork Cloud để truy cập worker được chia sẻ với tổ chức của bạn.", + "dashboard.create_shared_title": "Workspace chia sẻ", + "dashboard.create_workspace_confirm": "Tạo Workspace", + "dashboard.create_workspace_subtitle": "Khởi tạo workspace mới dựa trên thư mục.", + "dashboard.create_workspace_title": "Tạo Workspace", + "dashboard.creating": "Đang tạo...", + "dashboard.desktop_badge": "Desktop", + "dashboard.display_name_label": "Tên hiển thị", + "dashboard.display_name_optional": "(tùy chọn)", + "dashboard.docker_debug_details": "Chi tiết gỡ lỗi Docker", + "dashboard.edit_remote_workspace_confirm": "Lưu kết nối", + "dashboard.edit_remote_workspace_subtitle": "Cập nhật thông tin máy chủ OpenWork cho workspace này.", + "dashboard.edit_remote_workspace_title": "Chỉnh sửa kết nối từ xa", + "dashboard.empty_workspace": "Workspace trống", + "dashboard.empty_workspace_desc": "Bắt đầu từ thư mục trống, thêm những gì bạn cần.", + "dashboard.error_choose_org": "Chọn tổ chức trước khi mở workspace.", + "dashboard.error_connect_worker": "Kết nối {name} thất bại.", + "dashboard.error_create_template": "Tạo {name} thất bại.", + "dashboard.error_load_orgs": "Tải tổ chức thất bại.", + "dashboard.error_load_shared_workspaces": "Tải workspace chia sẻ thất bại.", + "dashboard.error_workspace_not_ready": "Workspace chưa sẵn sàng kết nối. Thử lại sau giây lát.", + "dashboard.import_config": "Nhập cấu hình", + "dashboard.importing": "Đang nhập…", + "dashboard.modal_back": "Quay lại", + "dashboard.modal_close": "Đóng hộp thoại thêm workspace", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "Chưa chọn thư mục.", + "dashboard.open_cloud_dashboard": "Mở bảng điều khiển Cloud", + "dashboard.opening": "Đang mở...", + "dashboard.openwork_host_hint": "Sử dụng URL được cung cấp bởi máy chủ OpenWork.", + "dashboard.openwork_host_label": "URL máy chủ OpenWork", + "dashboard.openwork_host_placeholder": "https://your-server.openwork.app", + "dashboard.openwork_host_token_hint": "Tùy chọn. Dùng token cộng tác cho truy cập thông thường hoặc token chủ sở hữu khi client này phải trả lời lời nhắc quyền.", + "dashboard.openwork_host_token_label": "Token cộng tác hoặc chủ sở hữu", + "dashboard.openwork_host_token_placeholder": "Dán token của bạn", + "dashboard.recently_updated": "Cập nhật gần đây", + "dashboard.remote": "Từ xa", + "dashboard.remote_base_url_required": "Vui lòng nhập URL máy chủ để tiếp tục.", + "dashboard.remote_connection_direct": "Trực tiếp", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "Để trống để dùng thư mục mặc định của máy chủ.", + "dashboard.remote_directory_label": "Thư mục workspace (tùy chọn)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "Tên hiển thị (tùy chọn)", + "dashboard.remote_display_name_placeholder": "Workspace nhóm thiết kế", + "dashboard.remote_server_details_hint": "Kết nối worker OpenWork tự lưu trữ.", + "dashboard.remote_server_details_title": "Chi tiết máy chủ từ xa", + "dashboard.remote_workspace_hint": "Lưu máy chủ OpenWork để kết nối lại bất kỳ lúc nào.", + "dashboard.remote_workspace_title": "Workspace từ xa", + "dashboard.repair_cache": "Sửa bộ nhớ đệm", + "dashboard.repairing_cache": "Đang sửa bộ nhớ đệm", + "dashboard.sandbox_checking_docker": "Đang kiểm tra Docker...", + "dashboard.sandbox_get_ready_action": "Chuẩn bị môi trường", + "dashboard.sandbox_get_ready_desc": "Chạy workspace này trong container Docker cách ly, an toàn và dễ tái tạo hơn.", + "dashboard.sandbox_get_ready_title": "Sandbox cần Docker", + "dashboard.sandbox_hide_logs": "Ẩn nhật ký", + "dashboard.sandbox_live_logs": "Nhật ký trực tiếp", + "dashboard.sandbox_setup": "Thiết lập sandbox", + "dashboard.sandbox_show_logs": "Hiện nhật ký", + "dashboard.search_shared_workspaces": "Tìm workspace chia sẻ", + "dashboard.select_folder": "Chọn thư mục", + "dashboard.settings": "Cài đặt", + "dashboard.shared_workspaces_loading": "Đang tải workspace chia sẻ…", + "dashboard.shared_workspaces_no_match": "Không có workspace chia sẻ phù hợp.", + "dashboard.shared_workspaces_none": "Chưa có workspace chia sẻ nào.", + "dashboard.shared_workspaces_refreshing": "Đang làm mới workspace…", + "dashboard.skills": "Skills", + "dashboard.starter_workspace": "Workspace khởi đầu", + "dashboard.starter_workspace_desc": "Đã cấu hình sẵn để hướng dẫn bạn sử dụng plugins, commands và skills.", + "dashboard.unknown_creator": "Người tạo không rõ", + "dashboard.worker_status_attention": "Cần chú ý", + "dashboard.worker_status_ready": "Sẵn sàng", + "dashboard.worker_status_starting": "Đang khởi động", + "dashboard.worker_status_stopped": "Đã dừng", + "dashboard.worker_status_unknown": "Không rõ", + "dashboard.worker_url_hint": "Dán URL worker OpenWork bạn muốn kết nối.", + "dashboard.worker_url_label": "URL Worker", + "dashboard.workspace_connect": "Kết nối", + "dashboard.workspace_connect_unavailable": "Kết nối workspace chia sẻ không khả dụng ở đây.", + "dashboard.workspace_connecting": "Đang kết nối", + "dashboard.workspace_folder_hint": "Chọn nơi workspace sẽ được lưu trên thiết bị của bạn.", + "dashboard.workspace_folder_title": "Thư mục workspace", + "dashboard.workspace_not_ready_title": "Workspace này chưa sẵn sàng kết nối.", + "dashboard.workspaces": "Workspaces", + "den.active_org_hint": "Worker Cloud và mẫu nhóm thuộc phạm vi tổ chức đã chọn.", + "den.active_org_title": "Tổ chức đang hoạt động", + "den.auto_reconnect_hint": "Hoàn tất xác thực trong trình duyệt và OpenWork sẽ tự động kết nối lại ở đây.", + "den.checking_session": "Đang kiểm tra phiên", + "den.choose_org_for_providers": "Chọn tổ chức để xem provider Cloud.", + "den.choose_org_for_skill_hubs": "Chọn tổ chức để xem skill hub Cloud.", + "den.cloud_account_hint": "Quản lý tài khoản và tổ chức đã kết nối.", + "den.cloud_account_title": "Tài khoản Cloud", + "den.cloud_control_plane_open": "Mở trong trình duyệt", + "den.cloud_control_plane_reset": "Đặt lại", + "den.cloud_control_plane_save": "Lưu URL", + "den.cloud_control_plane_url_hint": "Chỉ chế độ nhà phát triển. Dùng để trỏ đến Cloud control plane nội bộ hoặc tự lưu trữ. Thay đổi sẽ đăng xuất để ứng dụng kết nối lại với control plane mới.", + "den.cloud_control_plane_url_label": "URL Cloud control plane", + "den.cloud_provider_detail": "{count} model · provider {source}", + "den.cloud_provider_removed_detail": "Provider đã nhập không còn trên Cloud. Gỡ cài đặt cấu hình {providerId} cục bộ.", + "den.cloud_provider_sync_detail": "Provider Cloud đã thay đổi. Đồng bộ cấu hình {source} với {count} model vào opencode.jsonc.", + "den.cloud_providers_hint": "Nhập provider LLM được quản lý vào opencode.jsonc và sử dụng thông tin xác thực của tổ chức trong workspace này.", + "den.cloud_providers_title": "Provider Cloud", + "den.cloud_section_desc": "Đăng nhập, chọn tổ chức và mở worker Cloud hoặc mẫu nhóm.", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "Đăng nhập OpenWork Cloud để giữ task hoạt động khi máy ngủ.", + "den.cloud_workers_hint": "Mở worker trực tiếp vào OpenWork bằng luồng kết nối từ xa có sẵn.", + "den.cloud_workers_title": "Worker Cloud", + "den.create_account": "Tạo tài khoản", + "den.credentials_ready_badge": "Thông tin xác thực sẵn sàng", + "den.error_base_url": "Nhập URL Cloud control plane hợp lệ dạng http:// hoặc https://.", + "den.error_choose_org": "Chọn tổ chức trước khi mở worker.", + "den.error_load_orgs": "Tải tổ chức thất bại.", + "den.error_load_workers": "Tải worker thất bại.", + "den.error_no_session": "Không tìm thấy phiên Cloud đang hoạt động.", + "den.error_no_token": "Đăng nhập desktop hoàn tất, nhưng OpenWork Cloud không trả về token phiên.", + "den.error_open_worker": "Mở {name} trong OpenWork thất bại.", + "den.error_open_worker_fallback": "Mở {name} thất bại.", + "den.error_paste_valid_code": "Dán liên kết đăng nhập OpenWork hợp lệ hoặc mã đăng nhập một lần.", + "den.error_signin_failed": "Đăng nhập OpenWork Cloud thất bại.", + "den.error_worker_not_ready": "Worker chưa sẵn sàng. Thử lại sau khi cung cấp xong.", + "den.finish_signin": "Hoàn tất đăng nhập", + "den.finishing": "Đang hoàn tất...", + "den.hide_signin_code": "Ẩn mã đăng nhập", + "den.import_all": "Nhập tất cả", + "den.import_provider": "Nhập", + "den.import_provider_failed": "Không thể nhập {name}.", + "den.imported_badge": "Đã nhập", + "den.imported_provider": "Đã nhập {name}.", + "den.importing": "Đang nhập…", + "den.needs_attention": "Cần chú ý", + "den.no_cloud_providers": "Chưa có provider Cloud nào cho tổ chức này.", + "den.no_cloud_workers": "Chưa có worker Cloud cho tổ chức này. Tạo worker trong Cloud, rồi làm mới tab này.", + "den.no_org_selected": "Chưa chọn tổ chức", + "den.no_skill_hubs": "Chưa có skill hub Cloud nào cho tổ chức này.", + "den.open": "Mở", + "den.opening": "Đang mở...", + "den.org_member_suffix": "(Thành viên)", + "den.org_owner_suffix": "(Chủ sở hữu)", + "den.org_switched": "Đã chuyển sang {name}.", + "den.out_of_sync_badge": "Chưa đồng bộ", + "den.paste_signin_code": "Dán mã đăng nhập", + "den.refresh": "Làm mới", + "den.reload_workspace": "Tải lại workspace để áp dụng thay đổi cấu hình.", + "den.remove_provider_failed": "Không thể xóa {name}.", + "den.removed_from_cloud_badge": "Đã xóa khỏi Cloud", + "den.removed_provider": "Đã xóa {name}.", + "den.removing": "Đang xóa…", + "den.sign_out": "Đăng xuất", + "den.signed_out": "Đã đăng xuất", + "den.signin_button": "Đăng nhập", + "den.signin_code_note": "Chấp nhận liên kết openwork://den-auth hoặc mã một lần.", + "den.signin_link_hint": "Nếu trình duyệt không tự quay lại OpenWork, dán liên kết đăng nhập hoặc mã một lần từ OpenWork Cloud vào đây.", + "den.signin_link_label": "Liên kết đăng nhập hoặc mã một lần", + "den.signin_link_placeholder": "openwork://den-auth?... hoặc mã đã dán", + "den.signin_title": "Đăng nhập OpenWork Cloud", + "den.signing_in": "Đang hoàn tất đăng nhập OpenWork Cloud...", + "den.signing_out": "Đang đăng xuất...", + "den.skill_hub_detail": "Nhập {count} skill được chia sẻ vào .opencode/skills.", + "den.skill_hub_imported_detail": "Đã nhập {count} skill vào workspace này.", + "den.skill_hub_removed_detail": "Hub này đã bị xóa khỏi Cloud. Gỡ cài đặt {importedCount} skill đã nhập khỏi workspace này.", + "den.skill_hub_skills_badge": "{count} skills", + "den.skill_hub_sync_detail": "Cloud hiện có {liveCount} skill; workspace này đã nhập {importedCount}. Đồng bộ để cập nhật.", + "den.skill_hubs_hint": "Nhập tất cả skill từ hub Cloud được chia sẻ vào workspace này chỉ với một bước.", + "den.skill_hubs_title": "Skill hub", + "den.status_base_url_updated": "Đã cập nhật URL Cloud control plane. Đăng nhập lại để tiếp tục.", + "den.status_browser_signin": "Hoàn tất đăng nhập trong trình duyệt để kết nối OpenWork.", + "den.status_browser_signup": "Hoàn tất tạo tài khoản trong trình duyệt để kết nối OpenWork.", + "den.status_cloud_signed_in_as": "Đã kết nối OpenWork Cloud với {email}.", + "den.status_cloud_signin_done": "Đã kết nối OpenWork Cloud.", + "den.status_loaded_orgs": "Đã tải {count} tổ chức{plural}.", + "den.status_loaded_workers": "Đã tải {count} worker{plural} cho {name}.", + "den.status_no_workers": "Không tìm thấy worker cho {name}.", + "den.status_opened_worker": "Đã mở {name} trong OpenWork.", + "den.status_signed_in_as": "Đã đăng nhập với {email}.", + "den.status_signed_out": "Đã đăng xuất và xóa phiên OpenWork Cloud trên thiết bị này.", + "den.sync": "Đồng bộ", + "den.sync_provider_failed": "Không thể đồng bộ {name}.", + "den.synced_provider": "Đã đồng bộ {name}.", + "den.syncing": "Đang đồng bộ…", + "den.uninstall": "Gỡ cài đặt", + "den.worker_mine_badge": "Của tôi", + "den.worker_not_ready_title": "Worker này chưa sẵn sàng.", + "den.worker_provider_label": "Worker {provider}", + "den.worker_secondary_cloud": "Worker Cloud", + "extensions.app_count_one": "{count} ứng dụng đã kết nối", + "extensions.app_count_many": "{count} ứng dụng đã kết nối", + "extensions.apps_mcp_header": "Ứng dụng (MCP)", + "extensions.filter_all": "Tất cả", + "extensions.filter_apps": "Ứng dụng", + "extensions.filter_plugins": "Plugins", + "extensions.plugin_count_one": "{count} plugin", + "extensions.plugin_count_many": "{count} plugins", + "extensions.plugins_opencode_header": "Plugins (OpenCode)", + "extensions.subtitle": "Ứng dụng (MCP) và OpenCode plugins gộp chung một nơi.", + "extensions.title": "Tiện ích mở rộng", + "identities.agent_behavior_desc": "Một tệp mỗi workspace. Thêm dòng đầu tùy chọn @agent để định tuyến qua agent OpenCode cụ thể.", + "identities.agent_behavior_title": "Hành vi agent nhắn tin", + "identities.agent_created": "Đã tạo tệp agent nhắn tin mặc định.", + "identities.agent_file_changed": "Tệp đã thay đổi từ xa. Tải lại và lưu lần nữa.", + "identities.agent_loading": "Đang tải tệp agent…", + "identities.agent_none": "không có", + "identities.agent_not_found": "Chưa tìm thấy tệp agent trong workspace này.", + "identities.agent_saved": "Đã lưu hành vi nhắn tin.", + "identities.agent_scope_status": "Phạm vi hoạt động: workspace · trạng thái: {status} · agent đã chọn: {agent}", + "identities.agent_status_loaded": "đã tải", + "identities.agent_status_missing": "thiếu", + "identities.agent_worker_scope_unavailable": "Phạm vi worker không khả dụng.", + "identities.all_channels": "Tất cả kênh", + "identities.app_token_label": "Token ứng dụng", + "identities.auto_bind_label": "Tự động liên kết peer với thư mục khi gửi trực tiếp", + "identities.available_channels": "Kênh khả dụng", + "identities.bot_token_label": "Token bot", + "identities.bot_token_placeholder": "Dán token bot Telegram từ @BotFather", + "identities.botfather_step1_open": "1. Mở @BotFather trong Telegram", + "identities.botfather_step1_run": "và chạy /newbot", + "identities.botfather_step3_choose": "3. Chọn tên và username cho bot", + "identities.botfather_step3_or_private": "cho hộp thư mở hoặc", + "identities.botfather_step3_private": "Riêng tư", + "identities.botfather_step3_public": "Công khai", + "identities.botfather_step3_to_require": "để yêu cầu", + "identities.channel_label": "Kênh", + "identities.channels_connected": "đã kết nối", + "identities.channels_label": "Kênh", + "identities.configured_suffix": "đã cấu hình", + "identities.connect_server_desc": "Danh tính khả dụng khi bạn đã kết nối máy chủ OpenWork.", + "identities.connect_server_title": "Kết nối máy chủ OpenWork", + "identities.connect_slack": "Kết nối Slack", + "identities.connected_badge": "Đã kết nối", + "identities.connecting": "Đang kết nối...", + "identities.copy_bot_token_hint": "Sao chép token bot và dán vào bên dưới.", + "identities.copy_code": "Sao chép mã", + "identities.create_default_file": "Tạo tệp mặc định", + "identities.create_private_bot": "Tạo bot riêng tư", + "identities.create_public_bot": "Tạo bot công khai", + "identities.days_ago": "{days} ngày trước", + "identities.default_routing": "Định tuyến mặc định", + "identities.directory_label": "Thư mục (tùy chọn)", + "identities.disable_messaging": "Tắt nhắn tin", + "identities.disable_messaging_message": "Thao tác này sẽ tắt nhắn tin cho workspace này. Thiết lập Telegram và Slack sẽ bị ẩn cho đến khi bật lại, và bạn cần khởi động lại worker để dừng hoàn toàn sidecar nhắn tin.", + "identities.disable_messaging_title": "Tắt nhắn tin cho worker này?", + "identities.disabled_label": "Đã tắt", + "identities.disabling": "Đang tắt...", + "identities.disconnect": "Ngắt kết nối", + "identities.dispatched_messages": "Đã gửi {sent}/{attempted} tin nhắn.", + "identities.enable_messaging": "Bật nhắn tin", + "identities.enable_messaging_risk": "Nhắn tin có thể phơi bày worker này cho lệnh từ xa. Nếu bot công khai hoặc bị xâm phạm, nó có thể truy cập tệp, thông tin đăng nhập và API key của worker.", + "identities.enable_messaging_title": "Bật nhắn tin cho worker này?", + "identities.enabled_label": "Đã bật", + "identities.enabling": "Đang bật...", + "identities.health_offline": "Ngoại tuyến", + "identities.health_running": "Đang chạy", + "identities.health_unavailable": "Không khả dụng", + "identities.health_unknown": "Không rõ", + "identities.hours_ago": "{hours} giờ trước", + "identities.identities_label": "Danh tính", + "identities.just_now": "Vừa xong", + "identities.last_activity": "Hoạt động cuối", + "identities.later": "Để sau", + "identities.message_label": "Tin nhắn", + "identities.message_routing_desc": "Kiểm soát cuộc hội thoại nào đến thư mục workspace nào. Tin nhắn được định tuyến đến thư mục mặc định của worker trừ khi bạn thiết lập quy tắc ở đây.", + "identities.message_routing_title": "Định tuyến tin nhắn", + "identities.messages_today": "Tin nhắn hôm nay", + "identities.messaging_disabled_hint": "Chỉ bật nhắn tin nếu bạn hiểu rủi ro và dự định bảo mật truy cập (ví dụ: ghép nối Telegram riêng tư).", + "identities.messaging_disabled_restart": "Nhắn tin đã tắt. Khởi động lại worker để dừng sidecar nhắn tin.", + "identities.messaging_disabled_risk": "Bot nhắn tin có thể thực hiện hành động trên worker nội bộ. Nếu bị phơi bày công khai, chúng có thể truy cập tệp, thông tin đăng nhập và API key của worker.", + "identities.messaging_disabled_title": "Nhắn tin tắt theo mặc định", + "identities.messaging_enabled_restart": "Nhắn tin đã bật. Khởi động lại worker để áp dụng trước khi cấu hình kênh.", + "identities.messaging_sidecar_not_running": "Nhắn tin đã bật trong workspace này, nhưng sidecar nhắn tin chưa chạy. Khởi động lại worker, rồi quay lại cài đặt Nhắn tin để kết nối Telegram hoặc Slack.", + "identities.minutes_ago": "{minutes} phút trước", + "identities.not_set": "Chưa đặt", + "identities.open_bot_link": "Mở @{username} trong Telegram", + "identities.pairing_code_copied": "Đã sao chép mã ghép nối.", + "identities.pairing_code_copy_failed": "Không thể sao chép mã ghép nối. Vui lòng sao chép thủ công.", + "identities.pairing_code_instruction_prefix": "Gửi", + "identities.peer_id_label": "Peer ID (tùy chọn)", + "identities.peer_id_placeholder_slack": "ví dụ: slack:U12345678", + "identities.peer_id_placeholder_telegram": "ví dụ: telegram:123456789", + "identities.private_label": "Riêng tư", + "identities.private_pairing_code": "Mã ghép nối riêng tư", + "identities.public_bot_confirm": "Tôi hiểu rủi ro", + "identities.public_bot_warning_message": "Bot của bạn sẽ được truy cập công khai và bất kỳ ai có quyền truy cập bot đều có thể toàn quyền truy cập worker nội bộ bao gồm tệp và API key. Nếu tạo bot riêng tư, bạn có thể giới hạn quyền truy cập bằng mã ghép nối. Bạn có chắc muốn đặt bot công khai không?", + "identities.public_bot_warning_title": "Đặt bot này công khai?", + "identities.public_label": "Công khai", + "identities.quick_setup": "Thiết lập nhanh", + "identities.reconnect_failed": "Kết nối lại thất bại. Kiểm tra URL/token OpenWork và thử lại.", + "identities.reconnected": "Đã kết nối lại.", + "identities.reconnected_refreshing": "Đã kết nối lại. Đang làm mới trạng thái worker...", + "identities.reload": "Tải lại", + "identities.repair_reconnect": "Sửa lỗi & kết nối lại", + "identities.restart_failed": "Khởi động lại thất bại. Vui lòng khởi động lại worker từ Cài đặt và thử lại.", + "identities.restart_to_disable_messaging": "Nhắn tin đã tắt cho workspace này. Khởi động lại worker ngay để dừng sidecar nhắn tin.", + "identities.restart_to_enable_messaging": "Nhắn tin đã bật cho workspace này. Khởi động lại worker ngay để khởi chạy sidecar nhắn tin và mở khóa thiết lập Telegram và Slack.", + "identities.restart_worker": "Khởi động lại worker", + "identities.restart_worker_title": "Khởi động lại worker ngay?", + "identities.restarting": "Đang khởi động lại...", + "identities.routing_override_prefix": "Tất cả tin nhắn định tuyến đến", + "identities.routing_override_suffix": "(đang ghi đè)", + "identities.running_label": "Đang chạy", + "identities.save_behavior": "Lưu hành vi", + "identities.saving": "Đang lưu...", + "identities.send_test_button": "Gửi tin nhắn thử", + "identities.send_test_desc": "Kiểm tra đường truyền đi. Dùng peer ID để gửi trực tiếp, hoặc để trống peer ID để phân phối theo liên kết trong thư mục.", + "identities.send_test_title": "Gửi tin nhắn thử", + "identities.sending": "Đang gửi...", + "identities.slack_desc": "Worker của bạn xuất hiện như bot trong các kênh Slack. Thành viên nhóm có thể nhắn trực tiếp hoặc đề cập trong luồng.", + "identities.slack_intro": "Kết nối workspace Slack để thành viên nhóm tương tác với worker này trong kênh và tin nhắn trực tiếp.", + "identities.slack_unavailable": "Danh tính Slack không khả dụng.", + "identities.status_active": "Đang hoạt động", + "identities.status_label": "Trạng thái", + "identities.status_stopped": "Đã dừng", + "identities.stopped_label": "Đã dừng", + "identities.subtitle": "Để mọi người liên lạc worker qua ứng dụng nhắn tin. Kết nối kênh và worker sẽ tự động đọc và phản hồi tin nhắn.", + "identities.tab_general": "Chung", + "identities.telegram_bot_access_desc": "Bot công khai: chat Telegram đầu tiên tự động liên kết. Bot riêng tư: yêu cầu mã ghép nối trước khi tin nhắn chạy công cụ.", + "identities.telegram_delete_failed": "Xóa thất bại.", + "identities.telegram_deleted": "Đã xóa.", + "identities.telegram_deleted_pending": "Đã xóa (chờ áp dụng).", + "identities.telegram_desc": "Kết nối bot Telegram ở chế độ công khai (hộp thư mở) hoặc riêng tư (cần mã ghép nối).", + "identities.telegram_private_saved_pair": "Đã lưu bot riêng tư. Ghép nối qua /pair {code}", + "identities.telegram_save_failed": "Lưu thất bại.", + "identities.telegram_saved": "Đã lưu.", + "identities.telegram_saved_pending": "Đã lưu (chờ áp dụng).", + "identities.telegram_saved_username": "Đã lưu (@{username})", + "identities.telegram_unavailable": "Danh tính Telegram không khả dụng.", + "identities.title": "Kênh nhắn tin", + "identities.unsaved_changes": "Thay đổi chưa lưu", + "identities.worker_offline": "Worker ngoại tuyến", + "identities.worker_online": "Worker trực tuyến", + "identities.worker_restarted": "Đã khởi động lại worker.", + "identities.worker_restarted_refreshing": "Đã khởi động lại worker. Đang làm mới trạng thái nhắn tin...", + "identities.worker_scope_unavailable": "Phạm vi worker không khả dụng.", + "identities.worker_scope_unavailable_detail": "Phạm vi worker không khả dụng. Kết nối lại bằng URL worker hoặc chuyển sang worker đã biết.", + "identities.worker_unavailable": "Worker không khả dụng", + "identities.workspace_id_required": "Cần ID workspace để quản lý danh tính. Kết nối lại bằng URL workspace hoặc chọn workspace đã ánh xạ trên máy chủ này.", + "identities.workspace_scope_prefix": "Phạm vi workspace:", + "inbox_panel.connect_to_download": "Kết nối worker để tải về tệp chia sẻ.", + "inbox_panel.connect_to_see": "Kết nối để xem tệp chia sẻ.", + "inbox_panel.connect_to_upload": "Kết nối worker để tải lên", + "inbox_panel.copy_failed": "Sao chép thất bại. Trình duyệt có thể chặn truy cập clipboard.", + "inbox_panel.download": "Tải về", + "inbox_panel.drop_to_upload": "Thả tệp vào đây để tải lên", + "inbox_panel.helper_text": "Chia sẻ tệp với worker này từ ứng dụng.", + "inbox_panel.load_failed": "Tải thư mục chia sẻ thất bại", + "inbox_panel.missing_file_id": "Thiếu id tệp chia sẻ.", + "inbox_panel.no_files": "Chưa có tệp chia sẻ.", + "inbox_panel.refresh_tooltip": "Làm mới thư mục chia sẻ", + "inbox_panel.shared_folder": "Thư mục chia sẻ", + "inbox_panel.showing_first": "Hiển thị {count} đầu tiên.", + "inbox_panel.upload_failed": "Tải lên thư mục chia sẻ thất bại", + "inbox_panel.upload_needs_worker": "Kết nối worker để tải tệp lên thư mục chia sẻ.", + "inbox_panel.upload_prompt": "Thả tệp hoặc nhấn để tải lên", + "inbox_panel.upload_success": "Đã tải lên thư mục chia sẻ.", + "inbox_panel.uploading": "Đang tải lên...", + "inbox_panel.uploading_label": "Đang tải lên {label}...", + "mcp.activate_button": "Kích hoạt", + "mcp.add_modal_subtitle": "Kết nối MCP server tùy chỉnh bằng URL hoặc lệnh nội bộ.", + "mcp.add_modal_title": "Thêm ứng dụng tùy chỉnh", + "mcp.add_server_button": "Thêm ứng dụng", + "mcp.advanced": "Nâng cao", + "mcp.advanced_settings": "Cài đặt nâng cao", + "mcp.advanced_settings_hint": "Chỉnh sửa tệp cấu hình và quản lý kết nối thủ công.", + "mcp.app_connected": "ứng dụng đã kết nối", + "mcp.apps_connected": "ứng dụng đã kết nối", + "mcp.apps_subtitle": "Kết nối các công cụ yêu thích để OpenWork có thể sử dụng thay bạn.", + "mcp.apps_title": "Ứng dụng", + "mcp.auth.already_connected": "Đã kết nối", + "mcp.auth.already_connected_description": "{server} đã được xác thực và sẵn sàng sử dụng.", + "mcp.auth.applying_changes_body": "Chúng tôi đang khởi động lại worker để MCP mới sẵn sàng xác thực.", + "mcp.auth.applying_changes_title": "Đang áp dụng thay đổi trước khi đăng nhập", + "mcp.auth.authorization_link": "Liên kết cấp quyền", + "mcp.auth.authorization_still_required": "Vẫn cần xác thực. Thử lại để khởi động lại luồng.", + "mcp.auth.callback_invalid": "Dán URL callback hoặc tham số code để hoàn tất OAuth.", + "mcp.auth.callback_label": "URL callback hoặc mã code", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "Hủy", + "mcp.auth.client_registration_required": "Cần đăng ký client trước khi tiếp tục OAuth.", + "mcp.auth.complete_connection": "Hoàn tất kết nối", + "mcp.auth.configured_previously": "MCP có thể đã được cấu hình toàn cục hoặc trong phiên trước. Bạn có thể đóng hộp thoại này và bắt đầu sử dụng ngay.", + "mcp.auth.connect_server": "Kết nối {server}", + "mcp.auth.copied": "Đã sao chép", + "mcp.auth.copy_link": "Sao chép liên kết", + "mcp.auth.done": "Xong", + "mcp.auth.failed_to_start_oauth": "Không thể khởi động luồng OAuth", + "mcp.auth.follow_browser_steps": "Làm theo các bước xác thực trong trình duyệt.", + "mcp.auth.force_stop": "Buộc dừng", + "mcp.auth.force_stopping": "Đang dừng...", + "mcp.auth.im_done": "Tôi đã xong", + "mcp.auth.invalid_refresh_token": "Refresh token OAuth không hợp lệ hoặc đã hết hạn. Vui lòng xác thực lại.", + "mcp.auth.manual_finish_hint": "Dán URL callback (localhost:19876) hoặc chỉ mã code để hoàn tất kết nối.", + "mcp.auth.manual_finish_title": "Server từ xa?", + "mcp.auth.oauth_completed_reload": "OAuth hoàn tất. Tải lại engine để kích hoạt MCP.", + "mcp.auth.oauth_failed": "Xác thực OAuth thất bại.", + "mcp.auth.oauth_not_supported_hint": "Điều này có thể do:\n• MCP server không hỗ trợ OAuth\n• Engine cần tải lại để phát hiện tính năng server\n• Thử: opencode mcp auth {server} từ CLI", + "mcp.auth.open_browser_signin": "Chúng tôi sẽ mở trình duyệt để hoàn tất đăng nhập.", + "mcp.auth.port_forward_hint": "Mẹo: chuyển tiếp cổng callback nếu cần: ssh -L 19876:127.0.0.1:19876 user@host", + "mcp.auth.reauth_action": "Xác thực lại OAuth", + "mcp.auth.reauth_cli_hint": "Chạy: opencode mcp auth {server}", + "mcp.auth.reauth_failed": "Xác thực lại thất bại.", + "mcp.auth.reauth_remote_hint": "Xác thực lại từ máy đang chạy worker này.", + "mcp.auth.reauth_running": "Đang xác thực lại...", + "mcp.auth.reload_blocked": "Đang tạm dừng tải lại vì có phiên đang chạy. Dừng phiên để hoàn tất thiết lập.", + "mcp.auth.reload_engine_retry": "Áp dụng thay đổi và thử lại", + "mcp.auth.reload_failed": "Tải lại worker trước khi đăng nhập thất bại.", + "mcp.auth.reload_notice": "Để thay đổi có hiệu lực, OpenWork cần làm mới dịch vụ worker. Điều này có thể gián đoạn phiên đang chạy.", + "mcp.auth.reload_remote_confirm": "Để thay đổi có hiệu lực, OpenWork cần làm mới dịch vụ worker. Điều này có thể dừng phiên đang chạy. Tiếp tục?", + "mcp.auth.reopen_browser_link": "Nhấn vào đây để mở lại trình duyệt", + "mcp.auth.request_timed_out": "Yêu cầu hết thời gian.", + "mcp.auth.retry": "Thử lại", + "mcp.auth.retry_now": "Thử lại ngay", + "mcp.auth.server_disabled": "MCP server này đã bị tắt. Bật lại và thử lần nữa.", + "mcp.auth.step1_description": "Chúng tôi sẽ tự động mở trang đăng nhập của {server}.", + "mcp.auth.step1_title": "Đang mở trình duyệt", + "mcp.auth.step2_description": "Đăng nhập và chấp thuận quyền truy cập khi được yêu cầu.", + "mcp.auth.step2_title": "Cấp quyền cho OpenWork", + "mcp.auth.step3_description": "Chúng tôi sẽ hoàn tất kết nối ngay khi xác thực xong.", + "mcp.auth.step3_title": "Quay lại đây khi hoàn tất", + "mcp.auth.try_reload_engine": "{message}. Thử tải lại engine trước.", + "mcp.auth.waiting_authorization": "Đang đợi bạn hoàn tất xác thực trong trình duyệt...", + "mcp.auth.waiting_for_conversation_body": "Chúng tôi sẽ chuyển hướng bạn để xác thực ngay khi có thể.", + "mcp.auth.waiting_for_conversation_title": "Đang đợi cuộc hội thoại hoàn tất", + "mcp.auth.waiting_for_session": "Đang đợi {session} hoàn tất", + "mcp.available_apps": "Ứng dụng khả dụng", + "mcp.cap_signin": "Đăng nhập tài khoản", + "mcp.cap_tools": "Công cụ AI", + "mcp.config_file": "Tệp cấu hình", + "mcp.config_load_failed": "Không thể tải tệp cấu hình", + "mcp.config_not_loaded": "Chưa tải", + "mcp.config_source": "Từ cấu hình", + "mcp.configured": "đã cấu hình", + "mcp.connect": "Kết nối", + "mcp.connect_failed": "Không thể kết nối. Vui lòng thử lại.", + "mcp.connect_server_first": "Vui lòng kết nối máy chủ trước.", + "mcp.connected": "Đã kết nối", + "mcp.connected_badge": "Đã kết nối", + "mcp.connecting": "Đang kết nối...", + "mcp.connection_failed": "Lỗi kết nối — thử lại", + "mcp.connection_type": "Kết nối", + "mcp.control_chrome_browser_hint": "Trong Chrome 144 trở lên, làm điều này trước:", + "mcp.control_chrome_browser_step_one": "Mở chrome://inspect/#remote-debugging.", + "mcp.control_chrome_browser_step_two": "Bật gỡ lỗi từ xa.", + "mcp.control_chrome_browser_step_three": "Cho phép kết nối gỡ lỗi khi Chrome yêu cầu.", + "mcp.control_chrome_browser_title": "1. Bật quyền truy cập Chrome", + "mcp.control_chrome_connect": "Thêm Control Chrome", + "mcp.control_chrome_docs": "Hướng dẫn MCP chính thức", + "mcp.control_chrome_edit": "Chỉnh sửa cài đặt", + "mcp.control_chrome_profile_hint": "Control Chrome thường mở profile Chrome riêng. Bật tùy chọn này nếu bạn muốn OpenWork dùng lại cửa sổ Chrome đang mở.", + "mcp.control_chrome_profile_title": "2. Chọn Chrome nào để dùng", + "mcp.control_chrome_save": "Lưu cài đặt", + "mcp.control_chrome_setup_subtitle": "Bật quyền truy cập Chrome, rồi chọn OpenWork nên dùng profile sạch hay gắn vào Chrome đang dùng.", + "mcp.control_chrome_setup_title": "Thiết lập Control Chrome", + "mcp.control_chrome_toggle_hint": "Khi bật, OpenWork thêm --autoConnect để MCP gắn vào phiên Chrome đã khởi động.", + "mcp.control_chrome_toggle_label": "Dùng profile Chrome hiện tại", + "mcp.control_chrome_toggle_off": "OpenWork sẽ mở profile Chrome riêng cho tự động hóa.", + "mcp.control_chrome_toggle_on": "OpenWork sẽ dùng lại tab, cookie và đăng nhập hiện tại của bạn.", + "mcp.custom_app_cta_hint": "Kết nối MCP server, công cụ nội bộ hoặc ứng dụng được lưu trữ của riêng bạn.", + "mcp.desktop_required": "Ứng dụng yêu cầu app desktop.", + "mcp.docs_link": "Tìm hiểu thêm", + "mcp.file_not_found": "Chưa tạo tệp cấu hình", + "mcp.finish_setup": "Sắp xong rồi", + "mcp.finish_setup_hint": "Nhấn Kích hoạt để hoàn tất kết nối ứng dụng.", + "mcp.friendly_status_issue": "Sự cố", + "mcp.friendly_status_needs_signin": "Cần đăng nhập", + "mcp.friendly_status_offline": "Ngoại tuyến", + "mcp.friendly_status_paused": "Tạm dừng", + "mcp.friendly_status_ready": "Sẵn sàng", + "mcp.last_synced": "Đã đồng bộ", + "mcp.login_action": "Đăng nhập", + "mcp.login_hint": "Kết nối tài khoản để hoàn tất thiết lập ứng dụng.", + "mcp.login_unavailable": "Ứng dụng này không hỗ trợ đăng nhập từ OpenWork.", + "mcp.logout_action": "Đăng xuất", + "mcp.logout_failed": "Đăng xuất thất bại.", + "mcp.logout_hint": "Xóa thông tin đăng nhập OAuth đã lưu. Bạn sẽ cần đăng nhập lại.", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "Thao tác này sẽ xóa thông tin OAuth đã lưu của {server}. Bạn sẽ cần đăng nhập lại để sử dụng.", + "mcp.logout_modal_title": "Đăng xuất khỏi ứng dụng?", + "mcp.logout_success": "Đã đăng xuất khỏi {server}.", + "mcp.logout_working": "Đang đăng xuất...", + "mcp.name_required": "Vui lòng nhập tên server.", + "mcp.no_apps_hint": "Kết nối một ứng dụng ở trên để bắt đầu.", + "mcp.no_apps_yet": "Chưa kết nối ứng dụng nào", + "mcp.oauth": "Đăng nhập", + "mcp.oauth_optional_hint": "Dùng OAuth trong trình duyệt để kết nối tài khoản của bạn.", + "mcp.oauth_optional_label": "Ứng dụng này yêu cầu đăng nhập", + "mcp.one_click_connect": "Kết nối một chạm", + "mcp.open_file": "Mở tệp", + "mcp.opening_label": "Đang mở...", + "mcp.pick_workspace_error": "Vui lòng chọn thư mục workspace trước.", + "mcp.pick_workspace_first": "Vui lòng chọn thư mục workspace trước.", + "mcp.quick_connect_chrome_desc": "Điều khiển tab Chrome với browser automation.", + "mcp.quick_connect_chrome_title": "Điều khiển Chrome", + "mcp.quick_connect_context7_desc": "Tìm kiếm tài liệu sản phẩm với ngữ cảnh phong phú hơn.", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "Lập kế hoạch sprint và xử lý ticket nhanh hơn.", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "Đồng bộ trang, cơ sở dữ liệu và tài liệu dự án.", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "Theo dõi bản phát hành và xử lý lỗi production.", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "Kiểm tra thanh toán, hóa đơn và đăng ký.", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "Dừng task đang chạy để kích hoạt.", + "mcp.reload_banner_description": "Nhấn Kích hoạt để hoàn tất kết nối ứng dụng.", + "mcp.reload_banner_description_blocked": "Một task đang chạy. Dừng trước rồi kích hoạt.", + "mcp.remote_workspace_url_hint": "Workspace từ xa kết nối nhanh nhất với MCP server dạng URL.", + "mcp.remove_app": "Xóa", + "mcp.remove_failed": "Không thể xóa ứng dụng.", + "mcp.remove_modal_message": "Bạn có chắc muốn xóa {server}? Bạn luôn có thể thêm lại sau.", + "mcp.remove_modal_title": "Xóa ứng dụng", + "mcp.reveal_config_failed": "Không thể mở tệp cấu hình", + "mcp.reveal_in_finder": "Hiện trong Finder", + "mcp.scope_global": "Tất cả workspaces", + "mcp.scope_project": "Workspace này", + "mcp.server_command": "Lệnh", + "mcp.server_command_hint": "Lệnh shell để khởi động server.", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "Tên ứng dụng", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "Loại", + "mcp.server_url": "URL máy chủ", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "Đăng nhập", + "mcp.tap_to_connect": "Nhấn để kết nối", + "mcp.technical_details": "Chi tiết kỹ thuật", + "mcp.type_cloud": "Cloud (đăng nhập bằng tài khoản)", + "mcp.type_local": "Nội bộ (chạy trên thiết bị này)", + "mcp.type_local_cmd": "Nội bộ (lệnh)", + "mcp.type_remote": "Từ xa (URL)", + "mcp.url_or_command_required": "Nhập URL cho server từ xa hoặc lệnh cho server nội bộ.", + "mcp.your_apps": "Ứng dụng của bạn", + "message.tool_request_label": "Yêu cầu", + "message.tool_result_label": "Kết quả", + "message.waiting_subagent": "Đang đợi bản ghi từ subagent.", + "message_list.copy_message": "Sao chép tin nhắn", + "message_list.open_session": "Mở phiên", + "message_list.step_updates_progress": "Cập nhật tiến trình", + "message_list.subagent_loading_transcript": "Đang tải bản ghi", + "message_list.subagent_message_count": "{count} tin nhắn{plural}", + "message_list.subagent_running": "Đang chạy", + "message_list.subagent_session_fallback": "Phiên subagent", + "message_list.subagent_type_task": "Task {agentType}", + "message_list.subagent_waiting_transcript": "Đang đợi bản ghi", + "message_list.tool_checked_url": "Đã kiểm tra {url}", + "message_list.tool_checked_web_fallback": "Đã kiểm tra trang web", + "message_list.tool_delegate_agent": "Ủy quyền {agent}", + "message_list.tool_delegate_task_fallback": "Ủy quyền task", + "message_list.tool_load_skill_fallback": "Tải skill", + "message_list.tool_load_skill_named": "Tải skill {name}", + "message_list.tool_read_todo": "Đọc danh sách việc cần làm", + "message_list.tool_reviewed_file": "Đã xem {file}", + "message_list.tool_reviewed_file_fallback": "Đã xem tệp", + "message_list.tool_reviewed_files_fallback": "Đã xem các tệp", + "message_list.tool_reviewed_path": "Đã xem {path}", + "message_list.tool_run_command": "Chạy {command}", + "message_list.tool_run_command_fallback": "Chạy lệnh", + "message_list.tool_searched_code_fallback": "Đã tìm kiếm mã nguồn", + "message_list.tool_searched_pattern": "Đã tìm kiếm {pattern}", + "message_list.tool_update_file": "Cập nhật {file}", + "message_list.tool_update_file_fallback": "Cập nhật tệp", + "message_list.tool_update_todo": "Cập nhật danh sách việc cần làm", + "message_list.tool_updated_file": "Đã cập nhật {file}", + "message_list.tool_updated_file_fallback": "Đã cập nhật tệp", + "model_behavior.desc_builtin": "Model này tự quyết định đường suy luận và không hiển thị cấu hình tại đây.", + "model_behavior.desc_generic": "Sử dụng cấu hình {label}.", + "model_behavior.desc_high": "Dành nhiều thời gian hơn để suy luận trước khi trả lời.", + "model_behavior.desc_high_anthropic": "Sử dụng ngân sách extended thinking tiêu chuẩn.", + "model_behavior.desc_low": "Thực hiện suy luận nhẹ trước khi trả lời.", + "model_behavior.desc_low_google": "Dùng ngân sách suy luận nhẹ hơn để phản hồi nhanh.", + "model_behavior.desc_max": "Sử dụng cấu hình suy luận sâu nhất của provider.", + "model_behavior.desc_max_anthropic": "Sử dụng ngân sách extended thinking lớn nhất.", + "model_behavior.desc_medium": "Cân bằng tốc độ và độ sâu suy luận.", + "model_behavior.desc_minimal": "Sử dụng lượng suy luận rất nhỏ.", + "model_behavior.desc_none": "Ưu tiên tốc độ với đường suy luận nhẹ nhất.", + "model_behavior.desc_standard": "Model này không cung cấp điều khiển suy luận bổ sung.", + "model_behavior.label_balanced": "Cân bằng", + "model_behavior.label_builtin": "Tích hợp", + "model_behavior.label_deep": "Sâu", + "model_behavior.label_extended": "Mở rộng", + "model_behavior.label_fast": "Nhanh", + "model_behavior.label_light": "Nhẹ", + "model_behavior.label_maximum": "Tối đa", + "model_behavior.label_quick": "Nhanh gọn", + "model_behavior.label_standard": "Tiêu chuẩn", + "model_behavior.title_builtin_reasoning": "Suy luận tích hợp", + "model_behavior.title_extended_thinking": "Extended thinking", + "model_behavior.title_reasoning_budget": "Reasoning budget", + "model_behavior.title_reasoning_effort": "Reasoning effort", + "model_behavior.title_standard_generation": "Tạo văn bản tiêu chuẩn", + "model_picker.chat_model_desc": "Chọn model cho cuộc trò chuyện này. Nếu model hỗ trợ hồ sơ suy luận, cấu hình trên thẻ của nó.", + "model_picker.chat_model_title": "Model cuộc trò chuyện", + "model_picker.connect_provider_hint": "Kết nối provider này để duyệt và lưu model", + "model_picker.default_model_desc": "Chọn model mặc định cho cuộc trò chuyện mới, rồi tinh chỉnh hồ sơ suy luận trên thẻ trước khi nhấn Xong.", + "model_picker.default_model_title": "Model mặc định", + "model_picker.model_count": "{count} model", + "model_picker.model_count_one": "1 model", + "model_picker.more_providers": "Thêm provider", + "model_picker.no_results": "Không có model phù hợp với tìm kiếm.", + "model_picker.other_connected_models": "Model đã kết nối khác", + "model_picker.recommended": "Đề xuất", + "onboarding.access_label": "Quyền truy cập", + "onboarding.add": "Thêm", + "onboarding.add_folder_path": "Thêm đường dẫn thư mục", + "onboarding.advanced_settings": "Cài đặt nâng cao", + "onboarding.attach": "Gắn vào", + "onboarding.attach_description": "Gắn vào phiên hiện có trên thiết bị này.", + "onboarding.authorize_folder": "Cấp quyền thư mục", + "onboarding.back": "Quay lại", + "onboarding.checking_cli": "Đang kiểm tra OpenCode CLI...", + "onboarding.choose_workspace_folder": "Chọn thư mục workspace", + "onboarding.cli_checking": "Đang kiểm tra cài đặt...", + "onboarding.cli_install_commands": "Cài đặt OpenCode bằng một trong các lệnh bên dưới, sau đó khởi động lại OpenWork.", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "OpenCode CLI cần cập nhật để hỗ trợ serve.", + "onboarding.cli_not_found": "Không tìm thấy OpenCode CLI.", + "onboarding.cli_not_found_hint": "Không tìm thấy. Cài đặt để chạy máy chủ nội bộ.", + "onboarding.cli_ready": "OpenCode CLI sẵn sàng.", + "onboarding.cli_recheck": "Kiểm tra lại", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "Đã cài đặt", + "onboarding.create_first_workspace": "Tạo workspace đầu tiên", + "onboarding.create_workspace": "Tạo workspace", + "onboarding.engine_running": "Engine đang chạy", + "onboarding.folders_allowed": "{count} thư mục{plural} được phép", + "onboarding.getting_ready": "Đang chuẩn bị mọi thứ", + "onboarding.install": "Cài đặt OpenCode", + "onboarding.install_instruction": "Cài đặt OpenCode để bật máy chủ nội bộ (không cần terminal).", + "onboarding.last_checked": "Kiểm tra lần cuối {time}", + "onboarding.manage_access_hint": "Bạn có thể quản lý quyền truy cập trong cài đặt nâng cao.", + "onboarding.open_settings": "Mở Cài đặt", + "onboarding.open_settings_hint": "Cần tùy chọn engine hoặc truy cập? Mở Cài đặt.", + "onboarding.pick": "Chọn", + "onboarding.ready_message": "OpenCode sẵn sàng khởi động máy chủ nội bộ.", + "onboarding.remember_choice": "Ghi nhớ lựa chọn cho lần sau", + "onboarding.remote_workspace_action": "Kết nối", + "onboarding.remote_workspace_card_description": "Kết nối máy chủ OpenWork để truy cập workspace dùng chung.", + "onboarding.remote_workspace_card_title": "Kết nối workspace từ xa", + "onboarding.remote_workspace_description": "Kết nối máy chủ OpenWork để truy cập workspace từ mọi nơi.", + "onboarding.remote_workspace_title": "Kết nối máy chủ OpenWork", + "onboarding.remove": "Xóa", + "onboarding.resolved_path": "Đường dẫn đã xác định", + "onboarding.run_local": "Chạy nội bộ", + "onboarding.run_local_description": "OpenWork chạy OpenCode trên máy bạn và giữ công việc riêng tư.", + "onboarding.search_notes": "Ghi chú tìm kiếm", + "onboarding.searching_host": "Đang kết nối máy chủ OpenWork...", + "onboarding.serve_help": "Kết quả serve --help", + "onboarding.show_search_notes": "Hiện ghi chú tìm kiếm", + "onboarding.start": "Khởi động OpenWork", + "onboarding.starting_host": "Đang khởi động máy chủ OpenWork...", + "onboarding.theme_current": "Hiện tại: {mode}", + "onboarding.theme_dark": "Tối", + "onboarding.theme_label": "Giao diện", + "onboarding.theme_light": "Sáng", + "onboarding.theme_system": "Hệ thống", + "onboarding.verifying": "Xác minh kết nối bảo mật", + "onboarding.version": "Phiên bản", + "onboarding.welcome_title": "Hôm nay bạn muốn chạy OpenWork như thế nào?", + "onboarding.windows_install_instruction": "Cài đặt OpenCode cho Windows, sau đó khởi động lại OpenWork. Đảm bảo opencode.exe có trong PATH.", + "onboarding.workspace_folder_label": "Workspace là một thư mục chứa skills, plugins và commands riêng.", + "plugins.add": "Thêm", + "plugins.add_hint": "Thêm tên gói npm, ví dụ: opencode-wakatime", + "plugins.add_label": "Thêm plugin", + "plugins.added": "Đã thêm", + "plugins.config": "Cấu hình", + "plugins.config_label": "Cấu hình", + "plugins.desc": "Quản lý `opencode.json` cho dự án hoặc plugins OpenCode toàn cục.", + "plugins.empty": "Chưa cấu hình plugin nào.", + "plugins.enabled": "Đã bật", + "plugins.hide_setup": "Ẩn thiết lập", + "plugins.not_loaded": "Chưa tải", + "plugins.not_loaded_yet": "Chưa tải", + "plugins.remove": "Xóa", + "plugins.scope_global": "Toàn cục", + "plugins.scope_project": "Dự án", + "plugins.setup": "Thiết lập", + "plugins.suggested": "Plugins gợi ý", + "plugins.suggested_heading": "Plugins gợi ý", + "plugins.title": "OpenCode Plugins", + "providers.api_key_label": "API key", + "providers.api_key_required": "API key là bắt buộc", + "providers.auth_failed": "Xác thực thất bại", + "providers.connect_failed": "Kết nối provider thất bại", + "providers.disabled_in_config_suffix": "và đã tắt trong cấu hình OpenCode.", + "providers.disconnect_failed": "Ngắt kết nối provider thất bại", + "providers.disconnected_prefix": "Đã ngắt kết nối", + "providers.load_failed": "Tải providers thất bại", + "providers.no_oauth_prefix": "Không có luồng OAuth cho", + "providers.no_providers_available": "Không có provider khả dụng", + "providers.not_connected": "Chưa kết nối máy chủ", + "providers.not_oauth_flow_prefix": "Phương thức xác thực đã chọn không phải luồng OAuth cho", + "providers.oauth_failed": "Hoàn tất OAuth thất bại", + "providers.oauth_method_required": "Phương thức OAuth là bắt buộc", + "providers.provider_error": "Lỗi provider ({provider})", + "providers.provider_id_required": "ID provider là bắt buộc", + "providers.rate_limit_exceeded": "Vượt quá giới hạn tốc độ", + "providers.removal_unsupported": "Client này không hỗ trợ xóa xác thực provider.", + "providers.request_failed": "Yêu cầu thất bại", + "providers.save_api_key_failed": "Lưu API key thất bại", + "providers.still_connected_suffix": ", nhưng worker vẫn báo đã kết nối. Xóa API key hoặc thông tin OAuth còn lại và khởi động lại worker để ngắt hoàn toàn.", + "providers.unknown_provider": "Provider không xác định", + "providers.use_api_key_suffix": "Dùng API key thay thế.", + "question_modal.custom_answer_label": "Hoặc nhập câu trả lời tùy chỉnh", + "question_modal.custom_answer_placeholder": "Nhập câu trả lời của bạn...", + "question_modal.question_counter": "Câu hỏi {current} / {total}", + "session.allow_for_session": "Cho phép trong phiên này", + "session.allow_once": "Cho phép một lần", + "session.api_key_saved": "Đã lưu API key", + "session.attachments_add_token": "Thêm token máy chủ để đính kèm tệp.", + "session.attachments_connect_server": "Kết nối máy chủ OpenWork để đính kèm tệp.", + "session.back": "Quay lại", + "session.close_quick_actions": "Đóng thao tác nhanh", + "session.close_search": "Đóng tìm kiếm", + "session.cmd_compact_detail": "Gửi lệnh thu gọn đến OpenCode cho phiên này", + "session.cmd_compact_detail_empty": "Chưa có tin nhắn để thu gọn", + "session.cmd_compact_meta": "Thu gọn", + "session.cmd_compact_title": "Thu gọn cuộc hội thoại", + "session.cmd_current_workspace": "Workspace hiện tại", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "Model", + "session.cmd_model_meta": "Mở", + "session.cmd_model_title": "Thay đổi model", + "session.cmd_new_session_detail": "Bắt đầu task mới trong workspace hiện tại", + "session.cmd_new_session_meta": "Tạo", + "session.cmd_new_session_title": "Tạo phiên mới", + "session.cmd_provider_detail": "Mở luồng kết nối provider", + "session.cmd_provider_meta": "Mở", + "session.cmd_provider_title": "Kết nối provider", + "session.cmd_rename_detail_fallback": "Đặt tên rõ hơn cho phiên đã chọn", + "session.cmd_rename_meta": "Đổi tên", + "session.cmd_rename_title": "Đổi tên phiên hiện tại", + "session.cmd_sessions_detail": "{count} khả dụng trên các workspace", + "session.cmd_sessions_meta": "Chuyển", + "session.cmd_sessions_title": "Tìm kiếm phiên", + "session.cmd_switch": "Chuyển", + "session.compacted": "Đã thu gọn phiên.", + "session.compacting": "Đang thu gọn ngữ cảnh phiên...", + "session.compacting_auto": "OpenCode đang tự động thu gọn phiên này", + "session.compacting_manual": "OpenCode đang thu gọn phiên này", + "session.compaction_finished": "OpenCode đã hoàn tất thu gọn ngữ cảnh phiên.", + "session.compaction_started": "OpenCode bắt đầu thu gọn ngữ cảnh phiên.", + "session.conflict_sync_toast": "Xung đột khi đồng bộ {path}. Đã lưu thay đổi cục bộ tại {conflictPath}.", + "session.connect_failed": "Kết nối thất bại", + "session.connect_to_sync": "Kết nối máy chủ OpenWork để đồng bộ tệp từ xa.", + "session.create_or_connect_workspace": "Tạo hoặc kết nối workspace", + "session.create_workspace_desc": "Mở trình tạo workspace và chọn cách bắt đầu.", + "session.create_workspace_title": "Tạo workspace", + "session.default_agent": "Agent mặc định", + "session.default_title": "Phiên mới", + "session.delete": "Xóa", + "session.delete_named_session_message": "Thao tác này sẽ xóa vĩnh viễn \"{title}\" và các tin nhắn.", + "session.delete_session_generic": "Thao tác này sẽ xóa vĩnh viễn phiên đã chọn và các tin nhắn.", + "session.delete_session_title": "Xóa phiên?", + "session.deleted": "Đã xóa phiên", + "session.deleting": "Đang xóa...", + "session.deny": "Từ chối", + "session.details": "Chi tiết", + "session.details_label": "Chi tiết", + "session.doom_loop_label": "Vòng lặp lỗi", + "session.doom_loop_message": "OpenCode phát hiện lệnh gọi công cụ lặp lại với cùng đầu vào và hỏi xem có nên tiếp tục sau các lần thất bại lặp lại.", + "session.doom_loop_note": "Từ chối để dừng vòng lặp, hoặc cho phép nếu bạn muốn agent tiếp tục thử.", + "session.doom_loop_repeated_call_label": "Lệnh gọi lặp lại", + "session.doom_loop_repeated_tool_call": "Lệnh gọi công cụ lặp lại", + "session.doom_loop_title": "Phát hiện vòng lặp lỗi", + "session.doom_loop_tool_label": "Công cụ", + "session.downloading": "Đang tải xuống", + "session.downloading_percent": "Đang tải xuống {percent}%", + "session.downloading_update_title": "Đang tải bản cập nhật {version}", + "session.export_already_running": "Đang xuất rồi.", + "session.export_desktop_only": "Xuất khả dụng trong ứng dụng desktop.", + "session.export_desktop_only_local": "Xuất khả dụng cho worker nội bộ trong ứng dụng desktop.", + "session.export_local_only": "Xuất chỉ hỗ trợ cho worker nội bộ.", + "session.failed_to_compact": "Thu gọn phiên thất bại", + "session.failed_to_create_session": "Tạo phiên thất bại", + "session.failed_to_delete": "Xóa phiên thất bại", + "session.failed_to_load_agents": "Tải agents thất bại", + "session.failed_to_load_providers": "Tải providers thất bại", + "session.failed_to_redo": "Làm lại thất bại", + "session.failed_to_save_api_key": "Lưu API key thất bại", + "session.failed_to_stop": "Dừng thất bại", + "session.failed_to_undo": "Hoàn tác thất bại", + "session.file_open_desktop_only": "Mở tệp khả dụng trong ứng dụng desktop.", + "session.file_open_failed": "Mở tệp thất bại", + "session.file_open_remote_unavailable": "Mở tệp không khả dụng cho workspace từ xa.", + "session.flyout_file_modified": "Tệp đã sửa đổi", + "session.flyout_new_task": "Task mới", + "session.install_update": "Cài đặt bản cập nhật", + "session.jump_to_latest": "Chuyển đến mới nhất", + "session.jump_to_start": "Chuyển đến đầu tin nhắn", + "session.load_earlier": "Tải tin nhắn trước đó", + "session.loading_detail": "Đang tải tin nhắn mới nhất cho task này.", + "session.loading_earlier": "Đang tải tin nhắn trước đó...", + "session.loading_session": "Đang tải phiên", + "session.loading_title": "Đang tải phiên", + "session.menu_label": "Menu", + "session.model": "Model", + "session.model_fallback": "Model", + "session.new_task": "Tạo task mới", + "session.next_match": "Kết quả tiếp theo", + "session.no_matches": "Không có kết quả", + "session.no_matches_command": "Không có kết quả.", + "session.no_session_selected": "Chưa chọn phiên", + "session.nothing_to_compact": "Chưa có gì để thu gọn.", + "session.nothing_to_redo": "Không có gì để làm lại.", + "session.nothing_to_retry": "Chưa có gì để thử lại", + "session.nothing_to_undo": "Chưa có gì để hoàn tác.", + "session.oauth_failed": "OAuth thất bại", + "session.obsidian_worker_relative_only": "Chỉ có thể mở tệp thuộc worker trong Obsidian.", + "session.open": "Mở", + "session.palette_hint_navigate": "Phím mũi tên để di chuyển", + "session.palette_hint_run": "Enter để chạy · Esc để đóng", + "session.palette_placeholder_actions": "Tìm thao tác", + "session.palette_placeholder_sessions": "Tìm theo tiêu đề phiên hoặc workspace", + "session.palette_title_actions": "Thao tác nhanh", + "session.palette_title_sessions": "Tìm kiếm phiên", + "session.permission_detail_command": "Lệnh", + "session.permission_detail_cwd": "Thư mục làm việc", + "session.permission_detail_description": "Mô tả", + "session.permission_detail_diff": "Diff", + "session.permission_detail_file": "Tệp", + "session.permission_detail_files": "Tệp", + "session.permission_detail_agent": "Agent", + "session.permission_detail_parent_directory": "Thư mục cha", + "session.permission_detail_path": "Đường dẫn", + "session.permission_detail_query": "Truy vấn", + "session.permission_detail_target": "Đích", + "session.permission_detail_tool": "Công cụ", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "Sửa tệp", + "session.permission_kind_external_directory": "Thư mục bên ngoài", + "session.permission_kind_question": "Câu hỏi", + "session.permission_kind_read": "Đọc tệp", + "session.permission_kind_skill": "Skill", + "session.permission_kind_task": "Tác vụ con", + "session.permission_kind_todowrite": "Ghi danh sách việc cần làm", + "session.permission_label": "Quyền", + "session.permission_message": "OpenCode đang yêu cầu quyền để tiếp tục.", + "session.permission_message_bash": "Xem phạm vi lệnh trước khi cho phép OpenCode tiếp tục.", + "session.permission_message_edit": "Xem tệp và diff trước khi cho phép OpenCode thay đổi.", + "session.permission_message_external_directory": "Xem thư mục trước khi cho phép truy cập ngoài workspace.", + "session.permission_message_read": "Xem phạm vi tệp được yêu cầu trước khi cho phép truy cập.", + "session.permission_message_task": "Xem tác vụ con được yêu cầu trước khi cho phép bắt đầu.", + "session.permission_metadata_unavailable": "Không thể hiển thị siêu dữ liệu.", + "session.permission_required": "Cần cấp quyền", + "session.permission_review_label": "Xem lại", + "session.permission_scope_empty": "Không có phạm vi cụ thể.", + "session.permission_decision_hint": "Cho phép một lần cho yêu cầu này, hoặc cho phép trong phiên khi bạn tin tưởng phạm vi này.", + "session.permission_title_bash": "Chạy lệnh shell?", + "session.permission_title_edit": "Sửa tệp?", + "session.permission_title_external_directory": "Truy cập thư mục bên ngoài?", + "session.permission_title_generic": "Phê duyệt {permission}?", + "session.permission_title_read": "Đọc tệp?", + "session.permission_title_task": "Bắt đầu tác vụ con?", + "session.phase_responding": "Đang phản hồi", + "session.phase_retrying": "Đang thử lại", + "session.phase_run_failed": "Chạy thất bại", + "session.phase_sending": "Đang gửi", + "session.pick_folder_desc": "Chọn thư mục dự án hoặc ghi chú hiện có và OpenWork sẽ dùng nó làm workspace.", + "session.pick_folder_title": "Chọn thư mục bạn muốn làm việc", + "session.pick_workspace_to_open": "Chọn workspace để mở tệp.", + "session.prev_match": "Kết quả trước", + "session.provider_auth_in_progress": "Xác thực provider đang diễn ra.", + "session.provider_connected": "Đã kết nối provider", + "session.quick_actions_label": "Thao tác nhanh", + "session.quick_actions_title": "Thao tác nhanh (Ctrl/Cmd+K)", + "session.redo_aria_label": "Làm lại tin nhắn đã hoàn tác", + "session.redo_label": "Làm lại", + "session.redo_title": "Làm lại tin nhắn đã hoàn tác", + "session.remote_sync_failed": "Đồng bộ tệp từ xa thất bại", + "session.rename_description": "Cập nhật tên cho phiên này.", + "session.rename_label": "Tên phiên", + "session.rename_placeholder": "Nhập tên mới", + "session.rename_title": "Đổi tên phiên", + "session.resize_workspace_column": "Thay đổi kích thước cột workspace", + "session.restart_update_title": "Khởi động lại để áp dụng bản cập nhật {version}", + "session.restored_message": "Đã khôi phục tin nhắn đã hoàn tác.", + "session.reveal": "Hiện trong trình quản lý tệp", + "session.reveal_desktop_only": "Hiển thị trong trình quản lý tệp khả dụng trong ứng dụng desktop.", + "session.revert_label": "Hoàn tác", + "session.reverted_last_message": "Đã hoàn tác tin nhắn cuối.", + "session.run": "Chạy", + "session.scope_label": "Phạm vi", + "session.search_conversation_label": "Tìm kiếm cuộc hội thoại", + "session.search_conversation_title": "Tìm kiếm cuộc hội thoại (Ctrl/Cmd+F)", + "session.search_next": "Tiếp", + "session.search_placeholder": "Tìm trong cuộc trò chuyện này", + "session.search_position": "{current} / {total}", + "session.search_prev": "Trước", + "session.share_active_cloud_org": "Tổ chức Cloud đang hoạt động", + "session.share_choose_org": "Chọn tổ chức trong Cài đặt -> Cloud trước khi chia sẻ với nhóm.", + "session.share_collaborator_hint": "Truy cập từ xa thông thường khi không cần thao tác chủ sở hữu.", + "session.share_collaborator_host_hint": "Truy cập từ xa thông thường tới máy chủ này mà không cần thao tác chủ sở hữu.", + "session.share_collaborator_label": "Token cộng tác", + "session.share_collaborator_token": "Token cộng tác", + "session.share_connected_with_hint": "Workspace này đang kết nối với mật khẩu này.", + "session.share_desktop_app_required": "Yêu cầu ứng dụng desktop", + "session.share_desktop_required": "Yêu cầu ứng dụng desktop", + "session.share_host_url_and_token_required": "URL và token máy chủ OpenWork là bắt buộc.", + "session.share_local_host_not_ready": "Máy chủ OpenWork nội bộ chưa sẵn sàng.", + "session.share_missing_host_url": "Thiếu URL máy chủ OpenWork.", + "session.share_missing_token": "Thiếu token OpenWork.", + "session.share_no_skills": "Không tìm thấy skills trong workspace này.", + "session.share_note_direct_runtime": "Engine runtime đang ở chế độ Trực tiếp. Chuyển worker nội bộ có thể khởi động lại máy chủ và ngắt kết nối client. Token có thể thay đổi sau khi khởi động lại.", + "session.share_opencode_base_url": "URL cơ sở OpenCode", + "session.share_openwork_workers_only": "Liên kết chia sẻ dịch vụ khả dụng cho worker OpenWork.", + "session.share_owner_permission_hint": "Dùng khi client từ xa cần trả lời yêu cầu quyền.", + "session.share_password": "Mật khẩu", + "session.share_password_owner_hint": "Dùng khi client từ xa cần trả lời yêu cầu quyền.", + "session.share_publish_skills_failed": "Xuất bản bộ skills thất bại", + "session.share_publish_workspace_failed": "Xuất bản hồ sơ workspace thất bại", + "session.share_resolve_local_workspace_failed": "Không thể xác định workspace này trên máy chủ OpenWork nội bộ.", + "session.share_resolve_remote_workspace_failed": "Không thể xác định workspace này trên máy chủ OpenWork.", + "session.share_save_team_template_failed": "Lưu mẫu nhóm thất bại", + "session.share_saved_to_org": "Đã lưu {name} vào {org}.", + "session.share_select_workspace": "Chọn workspace trước.", + "session.share_set_token_hint": "Đặt token trong cài đặt workspace", + "session.share_sign_in_required": "Đăng nhập OpenWork Cloud trong Cài đặt để chia sẻ với nhóm.", + "session.share_skills_set_desc": "Bộ skills hoàn chỉnh từ workspace OpenWork.", + "session.share_starting_server": "Đang khởi động máy chủ...", + "session.share_team_fallback_name": "mẫu nhóm của bạn", + "session.share_url_resolving_hint": "URL Worker đang xử lý; URL máy chủ hiển thị tạm.", + "session.share_url_worker_hint": "Dùng trên điện thoại hoặc laptop kết nối worker này.", + "session.share_worker_url": "URL Worker", + "session.share_worker_url_phones_hint": "Dùng trên điện thoại hoặc laptop kết nối worker này.", + "session.share_worker_url_resolving_hint": "URL Worker đang xử lý; URL máy chủ hiển thị tạm.", + "session.shared_folder_upload_failed": "Tải lên thư mục chia sẻ thất bại", + "session.show_earlier": "Hiện {count} tin nhắn trước đó{plural}", + "session.status_active": "Phiên đang hoạt động", + "session.status_compacting": "Đang thu gọn ngữ cảnh", + "session.status_delegating": "Đang ủy quyền", + "session.status_gathering_context": "Đang thu thập ngữ cảnh", + "session.status_planning": "Đang lập kế hoạch", + "session.status_ready": "Sẵn sàng", + "session.status_ready_session": "Phiên sẵn sàng", + "session.status_running_shell": "Đang chạy shell", + "session.status_searching_codebase": "Đang tìm kiếm mã nguồn", + "session.status_searching_web": "Đang tìm kiếm trên web", + "session.status_thinking": "Đang suy nghĩ", + "session.status_working": "Đang làm việc", + "session.status_writing_file": "Đang ghi tệp", + "session.stopped": "Đã dừng.", + "session.stopping_run": "Đang dừng lượt chạy...", + "session.todo_progress": "{completed} / {total} task hoàn thành", + "session.trying_again": "Đang thử lại...", + "session.unable_to_open_file": "Không thể mở tệp", + "session.unable_to_open_obsidian": "Không thể mở tệp trong Obsidian", + "session.unable_to_reveal": "Không thể hiển thị workspace", + "session.undo_label": "Hoàn tác", + "session.undo_title": "Hoàn tác tin nhắn cuối", + "session.update_available": "Có bản cập nhật", + "session.update_available_title": "Có bản cập nhật {version}", + "session.update_ready": "Cập nhật sẵn sàng", + "session.update_ready_stop_runs_title": "Cập nhật sẵn sàng {version}. Dừng task đang chạy để khởi động lại.", + "session.upload_connect_server": "Kết nối máy chủ OpenWork để tải tệp lên thư mục chia sẻ.", + "session.uploaded_to_shared_folder": "Đã tải lên thư mục chia sẻ.", + "session.uploaded_with_summary": "Đã tải lên thư mục chia sẻ: {summary}", + "session.uploading_to_shared_folder": "Đang tải {label} lên thư mục chia sẻ...", + "session.workspace_fallback": "Workspace", + "session.workspace_label": "Workspace", + "session.workspace_path_unavailable": "Đường dẫn workspace không khả dụng.", + "session.workspace_setup_desc": "Bắt đầu với workspace OpenWork có hướng dẫn, hoặc chọn thư mục hiện có.", + "session.workspace_setup_label": "Thiết lập workspace", + "session.workspace_setup_title": "Thiết lập workspace đầu tiên", + "settings.action_download": "Tải xuống", + "settings.action_install": "Cài đặt", + "settings.actor_host": "máy chủ", + "settings.actor_remote": "từ xa", + "settings.actor_unknown": "không rõ", + "settings.advanced": "Nâng cao", + "settings.advanced_title": "Nâng cao", + "settings.api_keys_info": "API key được lưu cục bộ bởi OpenCode. Provider dựa trên biến môi trường phải thay đổi trong môi trường worker rồi tải lại.", + "settings.appearance_hint": "Theo hệ thống hoặc buộc chế độ sáng/tối.", + "settings.appearance_title": "Giao diện", + "settings.audit_error": "Lỗi", + "settings.audit_loading": "Đang tải", + "settings.audit_log_title": "Nhật ký kiểm toán", + "settings.audit_ready": "Sẵn sàng", + "settings.auto_compact": "Thu gọn ngữ cảnh tự động", + "settings.auto_compact_desc": "Kiểm soát thu gọn ngữ cảnh tự động của OpenCode cho workspace này. Tải lại engine sau khi thay đổi.", + "settings.auto_update_desc": "Tự động tải bản cập nhật (hỏi trước khi", + "settings.auto_update_title": "Tự động cập nhật", + "settings.available_count": "{count} khả dụng", + "settings.background_checks_desc": "OpenWork luôn kiểm tra khi khởi động. Cũng kiểm tra một lần", + "settings.background_checks_title": "Kiểm tra nền", + "settings.base_url_unavailable": "URL cơ sở không khả dụng", + "settings.binary_unavailable": "File nhị phân không khả dụng", + "settings.cache_nothing_to_repair": "Không tìm thấy bộ nhớ đệm OpenCode. Không cần sửa.", + "settings.cache_repair_requires_desktop": "Sửa bộ nhớ đệm yêu cầu ứng dụng desktop", + "settings.cache_repaired": "Đã sửa bộ nhớ đệm OpenCode. Khởi động lại engine nếu đang chạy.", + "settings.cap_browser_tools": "Công cụ trình duyệt: {value}", + "settings.cap_commands": "Commands: {value}", + "settings.cap_config": "Cấu hình: {value}", + "settings.cap_file_tools": "Công cụ tệp: {value}", + "settings.cap_inbox_off": "hộp đến tắt", + "settings.cap_inbox_on": "hộp đến bật", + "settings.cap_mcp": "MCP: {value}", + "settings.cap_outbox_off": "hộp đi tắt", + "settings.cap_outbox_on": "hộp đi bật", + "settings.cap_plugins": "Plugins: {value}", + "settings.cap_read": "đọc", + "settings.cap_sandbox": "Sandbox: {value}", + "settings.cap_skills": "Skills: {value}", + "settings.cap_write": "ghi", + "settings.capabilities_title": "Khả năng máy chủ OpenWork", + "settings.capabilities_unavailable": "Khả năng không khả dụng. Kết nối bằng token client.", + "settings.change": "Thay đổi", + "settings.check_update": "Kiểm tra", + "settings.checking_for_updates": "Đang kiểm tra cập nhật", + "settings.choose": "Chọn", + "settings.clear": "Xóa", + "settings.clipboard_unavailable": "Clipboard không khả dụng trong môi trường này.", + "settings.configure": "Cấu hình", + "settings.connect_opencode_hint": "Kết nối OpenCode để tải provider.", + "settings.connect_provider": "Kết nối provider", + "settings.connected_count": "{count} đã kết nối", + "settings.connection": "Kết nối", + "settings.connection_failed": "Kết nối thất bại", + "settings.connection_title": "Kết nối", + "settings.copied_debug_report": "Đã sao chép báo cáo runtime JSON.", + "settings.copy_failed": "Sao chép báo cáo runtime thất bại.", + "settings.copy_json": "Sao chép JSON", + "settings.custom_binary_hint": "Dùng để trỏ OpenWork đến bản build OpenCode nội bộ", + "settings.custom_binary_label": "File nhị phân OpenCode tùy chỉnh", + "settings.data_dir_unavailable": "Thư mục dữ liệu không khả dụng", + "settings.debug_commit": "Commit: {sha}", + "settings.debug_desktop_app": "Ứng dụng desktop: {version}", + "settings.debug_opencode_version": "OpenCode: {version}", + "settings.debug_openwork_server_version": "Máy chủ OpenWork: {version}", + "settings.debug_section_title": "Nhà phát triển", + "settings.deeplink_failed": "Mở deep link thất bại.", + "settings.deeplink_hint": "Chấp nhận openwork://, openwork-dev://, hoặc URL https://share.openworklabs.com/b/... hỗ trợ.", + "settings.default_model": "Model mặc định", + "settings.delete_containers": "Đang xóa container...", + "settings.delete_local_config": "Đang xóa trạng thái cục bộ...", + "settings.desktop_only_hint": "Khả dụng trong ứng dụng desktop.", + "settings.dev_mode_badge": "Chế độ phát triển", + "settings.developer": "Nhà phát triển", + "settings.developer_mode_desc": "Bật công cụ gỡ lỗi, chẩn đoán và tab Nhà phát triển.", + "settings.developer_mode_title": "Chế độ nhà phát triển", + "settings.developer_panel_disabled": "Đã tắt bảng nhà phát triển.", + "settings.developer_panel_enabled": "Đã bật bảng nhà phát triển.", + "settings.devlog_cleared": "Đã xóa log nhà phát triển.", + "settings.devlog_clipboard_unavailable": "Clipboard không khả dụng trong môi trường này.", + "settings.devlog_copied": "Đã sao chép log nhà phát triển.", + "settings.devlog_copy_failed": "Không thể sao chép log nhà phát triển.", + "settings.devlog_export_failed": "Không thể xuất log nhà phát triển.", + "settings.devlog_export_unavailable": "Xuất không khả dụng trong môi trường này.", + "settings.devlog_exported": "Đã xuất log nhà phát triển.", + "settings.devtools_desc": "Trạng thái sidecar, khả năng và nhật ký kiểm toán.", + "settings.devtools_title": "Devtools", + "settings.diag_approval": "Phê duyệt: {mode} ({ms}ms)", + "settings.diag_config_path": "Đường dẫn cấu hình: {path}", + "settings.diag_daemon_url": "Daemon: {url}", + "settings.diag_default": "mặc định", + "settings.diag_health_port": "Cổng health: {port}", + "settings.diag_healthy_ms": "Healthy: {ms}ms", + "settings.diag_host_token_source": "Nguồn token máy chủ: {source}", + "settings.diag_last_attempt": "Lần thử cuối: {time}", + "settings.diag_load_sessions_ms": "Tải phiên: {ms}ms", + "settings.diag_opencode_binary": "File nhị phân OpenCode: {binary}", + "settings.diag_opencode_url": "OpenCode: {url}", + "settings.diag_pending_permissions_ms": "Quyền đang chờ: {ms}ms", + "settings.diag_pid": "PID: {pid}", + "settings.diag_providers_ms": "Providers: {ms}ms", + "settings.diag_read_only": "Chỉ đọc: {value}", + "settings.diag_reason": "Lý do: {reason}", + "settings.diag_runtime_workspace": "Workspace runtime: {id}", + "settings.diag_selected_workspace": "Workspace đã chọn: {id}", + "settings.diag_sidecar": "Sidecar: {info}", + "settings.diag_started": "Bắt đầu: {time}", + "settings.diag_token_source": "Nguồn token: {source}", + "settings.diag_total_ms": "Tổng: {ms}ms", + "settings.diag_version": "Phiên bản: {version}", + "settings.diag_workspaces": "Workspaces: {count}", + "settings.diagnostics_unavailable": "Chẩn đoán không khả dụng.", + "settings.disable_developer_mode": "Tắt chế độ Nhà phát triển", + "settings.disabled": "Đã tắt", + "settings.disconnect": "Ngắt kết nối", + "settings.disconnect_confirm_suffix": "Ngắt kết nối {resolved}? Thao tác này sẽ xóa API key hoặc thông tin OAuth đã lưu cho provider này.", + "settings.disconnect_server": "Ngắt kết nối máy chủ", + "settings.disconnected_prefix": "Đã ngắt kết nối {resolved}.", + "settings.disconnecting": "Đang ngắt kết nối...", + "settings.docker_containers_desc": "Buộc xóa container Docker do OpenWork tạo", + "settings.docker_containers_title": "Container Docker của OpenWork", + "settings.docker_requires_desktop": "Dọn dẹp Docker yêu cầu ứng dụng desktop", + "settings.done": "Xong", + "settings.downloading_bytes": "Đang tải {downloaded}", + "settings.downloading_progress": "Đang tải {downloaded} / {total} ({percent}%)", + "settings.enable_developer_mode": "Bật chế độ Nhà phát triển", + "settings.enable_exa": "Bật tìm kiếm web Exa", + "settings.enable_exa_desc": "Áp dụng khi OpenWork Orchestrator khởi chạy OpenCode.", + "settings.enabled": "Đã bật", + "settings.engine_bundled": "Đi kèm (khuyến nghị)", + "settings.engine_bundled_hint": "Engine đi kèm là tùy chọn đáng tin nhất. Dùng Hệ thống", + "settings.engine_custom_binary": "File nhị phân tùy chỉnh", + "settings.engine_desc": "Chọn cách OpenCode chạy nội bộ.", + "settings.engine_runtime_label": "Runtime engine", + "settings.engine_source": "Nguồn engine", + "settings.engine_source_debug": "Nguồn engine", + "settings.engine_system_path": "Cài đặt hệ thống (PATH)", + "settings.engine_title": "Engine", + "settings.environment.add_button": "Add variable", + "settings.environment.add_title": "Add environment variable", + "settings.environment.apply_button": "Apply changes", + "settings.environment.apply_blocked_active_tasks": "Stop running tasks before applying environment changes.", + "settings.environment.apply_confirm_body": "OpenWork will restart local agents so they can use the latest environment. Running local tasks may stop.", + "settings.environment.apply_no_local_workspace": "OpenWork is not connected to a local workspace.", + "settings.environment.apply_pending_body": "Apply changes to restart local agents and make the latest values available.", + "settings.environment.apply_pending_body_manual": "Restart local agents to make the latest values available.", + "settings.environment.apply_pending_title": "Changes are saved, not active yet", + "settings.environment.apply_refresh_failed": "Changes are active, but OpenWork status did not refresh. Reopen the app if it looks stale.", + "settings.environment.apply_success": "Environment changes are active.", + "settings.environment.apply_title": "Apply environment changes?", + "settings.environment.apply_unavailable": "Apply changes is only available in the desktop app.", + "settings.environment.applying": "Applying…", + "settings.environment.cancel": "Cancel", + "settings.environment.click_to_edit": "Click to edit", + "settings.environment.close_editor": "Close editor", + "settings.environment.confirm_delete": "Delete {key}? Agents stop seeing this key after you apply changes.", + "settings.environment.delete": "Delete", + "settings.environment.delete_title": "Delete environment variable", + "settings.environment.delete_variable": "Delete {key}", + "settings.environment.deleting": "Deleting…", + "settings.environment.description": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device; changes become available after you apply them.", + "settings.environment.edit_title": "Edit environment variable", + "settings.environment.empty_body": "Add keys like ANTHROPIC_API_KEY, GOOGLE_API_KEY, ELEVENLABS_API_KEY, or GITHUB_TOKEN for services your agents and MCP servers need.", + "settings.environment.empty_title": "No environment variables yet", + "settings.environment.empty_value": "(empty)", + "settings.environment.footer_hint": "OPENWORK_ and OPENCODE_ keys are reserved for app/runtime wiring. Configure OpenCode runtime settings from your shell.", + "settings.environment.hide": "Hide", + "settings.environment.hide_value": "Hide value for {key}", + "settings.environment.key_hint": "Letters, digits, and underscores. Cannot start with a digit.", + "settings.environment.key_label": "Key", + "settings.environment.loading": "Loading…", + "settings.environment.override_hint": "Environment variables set before OpenWork starts take precedence over values saved here.", + "settings.environment.remote_workspace_hint": "This workspace is remote. Local environment variables are hidden here; use cloud LLM Providers or configure the worker host directly.", + "settings.environment.restart_required": "Saved. Apply changes to make the update available.", + "settings.environment.reveal": "Reveal", + "settings.environment.reveal_value": "Reveal value for {key}", + "settings.environment.save": "Save", + "settings.environment.saving": "Saving…", + "settings.environment.title": "Environment variables", + "settings.environment.validation_duplicate": "A variable with this name already exists.", + "settings.environment.validation_empty": "Name is required.", + "settings.environment.validation_reserved": "OPENWORK_ and OPENCODE_ names are managed by OpenWork/OpenCode.", + "settings.environment.validation_shape": "Use letters, digits, and underscores; do not start with a digit.", + "settings.environment.value_label": "Value", + "settings.exa_restart_hint": "Khởi động lại OpenCode hoặc orchestrator sau khi thay đổi cài đặt này.", + "settings.export": "Xuất", + "settings.export_failed": "Xuất báo cáo runtime thất bại.", + "settings.export_unavailable": "Xuất không khả dụng trong môi trường này.", + "settings.exported_debug_report": "Đã xuất báo cáo runtime JSON.", + "settings.failed": "Thất bại", + "settings.failed_open_providers": "Mở providers thất bại", + "settings.feedback_badge": "Chúng tôi đọc từng tin nhắn", + "settings.feedback_desc": "Cho chúng tôi biết điều gì tuyệt vời và điều gì chưa ổn. Phản hồi sẽ được gửi thẳng đến nhóm và giúp chúng tôi ưu tiên những gì phát triển tiếp theo.", + "settings.feedback_title": "Góp phần định hình OpenWork", + "settings.group_global": "Toàn cục", + "settings.group_workspace": "Workspace", + "settings.hide_titlebar": "Ẩn thanh tiêu đề", + "settings.hide_titlebar_desc": "Ẩn thanh tiêu đề cửa sổ. Hữu ích cho trình quản lý cửa sổ xếp gạch", + "settings.join_discord": "Tham gia Discord", + "settings.language": "Ngôn ngữ", + "settings.language.description": "Chọn ngôn ngữ ưa thích", + "settings.last_error": "Lỗi cuối", + "settings.last_stderr": "Stderr cuối", + "settings.last_stdout": "Stdout cuối", + "settings.loading_providers": "Đang tải provider...", + "settings.logs_on_host": "Nhật ký khả dụng trên máy chủ.", + "settings.managed_by_env": "Quản lý bởi biến môi trường", + "settings.messaging_bridge_service": "Dịch vụ cầu nối nhắn tin.", + "settings.messaging_section_desc": "Quản lý danh tính Telegram/Slack và liên kết trong tab Danh tính.", + "settings.messaging_section_title": "Nhắn tin", + "settings.model": "Model", + "settings.model_behavior": "Hành vi model", + "settings.model_behavior_desc": "Mở bộ chọn model mặc định để chọn hồ sơ suy luận khi có sẵn.", + "settings.model_default": "Mặc định", + "settings.model_description": "Mặc định + điều khiển suy luận cho các lượt chạy.", + "settings.model_description_default": "Chọn từ các provider đã cấu hình. Lựa chọn này áp dụng cho phiên mới.", + "settings.model_description_session": "Chọn từ các provider đã cấu hình. Lựa chọn này áp dụng cho tin nhắn tiếp theo.", + "settings.model_fallback": "Dự phòng", + "settings.model_reasoning": "Suy luận", + "settings.model_section_desc": "Chọn model trò chuyện mặc định và xem xét cách nó suy luận.", + "settings.model_title": "Model", + "settings.no_access": "không có quyền", + "settings.no_active_workspace": "Không có workspace nội bộ đang hoạt động.", + "settings.no_audit_entries": "Chưa có mục kiểm toán.", + "settings.no_binary_selected": "Chưa chọn file nhị phân.", + "settings.no_custom_path_set": "Chưa đặt đường dẫn tùy chỉnh", + "settings.no_project_directory": "Không có thư mục dự án", + "settings.no_stderr": "Chưa ghi nhận stderr.", + "settings.no_stdout": "Chưa ghi nhận stdout.", + "settings.no_worker_directory": "Không có thư mục dự án", + "settings.no_worker_path": "Không có đường dẫn worker", + "settings.nuke_confirm_dev": "Thao tác không thể hoàn tác. Sẽ xóa tất cả dữ liệu OpenWork cho bản dev này cùng cấu hình, xác thực, bộ nhớ đệm, dữ liệu và trạng thái OpenCode dev, rồi thoát OpenWork. Tiếp tục?", + "settings.nuke_confirm_prod": "Thao tác không thể hoàn tác. Sẽ xóa tất cả dữ liệu OpenWork cho bản dev này cùng cấu hình, xác thực, bộ nhớ đệm, dữ liệu và trạng thái OpenCode dev, rồi thoát OpenWork. Tiếp tục?", + "settings.nuke_failed": "Xóa trạng thái OpenWork và OpenCode thất bại.", + "settings.nuke_hint": "Chỉ dùng khi bạn muốn đặt lại hoàn toàn ứng dụng desktop và trạng thái runtime OpenCode.", + "settings.nuke_success": "Đã xóa trạng thái OpenWork và OpenCode. OpenWork đang đóng...", + "settings.off": "Tắt", + "settings.offline": "Ngoại tuyến", + "settings.on": "Bật", + "settings.open_deeplink_action": "Đang mở...", + "settings.open_deeplink_button": "Ẩn", + "settings.open_deeplink_desc": "Dán deeplink hoặc URL chia sẻ OpenWork để mở.", + "settings.open_deeplink_title": "Mở Deeplink", + "settings.opencode_cache": "Bộ nhớ đệm OpenCode", + "settings.opencode_cache_description": "Sửa dữ liệu đệm dùng để khởi động engine. An toàn để chạy.", + "settings.opencode_engine_desc": "Runtime nội bộ cho agents, tools và provider mô hình.", + "settings.opencode_engine_label": "Engine OpenCode", + "settings.opencode_engine_sidecar_desc": "Sidecar thực thi nội bộ.", + "settings.opencode_sdk_desc": "Chẩn đoán kết nối giao diện.", + "settings.opencode_sdk_title": "Engine OpenCode", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "URL cơ sở không khả dụng", + "settings.opening": "Mở deeplink", + "settings.openwork_config_sidecar_desc": "Sidecar cấu hình và phê duyệt.", + "settings.openwork_diagnostics_title": "Chẩn đoán máy chủ OpenWork", + "settings.openwork_server_desc": "Trung tâm điều phối phiên cho đồng bộ ứng dụng, workers và kết nối từ xa.", + "settings.openwork_server_label": "Máy chủ OpenWork", + "settings.pending_permissions": "Quyền đang chờ", + "settings.production_mode_badge": "Production", + "settings.provider_default_desc": "Sử dụng hành vi suy luận mặc định tích hợp sẵn của model.", + "settings.provider_default_label": "Mặc định của provider", + "settings.provider_source_config": "Cấu hình", + "settings.provider_source_custom": "Tùy chỉnh", + "settings.provider_source_env": "Biến môi trường", + "settings.providers_desc": "Kết nối dịch vụ cho models và công cụ.", + "settings.providers_title": "Providers", + "settings.quit_hint": "OpenWork thoát ngay sau khi dọn dẹp để lần khởi động tiếp theo bắt đầu từ trạng thái cục bộ trống cho chế độ này.", + "settings.recent_events": "Sự kiện gần đây", + "settings.reconnect_failed": "Kết nối lại thất bại. Kiểm tra URL/token máy chủ và thử lại.", + "settings.reconnect_server": "Đang kết nối lại...", + "settings.reconnect_server_failed": "Kết nối lại máy chủ OpenWork thất bại.", + "settings.reconnected": "Đã kết nối lại máy chủ OpenWork.", + "settings.reconnecting": "Đang kết nối lại...", + "settings.removing_containers": "Đang xóa container...", + "settings.removing_local_state": "Đang xóa trạng thái cục bộ...", + "settings.repair_cache": "Sửa bộ nhớ đệm", + "settings.repairing_cache": "Đang sửa bộ nhớ đệm", + "settings.report_issue": "Báo cáo sự cố", + "settings.reset": "Đặt lại", + "settings.reset_app_data": "Đặt lại dữ liệu ứng dụng", + "settings.reset_app_data_description": "Triệt để hơn. Xóa bộ nhớ đệm + dữ liệu ứng dụng OpenWork.", + "settings.reset_app_data_title": "Đặt lại dữ liệu ứng dụng", + "settings.reset_app_data_warning": "Xóa bộ nhớ đệm và dữ liệu ứng dụng OpenWork trên thiết bị này.", + "settings.reset_button": "Đặt lại", + "settings.reset_cancel": "Hủy", + "settings.reset_config_defaults": "Đang đặt lại...", + "settings.reset_config_failed": "Đặt lại cấu hình ứng dụng thất bại.", + "settings.reset_confirm_button": "Đặt lại & Khởi động lại", + "settings.reset_confirmation_hint": "Nhập {resetWord} để xác nhận. OpenWork sẽ khởi động lại.", + "settings.reset_confirmation_label": "Xác nhận", + "settings.reset_confirmation_placeholder": "Nhập RESET", + "settings.reset_onboarding": "Đặt lại thiết lập ban đầu", + "settings.reset_onboarding_description": "Xóa tùy chọn OpenWork và khởi động lại ứng dụng.", + "settings.reset_onboarding_title": "Đặt lại thiết lập ban đầu", + "settings.reset_onboarding_warning": "Xóa tùy chọn cục bộ và đánh dấu thiết lập ban đầu của OpenWork.", + "settings.reset_openwork_desc_dev": "Với chế độ dev, chỉ xóa trạng thái OpenCode dev riêng biệt trong openwork-dev-data.", + "settings.reset_openwork_desc_prod": "Với chế độ dev, chỉ xóa trạng thái OpenCode dev riêng biệt trong openwork-dev-data.", + "settings.reset_openwork_title": "Đặt lại trạng thái OpenWork + OpenCode", + "settings.reset_recovery_desc": "Xóa dữ liệu hoặc khởi động lại luồng thiết lập.", + "settings.reset_recovery_title": "Đặt lại & Khôi phục", + "settings.reset_requires_confirm": "Yêu cầu nhập RESET và sẽ khởi động lại ứng dụng.", + "settings.reset_startup": "Đặt lại chế độ khởi động mặc định", + "settings.reset_startup_pref": "Đặt lại tùy chọn khởi động", + "settings.reset_stop_active_runs": "Dừng các task đang chạy trước khi đặt lại.", + "settings.resetting": "Đang đặt lại...", + "settings.restart_blocked_message": "OpenWork cần khởi động lại để hoàn tất cập nhật. Để tránh gián đoạn công việc hiện tại, cài đặt bị tạm dừng cho đến khi task đang chạy hoàn tất hoặc bạn dừng chúng.", + "settings.restart_failed": "Khởi động lại thất bại. Kiểm tra nhật ký và thử lại.", + "settings.restart_opencode": "Đang khởi động lại...", + "settings.restart_openwork_server": "Đang khởi động lại...", + "settings.restart_server_failed": "Khởi động lại máy chủ nội bộ thất bại.", + "settings.restarted": "Đã khởi động lại máy chủ nội bộ.", + "settings.restarting": "Đang khởi động lại...", + "settings.reveal_config": "Hiện cấu hình", + "settings.reveal_config_failed": "Hiện cấu hình workspace thất bại.", + "settings.reveal_config_requires_desktop": "Hiện cấu hình yêu cầu ứng dụng desktop", + "settings.revealed_workspace_config": "Đã hiện cấu hình workspace.", + "settings.run_sandbox_probe": "Đang chạy kiểm tra...", + "settings.running_probe": "Đang chạy kiểm tra...", + "settings.runtime_applies_hint": "Áp dụng lần tiếp theo engine khởi động hoặc tải lại.", + "settings.runtime_debug_desc": "Bản chụp chẩn đoán dễ đọc với xuất một cú nhấp.", + "settings.runtime_debug_title": "Báo cáo gỡ lỗi runtime", + "settings.runtime_desc": "Trạng thái engine nội bộ và máy chủ OpenWork.", + "settings.runtime_direct": "Trực tiếp (OpenCode)", + "settings.runtime_title": "Runtime", + "settings.sandbox_error": "Lỗi", + "settings.sandbox_export_hint": "Dùng Xuất trong Báo cáo gỡ lỗi runtime ở trên để", + "settings.sandbox_probe_desc": "Chạy kiểm tra khởi động sandbox Docker tạm thời và", + "settings.sandbox_probe_errors": "Kiểm tra sandbox hoàn tất có lỗi.", + "settings.sandbox_probe_failed": "Kiểm tra sandbox thất bại.", + "settings.sandbox_probe_success": "Kiểm tra sandbox thành công. Xuất báo cáo gỡ lỗi để hỗ trợ.", + "settings.sandbox_probe_title": "Kiểm tra sandbox", + "settings.sandbox_ready": "Sẵn sàng", + "settings.sandbox_requires_desktop": "Kiểm tra sandbox yêu cầu ứng dụng desktop", + "settings.sandbox_result": "Kết quả: {status}", + "settings.sandbox_run_id": "ID chạy: {id}", + "settings.sandbox_stop_runs_hint": "Dừng task đang chạy trước khi kiểm tra", + "settings.search_models": "Tìm model…", + "settings.select_binary": "Chọn file nhị phân OpenCode", + "settings.select_workspace_first": "Chọn workspace nội bộ trước khi hiện cấu hình.", + "settings.send_feedback": "Gửi phản hồi", + "settings.service_restarts_desc": "Khởi động lại dịch vụ máy chủ cụ thể mà không cần rời", + "settings.service_restarts_title": "Khởi động lại dịch vụ", + "settings.session_model": "Model", + "settings.show_model_reasoning": "Hiển thị suy luận model", + "settings.show_model_reasoning_desc": "Mở rộng dấu vết suy luận trong giao diện khi model cung cấp.", + "settings.showing_models": "Hiển thị {count} / {total}", + "settings.sidecar_config_unavailable": "Cấu hình sidecar không khả dụng", + "settings.startup": "Khởi động", + "settings.startup_local": "Khởi động máy chủ nội bộ", + "settings.startup_not_set": "Kết nối máy chủ", + "settings.startup_remote_warning": "Tùy chọn khởi động hiện là từ xa. Cài đặt engine", + "settings.startup_reset_hint": "Xóa tùy chọn đã lưu và hiện lựa chọn kết nối", + "settings.startup_server": "Kết nối máy chủ", + "settings.startup_title": "Khởi động", + "settings.stop_local_server": "Dừng máy chủ nội bộ", + "settings.stop_runs_before_cleanup": "Dừng task đang chạy trước khi dọn dẹp", + "settings.stop_runs_before_reset_config": "Dừng task đang chạy trước khi đặt lại cấu hình", + "settings.stop_runs_to_reset": "Dừng task đang chạy để đặt lại", + "settings.switch": "Chuyển", + "settings.tab_advanced": "Nâng cao", + "settings.tab_appearance": "Giao diện", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "Gỡ lỗi", + "settings.tab_description_advanced": "Kiểm tra trạng thái runtime, kết nối và điều khiển dành cho nhà phát triển.", + "settings.tab_description_appearance": "Điều chỉnh giao diện OpenWork trên desktop, giao diện hệ thống và chrome ứng dụng.", + "settings.tab_description_debug": "Xem chẩn đoán runtime, nhật ký và tiện ích gỡ lỗi cấp thấp.", + "settings.tab_description_den": "Quản lý kết nối OpenWork Cloud, worker được lưu trữ và quyền truy cập workspace.", + "settings.tab_description_environment": "Save API keys and tokens for local agents, skills, and MCP servers. Secrets stay on this device.", + "settings.tab_description_extensions": "Quản lý ứng dụng MCP và plugins OpenCode cho workspace này.", + "settings.tab_description_general": "Kết nối provider, chọn model mặc định, cấp quyền thư mục và kiểm soát workspace OpenWork cùng kết nối runtime.", + "settings.tab_description_messaging": "Cấu hình danh tính router và hành vi hộp đến từ cài đặt workspace.", + "settings.tab_description_model": "Tinh chỉnh model mặc định, hành vi runtime và cài đặt đầu ra trợ lý.", + "settings.tab_description_recovery": "Sửa trạng thái di chuyển, đặt lại mặc định workspace và khôi phục cài đặt cục bộ.", + "settings.tab_description_skills": "Duyệt, chỉnh sửa và cài đặt skills mà không cần rời cài đặt.", + "settings.tab_description_updates": "Giữ ứng dụng luôn cập nhật với kiểm tra nền im lặng và điều khiển cài đặt.", + "settings.tab_environment": "Environment", + "settings.tab_extensions": "Tiện ích mở rộng", + "settings.tab_general": "Cài đặt", + "settings.tab_messaging": "Nhắn tin", + "settings.tab_model": "Model", + "settings.tab_recovery": "Khôi phục", + "settings.tab_skills": "Skills", + "settings.tab_updates": "Cập nhật", + "settings.theme_dark": "Tối", + "settings.theme_light": "Sáng", + "settings.theme_system": "Hệ thống", + "settings.theme_system_hint": "Chế độ hệ thống tự động theo tùy chọn hệ điều hành.", + "settings.toolbar_ready_to_install": "Sẵn sàng cài đặt", + "settings.update": "Cập nhật", + "settings.update_available": "Có bản cập nhật: v", + "settings.update_available_version": "Có bản cập nhật: v{version}", + "settings.update_check_button": "Kiểm tra", + "settings.update_check_failed": "Kiểm tra cập nhật thất bại", + "settings.update_checking": "Đang kiểm tra...", + "settings.update_download_button": "Tải xuống", + "settings.update_downloading": "Đang tải xuống...", + "settings.update_error": "Kiểm tra cập nhật thất bại", + "settings.update_install_button": "Cài đặt & Khởi động lại", + "settings.update_last_checked": "Kiểm tra lần cuối {time}", + "settings.update_published": "Phát hành {date}", + "settings.update_ready": "Sẵn sàng cài đặt: v", + "settings.update_ready_version": "Sẵn sàng cài đặt: v{version}", + "settings.update_uptodate": "Đã cập nhật mới nhất", + "settings.updates": "Cập nhật", + "settings.updates_desc": "Giữ OpenWork luôn cập nhật.", + "settings.updates_desktop_only": "Cập nhật chỉ khả dụng trong ứng dụng desktop.", + "settings.updates_not_supported": "Cập nhật không được hỗ trợ trong môi trường này.", + "settings.updates_title": "Cập nhật", + "settings.version": "Phiên bản", + "settings.versions_desc": "Thông tin build sidecar + desktop.", + "settings.versions_title": "Phiên bản", + "settings.window_appearance_desc": "Tùy chỉnh giao diện cửa sổ.", + "settings.worker_id_label": "Worker {id}", + "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "Cấu hình workspace", + "settings.workspace_debug_events_label": "Sự kiện gỡ lỗi workspace", + "settings.workspace_fallback_name": "Workspace", + "share.active_cloud_org": "Tổ chức Cloud đang hoạt động", + "share.back_hint": "Quay lại tùy chọn chia sẻ", + "share.chooser_subtitle": "Chọn cách bạn muốn chia sẻ workspace này.", + "share.close_hint": "Đóng", + "share.cloud_signin_note": "OpenWork Cloud mở trong trình duyệt và quay lại đây sau khi đăng nhập.", + "share.collaborator_hint": "Truy cập thông thường không cần phê duyệt quyền.", + "share.connect_messaging_desc": "Dùng workspace này từ Slack, Telegram và các ứng dụng khác.", + "share.connect_messaging_title": "Kết nối nhắn tin", + "share.connection_details_label": "Chi tiết kết nối", + "share.copy_hint": "Sao chép", + "share.copy_link_hint": "Sao chép liên kết", + "share.create_template_link": "Tạo liên kết mẫu", + "share.credentials_disabled_hint": "Bật truy cập từ xa và nhấn Lưu để khởi động lại worker và hiện chi tiết kết nối trực tiếp.", + "share.field_password": "Mật khẩu", + "share.field_worker_url": "URL Worker", + "share.hide_password": "Ẩn mật khẩu", + "share.included_in_template": "Bao gồm trong mẫu này", + "share.option_access_desc": "Hiện chi tiết kết nối trực tiếp để truy cập workspace này từ máy khác.", + "share.option_access_title": "Truy cập workspace từ xa", + "share.option_public_desc": "Tạo liên kết chia sẻ để mọi người bắt đầu từ mẫu này.", + "share.option_public_title": "Mẫu công khai", + "share.option_team_title": "Chia sẻ với nhóm", + "share.option_template_desc": "Đóng gói thiết lập để người khác có thể bắt đầu từ cùng môi trường.", + "share.optional_collaborator": "Quyền cộng tác tùy chọn", + "share.public_intro": "Chia sẻ workspace này dưới dạng liên kết mẫu công khai.", + "share.publishing": "Đang xuất bản...", + "share.regenerate_link": "Tạo lại liên kết", + "share.remote_access_desc": "Tắt theo mặc định. Chỉ bật khi bạn muốn worker này có thể truy cập từ máy khác.", + "share.remote_access_disabled": "Truy cập từ xa hiện đang tắt.", + "share.remote_access_enabled": "Truy cập từ xa hiện đang bật.", + "share.remote_access_title": "Truy cập từ xa", + "share.remote_save": "Lưu", + "share.remote_save_busy": "Đang lưu...", + "share.reveal_password": "Hiện mật khẩu", + "share.save_to_team": "Lưu cho nhóm", + "share.saving": "Đang lưu...", + "share.setup": "Thiết lập", + "share.sign_in_to_share": "Đăng nhập để chia sẻ với nhóm", + "share.subtitle_access": "Hiện chi tiết kết nối trực tiếp để truy cập workspace này từ máy khác.", + "share.team_intro": "Lưu mẫu này vào tổ chức OpenWork Cloud để đồng đội có thể mở sau từ cài đặt Cloud.", + "share.template_intro": "Chia sẻ thiết lập tái sử dụng mà không cấp quyền truy cập trực tiếp workspace đang chạy.", + "share.template_item_config": "Commands và cấu hình", + "share.template_item_config_desc": "Commands tái sử dụng cùng cấu hình OpenWork/OpenCode.", + "share.template_item_settings": "Cài đặt workspace", + "share.template_item_settings_desc": "Hồ sơ workspace chia sẻ và hành vi mặc định.", + "share.template_item_skills": "Skills đi kèm", + "share.template_item_skills_desc": "Skills tùy chỉnh được lưu trong workspace này.", + "share.template_name_label": "Tên mẫu", + "share.title": "Chia sẻ workspace", + "share.view_access": "Truy cập workspace từ xa", + "share.warning_basic": "Chỉ chia sẻ với người tin cậy. Thông tin đăng nhập này cấp quyền truy cập trực tiếp workspace.", + "share.warning_full": "Thông tin đăng nhập này cấp quyền truy cập trực tiếp workspace. Chia sẻ workspace từ xa có thể cho phép bất kỳ ai có quyền truy cập mạng điều khiển worker của bạn.", + "share.workspace_fallback": "Workspace", + "share.workspace_template_desc": "Chia sẻ thiết lập cốt lõi và cài đặt mặc định của workspace.", + "share.workspace_template_title": "Mẫu workspace", + "share_skill_destination.add_to_workspace": "Thêm vào workspace", + "share_skill_destination.adding": "Đang thêm...", + "share_skill_destination.confirm_busy": "Đang thêm skill...", + "share_skill_destination.confirm_button": "Thêm skill vào workspace", + "share_skill_destination.connect_remote": "Kết nối workspace từ xa", + "share_skill_destination.connect_remote_desc": "Gắn máy chủ OpenWork, rồi chọn từ danh sách để nhập skill này.", + "share_skill_destination.connect_remote_hint": "Kết nối workspace từ xa để nhập skill này.", + "share_skill_destination.create_worker": "Tạo workspace mới", + "share_skill_destination.create_worker_desc": "Mở luồng thiết lập workspace, rồi thêm skill này sau khi workspace mới sẵn sàng.", + "share_skill_destination.create_worker_hint": "Tạo workspace mới để cài skill này.", + "share_skill_destination.current_badge": "Hiện tại", + "share_skill_destination.existing_workers": "Workspace hiện có", + "share_skill_destination.fallback_skill_name": "Skill chia sẻ", + "share_skill_destination.footer_idle": "Chọn workspace để tiếp tục.", + "share_skill_destination.footer_selected": "Workspace đã chọn:", + "share_skill_destination.local_badge": "Nội bộ", + "share_skill_destination.more_options": "Thêm tùy chọn", + "share_skill_destination.new_destination": "Đích mới", + "share_skill_destination.no_workers": "Chưa có workspace sẵn sàng. Tạo workspace hoặc kết nối workspace từ xa để cài skill này.", + "share_skill_destination.remote_badge": "Từ xa", + "share_skill_destination.sandbox_badge": "Sandbox", + "share_skill_destination.selected_badge": "Đã chọn", + "share_skill_destination.selected_hint": "Đã chọn. Xem lại đích bên dưới, rồi xác nhận.", + "share_skill_destination.skill_label": "Skill chia sẻ", + "share_skill_destination.subtitle": "Chọn workspace hiện có hoặc tạo mới trước khi nhập skill chia sẻ này.", + "share_skill_destination.title": "Skill này nên đặt ở đâu?", + "share_skill_destination.trigger_label": "Kích hoạt", + "sidebar.active": "Đang hoạt động", + "sidebar.add_workspace": "Thêm workspace mới", + "sidebar.collapse": "Thu gọn", + "sidebar.connect_remote": "Kết nối từ xa", + "sidebar.delete_session": "Xóa phiên", + "sidebar.drag_reorder": "Kéo để sắp xếp", + "sidebar.edit_connection": "Chỉnh sửa kết nối", + "sidebar.expand": "Mở rộng", + "sidebar.import_config": "Nhập cấu hình", + "sidebar.needs_attention": "Cần chú ý", + "sidebar.new_worker": "Worker mới", + "sidebar.no_workspaces": "Chưa có workspace trong phiên này. Thêm một workspace để bắt đầu.", + "sidebar.progress": "Tiến trình", + "sidebar.show_fewer": "Hiện ít hơn", + "sidebar.show_more": "Hiện thêm {count}", + "sidebar.stop_sandbox": "Dừng sandbox", + "sidebar.switch": "Chuyển", + "sidebar.test_connection": "Kiểm tra kết nối", + "skills.add_custom_repo": "Thêm repo GitHub tùy chỉnh", + "skills.add_git_repo": "Thêm repo git", + "skills.add_openwork_hub": "Thêm OpenWork Hub", + "skills.available_from_hub": "Có sẵn từ Hub", + "skills.catalog_search_placeholder": "Tìm skills đã cài, nhóm và hub", + "skills.cloud_add_skill": "Thêm skill", + "skills.cloud_choose_org_detail": "Dùng bảng Cloud để chọn tổ chức, rồi làm mới danh sách này.", + "skills.cloud_choose_org_hint": "Chọn tổ chức trong Cài đặt → Cloud để tải skills nhóm.", + "skills.cloud_footer_label": "Nhóm", + "skills.cloud_hub_label": "Hub: {name}", + "skills.cloud_install_need_server": "Kết nối máy chủ OpenWork có quyền ghi skills để cài skills nhóm lên worker này.", + "skills.cloud_installed": "Đã cài {name} lên worker này.", + "skills.cloud_installing": "Đang cài {title}…", + "skills.cloud_installing_short": "Đang cài", + "skills.cloud_no_search_matches": "Không có skill phù hợp.", + "skills.cloud_org_empty": "Chưa có skills tổ chức.", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "Tải skills tổ chức thất bại.", + "skills.cloud_refresh": "Làm mới skills nhóm", + "skills.cloud_section_subtitle": "Skills được chia sẻ với bạn qua OpenWork Cloud — bao gồm skill hub nhóm mà bạn có quyền truy cập.", + "skills.cloud_section_title": "Từ tổ chức của bạn", + "skills.cloud_shared_org": "Tổ chức", + "skills.cloud_shared_public": "Công khai", + "skills.cloud_sign_in": "Đăng nhập Cloud", + "skills.cloud_sign_in_hint": "Đăng nhập OpenWork Cloud để duyệt skills nhóm và tổ chức.", + "skills.copy_link_failed": "Sao chép liên kết thất bại", + "skills.create_in_chat": "Tạo skill trong chat", + "skills.desktop_required": "Quản lý skills yêu cầu ứng dụng desktop.", + "skills.enter_plugin_name": "Nhập tên gói plugin.", + "skills.failed_load_active": "Tải plugins đang hoạt động thất bại.", + "skills.failed_load_opencode": "Tải opencode.json thất bại", + "skills.failed_parse_opencode": "Phân tích opencode.json thất bại", + "skills.failed_to_load": "Tải skills thất bại", + "skills.failed_update_opencode": "Cập nhật opencode.json thất bại", + "skills.filter_all": "Tất cả", + "skills.filter_cloud": "Nhóm", + "skills.filter_hub": "Hub", + "skills.filter_installed": "Đã cài", + "skills.from_repo": "Từ {owner}/{repo}", + "skills.github_repo_hint": "Nhập repo GitHub theo định dạng owner/repo.", + "skills.host_mode_only": "Chỉ workspace nội bộ", + "skills.host_only_error": "Quản lý skills yêu cầu workspace nội bộ hoặc máy chủ OpenWork đã kết nối.", + "skills.hub_desc": "Duyệt skills chia sẻ từ hub dựa trên GitHub và thêm vào worker này.", + "skills.hub_label": "Hub", + "skills.import": "Nhập", + "skills.import_failed": "Nhập thất bại ({status})", + "skills.import_local": "Nhập skill từ máy", + "skills.import_local_hint": "Sao chép thư mục skill có sẵn vào workspace này.", + "skills.import_local_skill": "Nhập skill từ máy", + "skills.imported": "Đã nhập.", + "skills.install": "Cài đặt", + "skills.install_failed": "Cài đặt skill thất bại.", + "skills.install_name_title": "Cài đặt {name}", + "skills.install_skill_creator": "Cài skill creator", + "skills.install_skill_creator_hint": "Skill này cho phép bạn tạo skills khác ngay trong cuộc hội thoại.", + "skills.installed": "Skills đã cài", + "skills.installed_desc": "Skills đã cài nằm trên worker này và có thể chỉnh sửa hoặc chia sẻ.", + "skills.installed_label": "Đã cài", + "skills.installed_status": "Đã cài", + "skills.installing": "Thêm skill", + "skills.installing_prefix": "Đang cài {name}…", + "skills.installing_skill_creator": "Đang cài skill creator...", + "skills.link_copied": "Đã sao chép liên kết", + "skills.loading": "Đang tải…", + "skills.no_description": "Chưa có mô tả.", + "skills.no_hub_repo_label": "Chưa chọn repo hub", + "skills.no_hub_repo_selected": "Không có skills hub khả dụng.", + "skills.no_hub_skills": "Chưa chọn repo hub. Thêm repo GitHub để duyệt skills.", + "skills.no_opencode_found": "Chưa tìm thấy opencode.json. Thêm plugin để tạo một file.", + "skills.no_opencode_workspace": "Chưa có opencode.json trong workspace này.", + "skills.no_skills": "Không phát hiện skills trong `.opencode/skills`, `.claude/skills`, hoặc `~/.agents/skills`.", + "skills.no_skills_found": "Chưa tìm thấy skills nào.", + "skills.owner_label": "Chủ sở hữu", + "skills.owner_repo_required": "Chủ sở hữu và repo là bắt buộc.", + "skills.pick_project_first": "Vui lòng chọn thư mục dự án trước.", + "skills.pick_project_for_active": "Chọn thư mục dự án để tải plugins đang hoạt động.", + "skills.pick_project_for_plugins": "Chọn thư mục dự án để quản lý plugins.", + "skills.pick_workspace_first": "Vui lòng chọn thư mục workspace trước.", + "skills.plugin_already_listed": "Plugin đã có trong opencode.json.", + "skills.plugin_management_host_only": "Quản lý plugins yêu cầu ứng dụng desktop.", + "skills.plugins_host_only": "Plugins chỉ khả dụng trong ứng dụng desktop.", + "skills.ref_label": "Ref (branch/tag/commit)", + "skills.refresh": "Làm mới", + "skills.refresh_hub": "Làm mới hub", + "skills.refresh_hub_title": "Làm mới danh mục hub", + "skills.remove_saved_repo": "Xóa repo đã lưu", + "skills.repo_label": "Repo", + "skills.reveal_failed": "Không thể mở thư mục skills.", + "skills.reveal_folder": "Mở thư mục skills", + "skills.reveal_folder_hint": "Mở thư mục skills trong Finder.", + "skills.save_and_load": "Lưu và tải", + "skills.save_failed": "Lưu skill thất bại.", + "skills.select_skill_folder": "Chọn thư mục skill", + "skills.share_back": "Quay lại", + "skills.share_chooser_subtitle": "Lưu vào tổ chức OpenWork Cloud hoặc tạo liên kết cài đặt công khai.", + "skills.share_close": "Đóng", + "skills.share_copy_link": "Sao chép", + "skills.share_done": "Xong", + "skills.share_option_public_desc": "Tạo liên kết để mọi người cài skill này.", + "skills.share_option_public_title": "Liên kết công khai", + "skills.share_option_team_desc": "Thêm skill này vào tổ chức OpenWork Cloud đang chọn.", + "skills.share_option_team_title": "Chia sẻ với nhóm", + "skills.share_public_create": "Tạo liên kết", + "skills.share_public_creating": "Đang xuất bản…", + "skills.share_public_intro": "Tạo liên kết công khai. Ai có URL đều có thể cài skill.", + "skills.share_public_regenerate": "Tạo lại liên kết", + "skills.share_publisher_label": "Nhà phát hành", + "skills.share_subtitle_public": "Ai có liên kết cũng có thể cài skill này.", + "skills.share_subtitle_team": "Lưu trong tổ chức để đồng đội dùng.", + "skills.share_team_choose_org": "Chọn tổ chức trong Cài đặt → Cloud trước khi chia sẻ.", + "skills.share_team_hub_label": "Thêm vào skill hub (tùy chọn)", + "skills.share_team_hub_none": "Chỉ tổ chức — không gắn hub", + "skills.share_team_hubs_loading": "Đang tải hub…", + "skills.share_team_intro": "Lưu vào tổ chức đang chọn để đồng đội cài từ Cloud.", + "skills.share_team_org_fallback": "Tổ chức Cloud hiện tại", + "skills.share_team_save": "Lưu cho nhóm", + "skills.share_team_saving": "Đang lưu…", + "skills.share_team_sign_in": "Đăng nhập để chia sẻ với nhóm", + "skills.share_team_sign_in_hint": "OpenWork Cloud mở trong trình duyệt. Đăng nhập rồi quay lại đây.", + "skills.share_team_success": "Đã lưu vào {org}. Đồng đội có thể cài từ skill của tổ chức.", + "skills.share_title": "Chia sẻ skill", + "skills.shown_count": "{count} hiển thị", + "skills.skill_creator_already_installed": "Skill creator đã được cài rồi.", + "skills.skill_creator_installed": "Đã cài skill creator.", + "skills.skill_load_failed": "Tải skill thất bại.", + "skills.source_label": "Nguồn", + "skills.subtitle": "Quản lý skills cho workspace này.", + "skills.title": "Skills", + "skills.trigger_label": "Kích hoạt: {trigger}", + "skills.uninstall": "Gỡ cài đặt", + "skills.uninstall_failed": "Gỡ skill thất bại.", + "skills.uninstall_title": "Gỡ skill này?", + "skills.uninstall_warning": "Thao tác này sẽ xóa vĩnh viễn skill `{name}` khỏi workspace của bạn.", + "skills.uninstalled": "Đã gỡ skill.", + "skills.unknown_error": "Lỗi không xác định", + "skills.worker_profile_desc": "Skills là khả năng cốt lõi của worker này. Khám phá từ Hub, quản lý những gì đã cài và tạo mới trực tiếp trong chat.", + "status.back": "Quay lại màn hình trước", + "status.connected": "Đã kết nối", + "status.connecting": "Đang kết nối", + "status.creating_task": "Đang tạo task mới", + "status.creating_workspace": "Đang tạo workspace", + "status.developer_mode": "Chế độ nhà phát triển", + "status.disconnected": "Đã ngắt kết nối", + "status.disconnected_hint": "Mở cài đặt để kết nối lại", + "status.disconnected_label": "Đã ngắt kết nối", + "status.disconnecting": "Đang ngắt kết nối", + "status.docs": "Tài liệu", + "status.feedback": "Phản hồi", + "status.idle": "Rảnh", + "status.installing_opencode": "Đang cài đặt OpenCode", + "status.limited_hint": "Kết nối lại để khôi phục đầy đủ tính năng OpenWork", + "status.limited_mcp_hint": "{count} MCP đã kết nối · kết nối lại để có đầy đủ tính năng", + "status.limited_mode": "Chế độ giới hạn", + "status.live": "Trực tiếp", + "status.loading_session": "Đang tải phiên", + "status.mcp_connected": "{count} MCP đã kết nối", + "status.open_docs": "Mở tài liệu", + "status.openwork_ready": "OpenWork sẵn sàng", + "status.providers_connected": "{count} provider{plural} đã kết nối", + "status.ready_for_tasks": "Sẵn sàng cho task mới", + "status.reloading_engine": "Đang tải lại engine", + "status.restarting_engine": "Đang khởi động lại engine", + "status.running": "Đang chạy", + "status.send_feedback": "Gửi phản hồi", + "status.settings": "Cài đặt", + "status.starting_engine": "Đang khởi động engine", + "system.cache_repair_requires_desktop": "Sửa cache cần ứng dụng desktop.", + "system.docker_cleanup_requires_desktop": "Dọn dẹp Docker cần ứng dụng desktop.", + "system.reload_body_agents": "OpenCode tải agent khi khởi động. Tải lại engine để agent cập nhật có sẵn.", + "system.reload_body_commands": "OpenCode tải command khi khởi động. Tải lại engine để command cập nhật có sẵn.", + "system.reload_body_config": "OpenCode đọc opencode.json khi khởi động. Tải lại engine để áp dụng thay đổi cấu hình.", + "system.reload_body_default": "OpenWork phát hiện thay đổi cần tải lại OpenCode.", + "system.reload_body_mcp": "OpenCode tải MCP server khi khởi động. Tải lại engine để kích hoạt kết nối mới.", + "system.reload_body_mixed": "OpenWork phát hiện thay đổi cấu hình OpenCode. Tải lại engine để áp dụng.", + "system.reload_body_plugins": "OpenCode tải plugin npm khi khởi động. Tải lại engine để áp dụng thay đổi opencode.json.", + "system.reload_body_skills": "OpenCode có thể cache trạng thái skill. Tải lại engine để skill mới có sẵn.", + "system.reload_failed": "Không thể tải lại engine.", + "system.reload_required": "Cần tải lại", + "system.reload_unavailable": "Không thể tải lại cho worker này.", + "system.stop_active_runs_before_reset": "Dừng các lượt chạy đang hoạt động trước khi đặt lại.", + "system.stop_runs_before_update": "Dừng các lượt chạy đang hoạt động trước khi cài đặt bản cập nhật.", + "system.updates_not_supported": "Cập nhật không được hỗ trợ trong môi trường này.", + "time.hours_ago": "{count} giờ trước", + "time.just_now": "vừa xong", + "time.minutes_ago": "{count} phút trước", + "time.seconds_ago": "{count} giây trước", + "workspace.loading_tasks": "Đang tải task...", + "workspace.local_badge": "Nội bộ", + "workspace.new_task_inline": "+ Task mới", + "workspace.no_tasks": "Chưa có task.", + "workspace.remote_badge": "Từ xa", + "workspace.rename_description": "Cập nhật tên hiển thị trên thanh bên.", + "workspace.rename_label": "Tên workspace", + "workspace.rename_placeholder": "Workspace nhóm thiết kế", + "workspace.rename_title": "Đổi tên workspace", + "workspace.sandbox_badge": "Sandbox", + "workspace.selected": "Đã chọn", + "workspace.switch": "Chuyển", + "workspace.switching_status_connecting": "Đang kiểm tra kết nối", + "workspace.switching_status_loading": "Đang tải task gần đây", + "workspace.switching_status_preparing": "Đang chuẩn bị", + "workspace.switching_subtitle": "Chúng tôi sẽ đưa bạn về công việc gần đây.", + "workspace.switching_title": "Đang mở {name}", + "workspace.switching_title_unknown": "Đang mở workspace", + "workspace_list.add_workspace": "Thêm workspace", + "workspace_list.connect_remote": "Kết nối workspace từ xa", + "workspace_list.connecting": "Đang kết nối...", + "workspace_list.delete_session": "Xóa phiên", + "workspace_list.desktop_only_hint": "Tạo workspace nội bộ trong ứng dụng desktop.", + "workspace_list.edit_connection": "Chỉnh sửa kết nối", + "workspace_list.edit_name": "Chỉnh sửa tên", + "workspace_list.hide_child_sessions": "Ẩn phiên con", + "workspace_list.import_config": "Nhập cấu hình", + "workspace_list.new_workspace": "Workspace mới", + "workspace_list.recover": "Khôi phục", + "workspace_list.remove_workspace": "Xóa workspace", + "workspace_list.rename_session": "Đổi tên phiên", + "workspace_list.reveal_explorer": "Hiện trong Explorer", + "workspace_list.reveal_finder": "Hiện trong Finder", + "workspace_list.session_actions": "Thao tác phiên", + "workspace_list.share": "Chia sẻ...", + "workspace_list.show_child_sessions": "Hiện phiên con", + "workspace_list.show_more": "Hiện thêm {count}", + "workspace_list.show_more_fallback": "Hiện thêm", + "workspace_list.test_connection": "Kiểm tra kết nối", + "workspace_list.workspace_fallback": "Workspace", + "workspace_list.workspace_options": "Tùy chọn workspace", + "workspace_sidebar.close_sidebar": "Đóng thanh bên", + "workspace_sidebar.collapse_sidebar": "Thu gọn thanh bên", + "workspace_sidebar.configuration": "Cấu hình", + "workspace_sidebar.expand_sidebar": "Mở rộng thanh bên", + "workspace_sidebar.extensions": "Tiện ích mở rộng", + "workspace_sidebar.messaging": "Nhắn tin", +} as const; diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts new file mode 100644 index 0000000000..3418fb5640 --- /dev/null +++ b/apps/app/src/i18n/locales/zh.ts @@ -0,0 +1,1992 @@ +/** + * 中文(简体)翻译 + * 产品名称保留英文:OpenCode、OpenPackage、OpenWork + * Skills:标题用"Skills(技能)",正文用"skills" + * MCP:协议名称保留英文,不翻译为"应用" + * 翻译的术语:命令(Commands)、插件(Plugins)、会话(Sessions)、应用(Apps) + */ + +export default { + "app.compact_command_desc": "压缩此会话以减少上下文大小。", + "app.connection_lost": "服务器连接已断开。请重新加载。", + "app.deep_link_auth_queued": "已排队处理Cloud认证深层链接。", + "app.deep_link_remote_queued": "已排队处理远程工作区链接。OpenWork将进入连接流程。", + "app.error.choose_folder": "选择一个文件夹以继续。", + "app.error.host_requires_local": "请先选择本地工作区以启动引擎。", + "app.error.install_failed": "OpenCode安装失败。请查看上方日志。", + "app.error.pick_workspace_folder": "请先选择一个工作区文件夹。", + "app.error.remote_base_url_required": "请先填写服务器地址。", + "app.error.tauri_required": "此操作需要OpenWork桌面应用运行时。", + "app.error_audit_load": "加载审计日志失败。", + "app.error_auth_failed": "认证失败", + "app.error_auto_compact_scope": "自动上下文压缩仅适用于本地工作区或可写的OpenWork服务器工作区。", + "app.error_cloud_signin": "完成OpenWork Cloud登录失败。", + "app.error_command_not_resolved": "命令未解析。", + "app.error_compact_empty": "暂无可压缩的内容。", + "app.error_compact_no_session": "请先选择一个有消息的会话再运行/compact。", + "app.error_compact_no_session_id": "请先选择一个会话再压缩。", + "app.error_connect_first": "请先连接到此工作区再应用运行时更改。", + "app.error_connection_failed": "连接失败", + "app.error_connection_failed_url": "连接失败。请检查URL和令牌。", + "app.error_deep_link_unrecognized": "该链接不是有效的OpenWork深层链接或分享URL。", + "app.error_desktop_signin": "桌面登录已完成,但OpenWork Cloud未返回会话令牌。", + "app.error_not_connected": "未连接到服务器", + "app.error_pick_local_folder": "请先选择本地工作区文件夹再重启本地服务器。", + "app.error_rate_limit": "请求频率超限", + "app.error_remote_access": "更新远程访问失败。", + "app.error_request_failed": "请求失败", + "app.error_reset_config": "重置应用配置默认值失败。", + "app.error_restart_local_worker": "使用更新的共享设置重启本地工作区失败。", + "app.error_runtime_changes": "应用运行时更改失败。", + "app.error_session_name_required": "会话名称为必填项", + "app.error_update_opencode_json": "更新opencode.json失败", + "app.import_bundle_desc": "选择导入方式。", + "app.import_shared_bundle": "导入共享包", + "app.local_disabled_reason": "本地工作区需在桌面应用中创建。远程和共享工作区仍可正常使用。", + "app.local_worker_detail": "本地工作区", + "app.model_behavior_desc": "先选择模型以查看提供商特定的行为控制。", + "app.model_behavior_title": "模型行为", + "app.plugins_hint_disconnected": "OpenWork服务器不可用。插件为只读模式。", + "app.plugins_hint_limited": "OpenWork服务器需要令牌才能编辑插件。", + "app.plugins_hint_readonly": "OpenWork服务器对插件为只读模式。", + "app.reload_later": "稍后", + "app.reload_now": "立即重新加载", + "app.reload_stop_tasks": "重新加载并停止任务", + "app.remote_worker_detail": "远程工作区", + "app.reset_config_ok": "已重置应用配置默认值。如有残留设置请重启OpenWork。", + "app.shared_setup": "共享配置", + "app.skill_added": "Skill已添加", + "app.skills_hint_disconnected": "OpenWork服务器不可用。请在高级设置中添加服务器URL/令牌以管理skills。", + "app.skills_hint_limited": "OpenWork服务器需要主机令牌才能安装/更新skills。请在高级设置中添加并重新连接。", + "app.skills_hint_readonly": "OpenWork服务器对skills为只读模式。请在高级设置中添加主机令牌以启用安装。", + "app.unknown_error": "未知错误", + "app.worker_fallback": "工作区", + "blueprint.automation_body": "从可复用的工作流开始,或在下方输入你的任务。", + "blueprint.automation_title": "你想自动化什么?", + "blueprint.csv_session_assistant": "我可以帮你生成、清洗、合并和汇总CSV文件。你想自动化哪种CSV工作?", + "blueprint.csv_session_title": "CSV工作流创意", + "blueprint.csv_session_user": "我想把多个工具的导出合并成一个整洁的CSV。", + "blueprint.empty_body": "选择一个起点,或直接在下方输入。", + "blueprint.empty_title": "你想做什么?", + "blueprint.minimal_body": "询问关于此工作区的问题,或使用启动提示词。", + "blueprint.minimal_title": "从一个任务开始", + "blueprint.starter_blueprint_desc": "设计一个包含skills、命令和交接步骤的可复用工作流。", + "blueprint.starter_blueprint_prompt": "帮我为此工作区设计一个可复用的自动化蓝图。先问我想标准化什么,然后提出工作流方案。", + "blueprint.starter_blueprint_title": "规划自动化蓝图", + "blueprint.starter_chrome_desc": "立即开始浏览器自动化对话。", + "blueprint.starter_chrome_prompt": "帮我连接Chrome并自动化一个重复性任务。", + "blueprint.starter_chrome_title": "Chrome自动化", + "blueprint.starter_command_desc": "将重复的工作流转化为此工作区的斜杠命令。", + "blueprint.starter_command_prompt": "帮我为此工作区创建一个可复用的/command。先问我想自动化什么工作流,然后起草命令。", + "blueprint.starter_command_title": "创建可复用命令", + "blueprint.starter_connect_openai_desc": "添加OpenAI提供商,让ChatGPT模型在新会话中即可使用。", + "blueprint.starter_connect_openai_title": "连接ChatGPT", + "blueprint.starter_csv_desc": "清洗或生成电子表格数据。", + "blueprint.starter_csv_prompt": "帮我在这台电脑上创建或编辑CSV文件。", + "blueprint.starter_csv_title": "处理CSV", + "blueprint.starter_explore_desc": "汇总文件并建议最适合先处理的任务。", + "blueprint.starter_explore_prompt": "汇总此工作区,指出最重要的文件,并建议最适合先处理的任务。", + "blueprint.starter_explore_title": "探索此工作区", + "blueprint.welcome_message": "你好,欢迎使用OpenWork!\n\n大家用OpenWork在电脑上编写CSV文件、连接Chrome自动化重复任务,以及将联系人同步到Notion。\n\n但唯一的限制是你的想象力。\n\n你想做什么?", + "blueprint.welcome_title": "欢迎使用OpenWork", + "common.add": "添加", + "common.cancel": "取消", + "common.choose": "选择", + "common.close": "关闭", + "common.default_parens": "(默认)", + "common.done": "完成", + "common.edit": "编辑", + "common.hide": "隐藏", + "common.install": "安装", + "common.navigate": "导航", + "common.next": "下一步", + "common.off": "关闭", + "common.on": "开启", + "common.path": "路径", + "common.question": "问题", + "common.refresh": "刷新", + "common.remove": "移除", + "common.reset": "重置", + "common.retry": "重试", + "common.save": "保存", + "common.select": "选择", + "common.show": "显示", + "common.something_went_wrong": "出了点问题", + "common.submit": "提交", + "common.unknown": "未知", + "composer.agent_label": "智能体", + "composer.attach_files": "附加文件", + "composer.attachments_unavailable": "附件功能不可用。", + "composer.behavior_label": "行为", + "composer.configure": "配置", + "composer.default_agent": "默认智能体", + "composer.expand_pasted": "点击展开粘贴的文本", + "composer.failed_read_attachment": "读取附件失败", + "composer.file_exceeds_limit": "{name}超过8MB限制。", + "composer.file_kind": "文件", + "composer.file_too_large_encoding": "{name}编码后过大。请尝试更小的图片。", + "composer.image_kind": "图片", + "composer.inserted_links_unsupported": "已为不支持的文件插入链接。", + "composer.loading_agents": "正在加载智能体…", + "composer.loading_commands": "正在加载命令…", + "composer.mcps_label": "MCP", + "composer.no_commands": "未找到命令。", + "composer.no_matches": "未找到匹配项。", + "composer.placeholder": "描述你的任务…", + "composer.remote_worker_paste_warning": "这是远程工作区。沙箱也是远程的。要共享文件,请上传到侧边栏的共享文件夹。", + "composer.run_task": "运行任务", + "composer.skill_source": "Skill", + "composer.stop": "停止", + "composer.tools_label": "命令、技能和MCP", + "composer.unsupported_attachment_type": "不支持的附件类型。", + "composer.upload_failed_local_links": "无法上传到共享文件夹。已插入本地链接。", + "composer.upload_to_shared_folder": "上传到共享文件夹", + "composer.uploaded_multiple_files": "已上传{count}个文件到共享文件夹并插入链接。", + "composer.uploaded_single_file": "已上传{name}到共享文件夹并插入链接。", + "config.auto_reload_desc": "智能体/skills/命令/配置变更后自动重新加载(仅在空闲时)。", + "config.auto_reload_title": "自动重新加载(本地)", + "config.auto_reload_unavailable": "仅在桌面应用的本地工作区中可用。", + "config.collaborator_token_disabled_hint": "已预存用于远程共享,但远程访问当前已禁用。", + "config.collaborator_token_label": "协作者令牌", + "config.collaborator_token_remote_hint": "手机或笔记本连接此服务器时的日常远程访问。", + "config.connection_failed": "连接失败。", + "config.connection_failed_check": "连接失败。请检查主机URL和令牌。", + "config.connection_status_updated": "连接状态已更新。", + "config.connection_successful": "连接成功。", + "config.copied": "已复制", + "config.copy": "复制", + "config.desktop_only_hint": "部分配置功能(本地服务器共享 + 消息桥接)需要桌面应用。", + "config.diagnostics_desc": "复制脱敏的运行时状态用于调试。", + "config.diagnostics_title": "诊断包", + "config.enable_auto_reload_first": "请先启用自动重新加载", + "config.engine_reload_desc": "重启此工作区的OpenCode服务器。", + "config.engine_reload_title": "引擎重新加载", + "config.host_admin_token_hint": "仅限主机内部使用的令牌,用于审批CLI和管理API。请勿在远程应用连接流程中使用。", + "config.host_admin_token_label": "主机管理员令牌", + "config.host_local_only": "仅限本地", + "config.host_offline": "离线", + "config.host_remote_enabled": "已启用远程", + "config.local_ip_hint": "在同一Wi-Fi下使用本地IP可获得最快连接。", + "config.mdns_hint": ".local名称更易记忆,但可能无法在所有网络上解析。", + "config.messaging_identities_desc": "在身份标签页中管理Telegram/Slack身份和路由。", + "config.messaging_identities_title": "消息身份", + "config.not_set": "未设置", + "config.owner_token_disabled_hint": "仅在启用此工作区的远程访问后才有效。", + "config.owner_token_label": "所有者令牌", + "config.owner_token_remote_hint": "远程客户端需要回答权限提示或执行所有者操作时使用。", + "config.reload_active_tasks_warning": "重新加载将停止活动任务。", + "config.reload_availability_hint": "仅本地工作区或已连接的OpenWork服务器支持重新加载。", + "config.reload_connect_hint": "连接此工作区后才能重新加载。", + "config.reload_engine": "重新加载引擎", + "config.reload_now_desc": "应用配置更新并重新连接会话。", + "config.reload_now_title": "立即重新加载", + "config.reloading": "正在重新加载…", + "config.remote_access_off_hint": "远程访问已关闭。请先通过分享工作区启用远程访问,然后再从其他设备连接。", + "config.resolved_worker_url": "解析后的工作区URL:", + "config.resume_sessions_desc": "如果在任务运行期间排队了重新加载,则在之后发送恢复消息。", + "config.resume_sessions_title": "自动重新加载后恢复会话", + "config.server_needed_hint": "需要连接OpenWork服务器以同步skills、插件和命令。", + "config.server_section_desc": "连接OpenWork服务器。使用URL加服务器管理员提供的协作者或所有者令牌。", + "config.server_section_title": "OpenWork服务器", + "config.server_sharing_desc": "将这些详情分享给受信任的设备。保持服务器在同一网络以获得最快设置。", + "config.server_sharing_menu_hint": "如需每个工作区的分享链接,请使用工作区菜单中的分享…", + "config.server_sharing_title": "OpenWork服务器共享", + "config.server_url_hint": "使用OpenWork服务器提供的URL。本地桌面工作区使用48000-51000范围内的持久高端口。", + "config.server_url_input_label": "OpenWork服务器URL", + "config.server_url_label": "OpenWork服务器URL", + "config.starting_server": "正在启动服务器…", + "config.status_connected": "已连接", + "config.status_limited": "受限", + "config.status_not_connected": "未连接", + "config.test_connection": "测试连接", + "config.testing": "正在测试…", + "config.testing_connection": "正在测试连接…", + "config.token_hint": "可选。粘贴协作者令牌用于日常访问,或在此客户端需要回答权限提示时粘贴所有者令牌。", + "config.token_label": "协作者或所有者令牌", + "config.token_placeholder": "粘贴你的令牌", + "config.unavailable": "不可用", + "config.worker_id": "工作区ID:", + "config.workspace_config_desc": "这些设置影响所选工作区。仅运行时操作适用于当前连接的工作区。", + "config.workspace_config_title": "工作区配置", + "config.workspace_id_prefix": "工作区:", + "context_panel.add_button": "添加", + "context_panel.add_folder_hint": "添加文件夹以允许此工作区读写其根目录以外的文件。", + "context_panel.adding_button": "添加中…", + "context_panel.always_available": "始终可用", + "context_panel.authorized_folders": "已授权文件夹", + "context_panel.authorized_folders_desc": "授予此工作区访问权限,以读取和编辑根目录之外的文件夹。", + "context_panel.authorized_folders_no_access": "连接可写的OpenWork服务器工作区以编辑已授权文件夹。", + "context_panel.browse_button": "浏览", + "context_panel.config_access_unavailable": "此工作区无法访问OpenWork服务器配置。", + "context_panel.config_read_only": "OpenWork服务器对工作区配置为只读连接。", + "context_panel.context": "上下文", + "context_panel.folder_already_authorized": "文件夹已授权。", + "context_panel.folders_updated": "已授权文件夹已更新。", + "context_panel.input_placeholder": "输入要授权的文件夹路径…", + "context_panel.mcp": "MCP", + "context_panel.mcp_connected": "已连接", + "context_panel.mcp_disabled": "已禁用", + "context_panel.mcp_disconnected": "已断开", + "context_panel.mcp_failed": "失败", + "context_panel.mcp_needs_auth": "需要认证", + "context_panel.mcp_register_client": "注册客户端", + "context_panel.no_external_folders": "暂无已授权的外部文件夹", + "context_panel.no_mcp": "未加载MCP服务器。", + "context_panel.no_plugins": "未加载插件。", + "context_panel.no_server_workspace": "未选择活动的服务器工作区。", + "context_panel.no_skills": "未加载skills。", + "context_panel.none_yet": "暂无。", + "context_panel.plugins": "插件", + "context_panel.preserving_entries": "保留{count}条非文件夹权限条目。", + "context_panel.preserving_entry": "保留1条非文件夹权限条目。", + "context_panel.remove_folder": "移除{name}", + "context_panel.saving_folders": "正在保存已授权文件夹…", + "context_panel.server_disconnected": "OpenWork服务器已断开连接。", + "context_panel.skills": "Skills(技能)", + "context_panel.working_files": "工作文件", + "context_panel.workspace_root_available": "工作区根目录已可用。", + "context_panel.workspace_root_badge": "工作区根目录", + "context_panel.writable_workspace_required": "需要可写的OpenWork服务器工作区才能更新已授权文件夹。", + "dashboard.access_token": "访问令牌", + "dashboard.access_token_optional_hint": "仅在工作区需要时添加令牌。", + "dashboard.blueprints_workspace": "蓝图工作区", + "dashboard.blueprints_workspace_desc": "从适合复用skills、命令和共享流程的自动化工作区开始。", + "dashboard.change": "更改", + "dashboard.choose_folder": "选择文件夹", + "dashboard.choose_folder_continue": "选择文件夹以继续。", + "dashboard.choose_folder_next": "与你的工作区共享文件。", + "dashboard.choose_preset": "选择预设", + "dashboard.chooser_local_desc": "在此设备上创建工作区,可选择从团队模板开始。", + "dashboard.chooser_remote_desc": "使用URL和访问令牌连接自托管的OpenWork工作区。", + "dashboard.chooser_shared_desc": "浏览组织共享的云端工作区,一步连接。", + "dashboard.close_settings": "关闭设置", + "dashboard.cloud_signin_button": "使用Cloud继续", + "dashboard.cloud_signin_hint": "访问组织共享的远程工作区。", + "dashboard.cloud_signin_next": "接下来你将选择团队并连接到已有的工作区。", + "dashboard.cloud_signin_title": "登录OpenWork Cloud", + "dashboard.cloud_worker": "云端工作区", + "dashboard.commands": "命令", + "dashboard.connect_remote_button": "连接远程", + "dashboard.connected": "已连接", + "dashboard.connecting": "正在连接…", + "dashboard.create_local_workspace_subtitle": "在此设备上创建工作区,可选择从团队模板开始。", + "dashboard.create_local_workspace_title": "本地工作区", + "dashboard.create_remote_custom_subtitle": "连接自托管的OpenWork工作区。", + "dashboard.create_remote_custom_title": "连接自定义远程", + "dashboard.create_remote_workspace_confirm": "添加工作区", + "dashboard.create_remote_workspace_subtitle": "保存OpenWork服务器为工作区。", + "dashboard.create_remote_workspace_title": "添加远程工作区", + "dashboard.create_sandbox_confirm": "创建为沙箱", + "dashboard.create_shared_subtitle_signed_in": "浏览组织共享的云端工作区,一步连接。", + "dashboard.create_shared_subtitle_signed_out": "登录OpenWork Cloud以访问组织共享的工作区。", + "dashboard.create_shared_title": "共享工作区", + "dashboard.create_workspace_confirm": "创建工作区", + "dashboard.create_workspace_subtitle": "初始化新的基于文件夹的工作区。", + "dashboard.create_workspace_title": "创建工作区", + "dashboard.creating": "正在创建…", + "dashboard.desktop_badge": "桌面版", + "dashboard.display_name_label": "显示名称", + "dashboard.display_name_optional": "(可选)", + "dashboard.docker_debug_details": "Docker调试详情", + "dashboard.edit_remote_workspace_confirm": "保存连接", + "dashboard.edit_remote_workspace_subtitle": "更新此工作区的OpenWork服务器信息。", + "dashboard.edit_remote_workspace_title": "编辑远程连接", + "dashboard.empty_workspace": "空白工作区", + "dashboard.empty_workspace_desc": "从空白文件夹开始,添加你需要的内容。", + "dashboard.error_choose_org": "请先选择组织再打开工作区。", + "dashboard.error_connect_worker": "连接{name}失败。", + "dashboard.error_create_template": "创建{name}失败。", + "dashboard.error_load_orgs": "加载组织失败。", + "dashboard.error_load_shared_workspaces": "加载共享工作区失败。", + "dashboard.error_workspace_not_ready": "工作区尚未就绪。请稍后重试。", + "dashboard.import_config": "导入配置", + "dashboard.importing": "正在导入…", + "dashboard.modal_back": "返回", + "dashboard.modal_close": "关闭添加工作区弹窗", + "dashboard.nav_ids": "IDs", + "dashboard.no_folder_selected": "尚未选择文件夹。", + "dashboard.open_cloud_dashboard": "打开云端控制台", + "dashboard.opening": "正在打开...", + "dashboard.openwork_host_hint": "使用OpenWork服务器提供的地址。", + "dashboard.openwork_host_label": "OpenWork服务器地址", + "dashboard.openwork_host_placeholder": "https://your-server.openwork.app", + "dashboard.openwork_host_token_hint": "可选。日常访问可粘贴协作者令牌;如果这个客户端需要处理权限提示,请粘贴所有者令牌。", + "dashboard.openwork_host_token_label": "协作者或所有者令牌", + "dashboard.openwork_host_token_placeholder": "粘贴你的令牌", + "dashboard.recently_updated": "最近更新", + "dashboard.remote": "远程", + "dashboard.remote_base_url_required": "请先填写服务器地址。", + "dashboard.remote_connection_direct": "直连", + "dashboard.remote_connection_openwork": "OpenWork", + "dashboard.remote_directory_hint": "留空则使用服务器默认目录。", + "dashboard.remote_directory_label": "工作区目录(可选)", + "dashboard.remote_directory_placeholder": "/home/team/project", + "dashboard.remote_display_name_label": "显示名称(可选)", + "dashboard.remote_display_name_placeholder": "设计团队工作区", + "dashboard.remote_server_details_hint": "连接自托管的OpenWork工作区。", + "dashboard.remote_server_details_title": "远程服务器详情", + "dashboard.remote_workspace_hint": "记录OpenWork服务器,随时重新连接。", + "dashboard.remote_workspace_title": "远程工作区", + "dashboard.repair_cache": "修复缓存", + "dashboard.repairing_cache": "正在修复缓存", + "dashboard.sandbox_checking_docker": "正在检查Docker...", + "dashboard.sandbox_get_ready_action": "准备沙箱环境", + "dashboard.sandbox_get_ready_desc": "在隔离的Docker容器中运行此工作区,更安全、更可复现。", + "dashboard.sandbox_get_ready_title": "沙箱需要Docker", + "dashboard.sandbox_hide_logs": "隐藏日志", + "dashboard.sandbox_live_logs": "实时日志", + "dashboard.sandbox_setup": "沙箱配置", + "dashboard.sandbox_show_logs": "显示日志", + "dashboard.search_shared_workspaces": "搜索共享工作区", + "dashboard.select_folder": "选择文件夹", + "dashboard.settings": "设置", + "dashboard.shared_workspaces_loading": "正在加载共享工作区…", + "dashboard.shared_workspaces_no_match": "没有匹配的共享工作区。", + "dashboard.shared_workspaces_none": "暂无可用的共享工作区。", + "dashboard.shared_workspaces_refreshing": "正在刷新工作区…", + "dashboard.skills": "Skills(技能)", + "dashboard.starter_workspace": "启动工作区", + "dashboard.starter_workspace_desc": "预配置以展示如何使用插件、命令和skills。", + "dashboard.unknown_creator": "未知创建者", + "dashboard.worker_status_attention": "需要关注", + "dashboard.worker_status_ready": "就绪", + "dashboard.worker_status_starting": "启动中", + "dashboard.worker_status_stopped": "已停止", + "dashboard.worker_status_unknown": "未知", + "dashboard.worker_url_hint": "粘贴要连接的OpenWork工作区URL。", + "dashboard.worker_url_label": "工作区URL", + "dashboard.workspace_connect": "连接", + "dashboard.workspace_connect_unavailable": "此处无法连接共享工作区。", + "dashboard.workspace_connecting": "连接中", + "dashboard.workspace_folder_hint": "选择此工作区在设备上的位置。", + "dashboard.workspace_folder_title": "工作区文件夹", + "dashboard.workspace_not_ready_title": "此工作区尚未准备就绪。", + "dashboard.workspaces": "工作区", + "den.active_org_hint": "云端工作区和团队模板限于所选组织。", + "den.active_org_title": "当前组织", + "den.auto_reconnect_hint": "在浏览器中完成认证后,OpenWork将自动重新连接。", + "den.checking_session": "正在检查会话", + "den.choose_org_for_providers": "选择一个组织以查看云端提供商。", + "den.choose_org_for_skill_hubs": "选择一个组织以查看云端Skill Hub。", + "den.cloud_account_hint": "管理已连接的账户和组织。", + "den.cloud_account_title": "Cloud账户", + "den.cloud_control_plane_open": "在浏览器中打开", + "den.cloud_control_plane_reset": "重置", + "den.cloud_control_plane_save": "保存URL", + "den.cloud_control_plane_url_hint": "仅限开发者模式。用于连接本地或自托管的Cloud控制平面。更改后将退出登录以重新连接新的控制平面。", + "den.cloud_control_plane_url_label": "Cloud控制平面URL", + "den.cloud_provider_detail": "{count}个模型 · {source}提供商", + "den.cloud_provider_removed_detail": "此导入的提供商不再存在于云端。请卸载本地的{providerId}配置。", + "den.cloud_provider_sync_detail": "云端提供商已变更。将{count}个模型的{source}配置同步到opencode.jsonc。", + "den.cloud_providers_hint": "将托管LLM提供商导入opencode.jsonc,并在此工作区中使用组织凭据。", + "den.cloud_providers_title": "云端提供商", + "den.cloud_section_desc": "登录、选择组织,打开Cloud工作区或团队模板。", + "den.cloud_section_title": "OpenWork Cloud", + "den.cloud_sleep_hint": "登录OpenWork Cloud,即使电脑进入睡眠状态也能保持任务运行。", + "den.cloud_workers_hint": "使用应用内的远程连接流程直接在OpenWork中打开工作区。", + "den.cloud_workers_title": "云端工作区", + "den.create_account": "创建账户", + "den.credentials_ready_badge": "凭据就绪", + "den.error_base_url": "请输入有效的http:// 或https:// Cloud控制平面URL。", + "den.error_choose_org": "请先选择组织再打开工作区。", + "den.error_load_orgs": "加载组织失败。", + "den.error_load_workers": "加载工作区失败。", + "den.error_no_session": "未找到有效的Cloud会话。", + "den.error_no_token": "桌面登录已完成,但OpenWork Cloud未返回会话令牌。", + "den.error_open_worker": "在OpenWork中打开{name}失败。", + "den.error_open_worker_fallback": "打开{name}失败。", + "den.error_paste_valid_code": "请粘贴有效的OpenWork登录链接或一次性登录码。", + "den.error_signin_failed": "完成OpenWork Cloud登录失败。", + "den.error_worker_not_ready": "工作区尚未就绪。请在部署完成后重试。", + "den.finish_signin": "完成登录", + "den.finishing": "正在完成…", + "den.hide_signin_code": "隐藏登录码", + "den.import_all": "全部导入", + "den.import_provider": "导入", + "den.import_provider_failed": "导入{name}失败。", + "den.imported_badge": "已导入", + "den.imported_provider": "已导入{name}。", + "den.importing": "导入中…", + "den.needs_attention": "需要关注", + "den.no_cloud_providers": "此组织暂无可用的云端提供商。", + "den.no_cloud_workers": "此组织暂无可见的云端工作区。请在Cloud中创建后刷新此标签页。", + "den.no_org_selected": "未选择组织", + "den.no_skill_hubs": "此组织暂无可用的云端Skill Hub。", + "den.open": "打开", + "den.opening": "正在打开…", + "den.org_member_suffix": "(成员)", + "den.org_owner_suffix": "(所有者)", + "den.org_switched": "已切换到{name}。", + "den.out_of_sync_badge": "不同步", + "den.paste_signin_code": "粘贴登录码", + "den.refresh": "刷新", + "den.reload_workspace": "重新加载工作区以应用配置变更。", + "den.remove_provider_failed": "移除{name}失败。", + "den.removed_from_cloud_badge": "已从云端移除", + "den.removed_provider": "已移除{name}。", + "den.removing": "移除中…", + "den.sign_out": "退出登录", + "den.signed_out": "已退出", + "den.signin_button": "登录", + "den.signin_code_note": "支持openwork://den-auth链接或一次性授权码。", + "den.signin_link_hint": "如果浏览器未自动跳回OpenWork,请在此粘贴OpenWork Cloud的登录链接或一次性登录码。", + "den.signin_link_label": "登录链接或一次性登录码", + "den.signin_link_placeholder": "openwork://den-auth?...或粘贴登录码", + "den.signin_title": "登录OpenWork Cloud", + "den.signing_in": "正在完成OpenWork Cloud登录…", + "den.signing_out": "正在退出…", + "den.skill_hub_detail": "将{count}个共享Skill导入到.opencode/skills。", + "den.skill_hub_imported_detail": "已将{count}个Skill导入此工作区。", + "den.skill_hub_removed_detail": "此Hub已从云端移除。请卸载此工作区中已导入的{importedCount}个Skill。", + "den.skill_hub_skills_badge": "{count}个Skill", + "den.skill_hub_sync_detail": "云端现有{liveCount}个Skill;此工作区已导入{importedCount}个。同步以更新已安装集。", + "den.skill_hubs_hint": "一键将共享云端Hub中的所有Skill导入到此工作区。", + "den.skill_hubs_title": "Skill Hub", + "den.status_base_url_updated": "已更新Cloud控制平面URL。请重新登录以继续。", + "den.status_browser_signin": "请在浏览器中完成登录以连接OpenWork。", + "den.status_browser_signup": "请在浏览器中完成账户创建以连接OpenWork。", + "den.status_cloud_signed_in_as": "已作为{email}连接OpenWork Cloud。", + "den.status_cloud_signin_done": "已连接OpenWork Cloud。", + "den.status_loaded_orgs": "已加载{count}个组织。", + "den.status_loaded_workers": "已加载{name}的{count}个工作区。", + "den.status_no_workers": "未找到{name}的工作区。", + "den.status_opened_worker": "已在OpenWork中打开{name}。", + "den.status_signed_in_as": "已登录为{email}。", + "den.status_signed_out": "已退出登录并清除此设备上的OpenWork Cloud会话。", + "den.sync": "同步", + "den.sync_provider_failed": "同步{name}失败。", + "den.synced_provider": "已同步{name}。", + "den.syncing": "同步中…", + "den.uninstall": "卸载", + "den.worker_mine_badge": "我的", + "den.worker_not_ready_title": "此工作区尚未准备就绪。", + "den.worker_provider_label": "{provider}工作区", + "den.worker_secondary_cloud": "云端工作区", + "extensions.app_count_one": "{count}个应用已连接", + "extensions.app_count_many": "{count}个应用已连接", + "extensions.apps_mcp_header": "应用(MCP)", + "extensions.filter_all": "全部", + "extensions.filter_apps": "应用", + "extensions.filter_plugins": "插件", + "extensions.plugin_count_one": "{count}个插件", + "extensions.plugin_count_many": "{count}个插件", + "extensions.plugins_opencode_header": "插件(OpenCode)", + "extensions.subtitle": "应用(MCP)和OpenCode插件集中管理。", + "extensions.title": "扩展", + "identities.agent_behavior_desc": "每个工作区一个文件。可在首行添加@agent 以通过特定OpenCode智能体路由。", + "identities.agent_behavior_title": "消息智能体行为", + "identities.agent_created": "已创建默认消息智能体文件。", + "identities.agent_file_changed": "文件已被远程修改。请重新加载后再保存。", + "identities.agent_loading": "正在加载智能体文件…", + "identities.agent_none": "无", + "identities.agent_not_found": "此工作区中尚未找到智能体文件。", + "identities.agent_saved": "已保存消息行为。", + "identities.agent_scope_status": "活动范围:工作区 · 状态:{status} · 已选智能体:{agent}", + "identities.agent_status_loaded": "已加载", + "identities.agent_status_missing": "缺失", + "identities.agent_worker_scope_unavailable": "工作区范围不可用。", + "identities.all_channels": "所有频道", + "identities.app_token_label": "应用令牌", + "identities.auto_bind_label": "直接发送时自动绑定对等方到目录", + "identities.available_channels": "可用频道", + "identities.bot_token_label": "机器人令牌", + "identities.bot_token_placeholder": "粘贴来自@BotFather的Telegram机器人令牌", + "identities.botfather_step1_open": "1. 在Telegram中打开@BotFather", + "identities.botfather_step1_run": "并运行/newbot", + "identities.botfather_step3_choose": "3. 为你的机器人选择名称和用户名", + "identities.botfather_step3_or_private": "用于开放收件箱,或", + "identities.botfather_step3_private": "私有", + "identities.botfather_step3_public": "公开", + "identities.botfather_step3_to_require": "以要求", + "identities.channel_label": "频道", + "identities.channels_connected": "已连接", + "identities.channels_label": "频道", + "identities.configured_suffix": "已配置", + "identities.connect_server_desc": "身份功能在连接OpenWork服务器后可用。", + "identities.connect_server_title": "连接OpenWork服务器", + "identities.connect_slack": "连接Slack", + "identities.connected_badge": "已连接", + "identities.connecting": "正在连接…", + "identities.copy_bot_token_hint": "复制机器人令牌并粘贴到下方。", + "identities.copy_code": "复制代码", + "identities.create_default_file": "创建默认文件", + "identities.create_private_bot": "创建私有机器人", + "identities.create_public_bot": "创建公开机器人", + "identities.days_ago": "{days}天前", + "identities.default_routing": "默认路由", + "identities.directory_label": "目录(可选)", + "identities.disable_messaging": "禁用消息", + "identities.disable_messaging_message": "这将关闭此工作区的消息功能。Telegram和Slack设置将隐藏,直到重新启用消息功能。你需要重启工作区以完全停止消息sidecar。", + "identities.disable_messaging_title": "禁用此工作区的消息功能?", + "identities.disabled_label": "已禁用", + "identities.disabling": "正在禁用…", + "identities.disconnect": "断开连接", + "identities.dispatched_messages": "已分发{sent}/{attempted}条消息。", + "identities.enable_messaging": "启用消息", + "identities.enable_messaging_risk": "消息功能可能将此工作区暴露给远程命令。如果机器人是公开的或被入侵,它可以访问此工作区可用的文件、凭据和API密钥。", + "identities.enable_messaging_title": "启用此工作区的消息功能?", + "identities.enabled_label": "已启用", + "identities.enabling": "正在启用…", + "identities.health_offline": "离线", + "identities.health_running": "运行中", + "identities.health_unavailable": "不可用", + "identities.health_unknown": "未知", + "identities.hours_ago": "{hours}小时前", + "identities.identities_label": "身份", + "identities.just_now": "刚刚", + "identities.last_activity": "最近活动", + "identities.later": "稍后", + "identities.message_label": "消息", + "identities.message_routing_desc": "控制哪些对话路由到哪个工作区文件夹。除非你在此设置规则,否则消息将路由到工作区的默认文件夹。", + "identities.message_routing_title": "消息路由", + "identities.messages_today": "今日消息", + "identities.messaging_disabled_hint": "仅在你了解风险并计划保护访问安全(例如使用Telegram私有配对)时才启用消息功能。", + "identities.messaging_disabled_restart": "消息已禁用。请重启此工作区以停止消息sidecar。", + "identities.messaging_disabled_risk": "消息机器人可以对你的本地工作区执行操作。如果公开暴露,它们可能允许访问此工作区可用的文件、凭据和API密钥。", + "identities.messaging_disabled_title": "消息功能默认禁用", + "identities.messaging_enabled_restart": "消息已启用。请立即重启此工作区以启动消息sidecar,并解锁Telegram和Slack设置。", + "identities.messaging_sidecar_not_running": "此工作区已启用消息功能,但消息sidecar尚未运行。请重启此工作区,然后返回消息设置连接Telegram或Slack。", + "identities.minutes_ago": "{minutes}分钟前", + "identities.not_set": "未设置", + "identities.open_bot_link": "在Telegram中打开@{username}", + "identities.pairing_code_copied": "配对码已复制。", + "identities.pairing_code_copy_failed": "无法复制配对码。请手动复制。", + "identities.pairing_code_instruction_prefix": "发送", + "identities.peer_id_label": "对等方ID(可选)", + "identities.peer_id_placeholder_slack": "例如 slack:U12345678", + "identities.peer_id_placeholder_telegram": "例如 telegram:123456789", + "identities.private_label": "私有", + "identities.private_pairing_code": "私有配对码", + "identities.public_bot_confirm": "是的,我了解风险", + "identities.public_bot_warning_message": "你的机器人将对公众开放,任何获得你机器人访问权限的人都可以完全访问你的本地工作区,包括你提供的所有文件和API密钥。如果创建私有机器人,你可以通过要求配对令牌来限制谁可以访问。确定要将机器人设为公开吗?", + "identities.public_bot_warning_title": "将此机器人设为公开?", + "identities.public_label": "公开", + "identities.quick_setup": "快速设置", + "identities.reconnect_failed": "重新连接失败。请检查OpenWork URL/令牌后重试。", + "identities.reconnected": "已重新连接。", + "identities.reconnected_refreshing": "已重新连接。正在刷新工作区状态…", + "identities.reload": "重新加载", + "identities.repair_reconnect": "修复并重新连接", + "identities.restart_failed": "重启失败。请在设置中重启工作区后重试。", + "identities.restart_to_disable_messaging": "此工作区的消息功能已禁用。请立即重启工作区以停止消息sidecar。", + "identities.restart_to_enable_messaging": "此工作区的消息功能已启用。请立即重启工作区以启动消息sidecar,并解锁Telegram和Slack设置。", + "identities.restart_worker": "重启工作区", + "identities.restart_worker_title": "立即重启工作区?", + "identities.restarting": "正在重启…", + "identities.routing_override_prefix": "所有消息路由到", + "identities.routing_override_suffix": "(覆盖已激活)", + "identities.running_label": "运行中", + "identities.save_behavior": "保存行为", + "identities.saving": "正在保存…", + "identities.send_test_button": "发送测试消息", + "identities.send_test_desc": "验证出站链路。使用对等方ID进行直接发送,或留空对等方ID以按目录中的绑定进行扇出。", + "identities.send_test_title": "发送测试消息", + "identities.sending": "正在发送…", + "identities.slack_desc": "你的工作区在Slack频道中显示为机器人。团队成员可以直接发消息或在对话中@提及它。", + "identities.slack_intro": "连接Slack工作区,让团队成员可以在频道和私信中与此工作区交互。", + "identities.slack_unavailable": "Slack身份不可用。", + "identities.status_active": "活动", + "identities.status_label": "状态", + "identities.status_stopped": "已停止", + "identities.stopped_label": "已停止", + "identities.subtitle": "让人们通过消息应用联系你的工作区。连接频道后,你的工作区将自动读取和回复消息。", + "identities.tab_general": "通用", + "identities.telegram_bot_access_desc": "公开机器人:首次Telegram聊天自动关联。私有机器人:需要配对码后才能运行工具。", + "identities.telegram_delete_failed": "删除失败。", + "identities.telegram_deleted": "已删除。", + "identities.telegram_deleted_pending": "已删除(等待应用)。", + "identities.telegram_desc": "连接Telegram机器人,可选公开模式(开放收件箱)或私有模式(需要配对码)。", + "identities.telegram_private_saved_pair": "私有机器人已保存。通过/pair {code}配对", + "identities.telegram_save_failed": "保存失败。", + "identities.telegram_saved": "已保存。", + "identities.telegram_saved_pending": "已保存(等待应用)。", + "identities.telegram_saved_username": "已保存(@{username})", + "identities.telegram_unavailable": "Telegram身份不可用。", + "identities.title": "消息频道", + "identities.unsaved_changes": "未保存的更改", + "identities.worker_offline": "工作区离线", + "identities.worker_online": "工作区在线", + "identities.worker_restarted": "工作区已重启。", + "identities.worker_restarted_refreshing": "工作区已重启。正在刷新消息状态…", + "identities.worker_scope_unavailable": "工作区范围不可用。", + "identities.worker_scope_unavailable_detail": "工作区范围不可用。请使用工作区URL重新连接或切换到已知的工作区。", + "identities.worker_unavailable": "工作区不可用", + "identities.workspace_id_required": "管理身份需要工作区ID。请使用工作区URL重新连接或选择此主机上映射的工作区。", + "identities.workspace_scope_prefix": "工作区范围:", + "inbox_panel.connect_to_download": "连接工作区以下载共享文件。", + "inbox_panel.connect_to_see": "连接以查看共享文件。", + "inbox_panel.connect_to_upload": "连接工作区以上传", + "inbox_panel.copy_failed": "复制失败。浏览器可能阻止了剪贴板访问。", + "inbox_panel.download": "下载", + "inbox_panel.drop_to_upload": "拖放文件到此处上传", + "inbox_panel.helper_text": "通过应用与此工作区共享文件。", + "inbox_panel.load_failed": "加载共享文件夹失败", + "inbox_panel.missing_file_id": "缺少共享文件ID。", + "inbox_panel.no_files": "暂无共享文件。", + "inbox_panel.refresh_tooltip": "刷新共享文件夹", + "inbox_panel.shared_folder": "共享文件夹", + "inbox_panel.showing_first": "显示前{count}个。", + "inbox_panel.upload_failed": "共享文件夹上传失败", + "inbox_panel.upload_needs_worker": "连接工作区以上传文件到共享文件夹。", + "inbox_panel.upload_prompt": "拖放文件或点击上传", + "inbox_panel.upload_success": "已上传到共享文件夹。", + "inbox_panel.uploading": "正在上传…", + "inbox_panel.uploading_label": "正在上传{label}…", + "mcp.activate_button": "激活", + "mcp.add_modal_subtitle": "通过URL或本地命令连接自定义MCP服务器。", + "mcp.add_modal_title": "添加自定义应用", + "mcp.add_server_button": "添加应用", + "mcp.advanced": "高级", + "mcp.advanced_settings": "高级设置", + "mcp.advanced_settings_hint": "编辑配置文件和手动管理连接。", + "mcp.app_connected": "个应用已连接", + "mcp.apps_connected": "个应用已连接", + "mcp.apps_subtitle": "连接常用工具,让OpenWork代你使用。", + "mcp.apps_title": "应用", + "mcp.auth.already_connected": "已连接", + "mcp.auth.already_connected_description": "{server}已通过身份验证,可以正常使用。", + "mcp.auth.applying_changes_body": "我们正在重启worker,以便新MCP可以开始认证。", + "mcp.auth.applying_changes_title": "正在登录前应用更改", + "mcp.auth.authorization_link": "授权链接", + "mcp.auth.authorization_still_required": "仍需要授权。请重试以重新启动流程。", + "mcp.auth.callback_invalid": "请粘贴回调URL或code参数以完成OAuth。", + "mcp.auth.callback_label": "回调URL或授权码", + "mcp.auth.callback_placeholder": "http://127.0.0.1:19876/mcp/oauth/callback?code=...", + "mcp.auth.cancel": "取消", + "mcp.auth.client_registration_required": "在继续OAuth之前需要先注册客户端。", + "mcp.auth.complete_connection": "完成连接", + "mcp.auth.configured_previously": "该MCP可能在全局或之前的会话中已配置。你可以关闭此弹窗,立即开始使用MCP工具。", + "mcp.auth.connect_server": "连接{server}", + "mcp.auth.copied": "已复制", + "mcp.auth.copy_link": "复制链接", + "mcp.auth.done": "完成", + "mcp.auth.failed_to_start_oauth": "启动OAuth流程失败", + "mcp.auth.follow_browser_steps": "请在浏览器中完成授权步骤。", + "mcp.auth.force_stop": "强制停止", + "mcp.auth.force_stopping": "正在停止...", + "mcp.auth.im_done": "我已完成", + "mcp.auth.invalid_refresh_token": "OAuth刷新令牌无效或已过期。请重新授权。", + "mcp.auth.manual_finish_hint": "粘贴回调URL(localhost:19876)或授权码以完成连接。", + "mcp.auth.manual_finish_title": "远程服务器?", + "mcp.auth.oauth_completed_reload": "OAuth已完成。重新加载引擎以激活MCP。", + "mcp.auth.oauth_failed": "OAuth身份验证失败。", + "mcp.auth.oauth_not_supported_hint": "这可能意味着:\n• MCP服务器未声明OAuth功能\n• 引擎需要重新加载以发现服务器功能\n• 尝试:从CLI运行opencode mcp auth {server}", + "mcp.auth.open_browser_signin": "我们将打开你的浏览器完成登录。", + "mcp.auth.port_forward_hint": "提示:如需转发回调端口:ssh -L 19876:127.0.0.1:19876 user@host", + "mcp.auth.reauth_action": "重新授权OAuth", + "mcp.auth.reauth_cli_hint": "运行:opencode mcp auth {server}", + "mcp.auth.reauth_failed": "重新授权失败。", + "mcp.auth.reauth_remote_hint": "请在运行该工作区的设备上重新授权。", + "mcp.auth.reauth_running": "正在重新授权...", + "mcp.auth.reload_blocked": "会话运行中,暂时无法刷新。请先停止运行后再完成设置。", + "mcp.auth.reload_engine_retry": "应用更改并重试", + "mcp.auth.reload_failed": "登录前重启worker失败。", + "mcp.auth.reload_notice": "要使更改生效,OpenWork需要刷新worker服务。这可能会中断正在运行的会话。", + "mcp.auth.reload_remote_confirm": "要使更改生效,OpenWork需要刷新worker服务。这可能会停止你正在运行的会话。是否继续?", + "mcp.auth.reopen_browser_link": "点击这里重新打开浏览器", + "mcp.auth.request_timed_out": "请求超时。", + "mcp.auth.retry": "重试", + "mcp.auth.retry_now": "立即重试", + "mcp.auth.server_disabled": "此MCP服务器已禁用。请启用它后重试。", + "mcp.auth.step1_description": "我们将自动启动{server}的登录流程。", + "mcp.auth.step1_title": "正在打开你的浏览器", + "mcp.auth.step2_description": "登录并在提示时批准访问权限。", + "mcp.auth.step2_title": "授权OpenWork", + "mcp.auth.step3_description": "授权完成后我们将立即完成连接。", + "mcp.auth.step3_title": "完成后返回此处", + "mcp.auth.try_reload_engine": "{message}。请尝试先重新加载引擎。", + "mcp.auth.waiting_authorization": "正在等待你在浏览器中完成授权...", + "mcp.auth.waiting_for_conversation_body": "一旦可以开始认证,我们会立即为你跳转。", + "mcp.auth.waiting_for_conversation_title": "正在等待对话完成", + "mcp.auth.waiting_for_session": "正在等待{session}完成工作", + "mcp.available_apps": "可用应用", + "mcp.cap_signin": "账户登录", + "mcp.cap_tools": "AI工具", + "mcp.config_file": "配置文件", + "mcp.config_load_failed": "无法加载配置文件", + "mcp.config_not_loaded": "尚未加载", + "mcp.config_source": "来自配置", + "mcp.configured": "已配置", + "mcp.connect": "连接", + "mcp.connect_failed": "连接失败,请重试。", + "mcp.connect_server_first": "请先连接服务器。", + "mcp.connected": "已连接", + "mcp.connected_badge": "已连接", + "mcp.connecting": "连接中", + "mcp.connection_failed": "连接异常 — 请重试", + "mcp.connection_type": "连接方式", + "mcp.control_chrome_browser_hint": "在Chrome 144或更高版本中,请先执行以下步骤:", + "mcp.control_chrome_browser_step_one": "打开chrome://inspect/#remote-debugging。", + "mcp.control_chrome_browser_step_two": "启用远程调试。", + "mcp.control_chrome_browser_step_three": "在Chrome提示时允许传入调试连接。", + "mcp.control_chrome_browser_title": "1. 开启Chrome访问", + "mcp.control_chrome_connect": "添加Control Chrome", + "mcp.control_chrome_docs": "官方MCP指南", + "mcp.control_chrome_edit": "编辑设置", + "mcp.control_chrome_profile_hint": "Control Chrome通常会打开独立的Chrome配置文件。如果你想让OpenWork复用已打开的Chrome窗口,请开启此选项。", + "mcp.control_chrome_profile_title": "2. 选择使用哪个Chrome", + "mcp.control_chrome_save": "保存设置", + "mcp.control_chrome_setup_subtitle": "先开启Chrome访问,然后选择OpenWork是使用独立配置文件还是复用已打开的Chrome。", + "mcp.control_chrome_setup_title": "设置Control Chrome", + "mcp.control_chrome_toggle_hint": "开启后,OpenWork将添加--autoConnect以连接到你已启动的Chrome实例。", + "mcp.control_chrome_toggle_label": "使用我现有的Chrome配置文件", + "mcp.control_chrome_toggle_off": "OpenWork将启动独立的Chrome配置文件用于自动化。", + "mcp.control_chrome_toggle_on": "OpenWork将复用你当前的标签页、Cookie和登录状态。", + "mcp.custom_app_cta_hint": "连接你的MCP服务器、内部工具或托管应用。", + "mcp.desktop_required": "应用需要桌面应用。", + "mcp.docs_link": "了解更多", + "mcp.file_not_found": "配置文件尚未创建", + "mcp.finish_setup": "即将完成", + "mcp.finish_setup_hint": "点击激活以完成应用连接。", + "mcp.friendly_status_issue": "异常", + "mcp.friendly_status_needs_signin": "需要登录", + "mcp.friendly_status_offline": "离线", + "mcp.friendly_status_paused": "已暂停", + "mcp.friendly_status_ready": "就绪", + "mcp.last_synced": "已同步", + "mcp.login_action": "登录", + "mcp.login_hint": "连接你的账户以完成此应用的设置。", + "mcp.login_unavailable": "此应用不支持从OpenWork发起登录。", + "mcp.logout_action": "退出登录", + "mcp.logout_failed": "退出登录失败。", + "mcp.logout_hint": "将删除已保存的OAuth凭据。之后需要重新登录。", + "mcp.logout_label": "OAuth", + "mcp.logout_modal_message": "这将删除{server}的OAuth凭据。之后需要重新登录才能使用该应用。", + "mcp.logout_modal_title": "退出登录?", + "mcp.logout_success": "{server}已退出登录。", + "mcp.logout_working": "正在退出...", + "mcp.name_required": "请输入服务器名称。", + "mcp.no_apps_hint": "连接上方的应用开始使用。", + "mcp.no_apps_yet": "暂无已连接的应用", + "mcp.oauth": "登录", + "mcp.oauth_optional_hint": "在浏览器中通过OAuth连接你的账户。", + "mcp.oauth_optional_label": "此应用需要登录", + "mcp.one_click_connect": "一键连接", + "mcp.open_file": "打开文件", + "mcp.opening_label": "正在打开", + "mcp.pick_workspace_error": "请先选择工作区文件夹。", + "mcp.pick_workspace_first": "请先选择工作区文件夹。", + "mcp.quick_connect_chrome_desc": "通过浏览器自动化操控Chrome标签页。", + "mcp.quick_connect_chrome_title": "控制Chrome", + "mcp.quick_connect_context7_desc": "以更丰富的上下文搜索产品文档。", + "mcp.quick_connect_context7_title": "Context7", + "mcp.quick_connect_linear_desc": "规划冲刺,更快交付工单。", + "mcp.quick_connect_linear_title": "Linear", + "mcp.quick_connect_notion_desc": "同步页面、数据库和项目文档。", + "mcp.quick_connect_notion_title": "Notion", + "mcp.quick_connect_sentry_desc": "追踪发布并解决生产错误。", + "mcp.quick_connect_sentry_title": "Sentry", + "mcp.quick_connect_stripe_desc": "查看支付、发票和订阅。", + "mcp.quick_connect_stripe_title": "Stripe", + "mcp.reload_banner_blocked_hint": "停止正在运行的任务以激活。", + "mcp.reload_banner_description": "点击激活以完成应用连接。", + "mcp.reload_banner_description_blocked": "任务正在运行。请先停止任务再激活。", + "mcp.remote_workspace_url_hint": "远程工作区建议优先使用URL类型的MCP服务器。", + "mcp.remove_app": "移除", + "mcp.remove_failed": "无法移除应用。", + "mcp.remove_modal_message": "确定要移除{server}吗?你可以随时重新添加。", + "mcp.remove_modal_title": "移除应用", + "mcp.reveal_config_failed": "无法打开配置文件", + "mcp.reveal_in_finder": "在文件管理器中显示", + "mcp.scope_global": "所有工作区", + "mcp.scope_project": "此工作区", + "mcp.server_command": "命令", + "mcp.server_command_hint": "启动服务器的shell命令。", + "mcp.server_command_placeholder": "npx -y @modelcontextprotocol/server-sequential-thinking", + "mcp.server_name": "应用名称", + "mcp.server_name_placeholder": "github-copilot", + "mcp.server_type": "类型", + "mcp.server_url": "服务器URL", + "mcp.server_url_placeholder": "https://api.githubcopilot.com/mcp/", + "mcp.sign_in_section_label": "登录", + "mcp.tap_to_connect": "点击连接", + "mcp.technical_details": "技术详情", + "mcp.type_cloud": "Cloud(使用账户登录)", + "mcp.type_local": "本地(在此设备运行)", + "mcp.type_local_cmd": "本地(命令)", + "mcp.type_remote": "远程(URL)", + "mcp.url_or_command_required": "远程服务器需要URL,本地服务器需要命令。", + "mcp.your_apps": "你的应用", + "message.tool_request_label": "请求", + "message.tool_result_label": "结果", + "message.waiting_subagent": "正在等待子智能体的转录到达。", + "message_list.copy_message": "复制消息", + "message_list.open_session": "打开会话", + "message_list.step_updates_progress": "更新进度", + "message_list.subagent_loading_transcript": "正在加载转录", + "message_list.subagent_message_count": "{count}条消息", + "message_list.subagent_running": "运行中", + "message_list.subagent_session_fallback": "子智能体会话", + "message_list.subagent_type_task": "{agentType}任务", + "message_list.subagent_waiting_transcript": "等待转录", + "message_list.tool_checked_url": "检查了{url}", + "message_list.tool_checked_web_fallback": "检查了网页", + "message_list.tool_delegate_agent": "委托{agent}", + "message_list.tool_delegate_task_fallback": "委托任务", + "message_list.tool_load_skill_fallback": "加载skill", + "message_list.tool_load_skill_named": "加载skill {name}", + "message_list.tool_read_todo": "读取待办列表", + "message_list.tool_reviewed_file": "查看了{file}", + "message_list.tool_reviewed_file_fallback": "查看了文件", + "message_list.tool_reviewed_files_fallback": "查看了文件", + "message_list.tool_reviewed_path": "查看了{path}", + "message_list.tool_run_command": "运行{command}", + "message_list.tool_run_command_fallback": "运行命令", + "message_list.tool_searched_code_fallback": "搜索了代码", + "message_list.tool_searched_pattern": "搜索了{pattern}", + "message_list.tool_update_file": "更新{file}", + "message_list.tool_update_file_fallback": "更新文件", + "message_list.tool_update_todo": "更新待办列表", + "message_list.tool_updated_file": "已更新{file}", + "message_list.tool_updated_file_fallback": "已更新文件", + "model_behavior.desc_builtin": "此模型自行决定推理路径,不在此处提供配置。", + "model_behavior.desc_generic": "使用{label}配置。", + "model_behavior.desc_high": "回答前花更多时间推理。", + "model_behavior.desc_high_anthropic": "使用标准扩展思考预算。", + "model_behavior.desc_low": "回答前使用较轻的推理过程。", + "model_behavior.desc_low_google": "使用较轻的推理预算以更快响应。", + "model_behavior.desc_max": "使用提供商最深层的推理配置。", + "model_behavior.desc_max_anthropic": "使用最大的扩展思考预算。", + "model_behavior.desc_medium": "平衡速度和推理深度。", + "model_behavior.desc_minimal": "使用极少量的推理。", + "model_behavior.desc_none": "以最轻量的推理路径优先保证速度。", + "model_behavior.desc_standard": "此模型不提供额外的推理控制。", + "model_behavior.label_balanced": "均衡", + "model_behavior.label_builtin": "内置", + "model_behavior.label_deep": "深度", + "model_behavior.label_extended": "扩展", + "model_behavior.label_fast": "快速", + "model_behavior.label_light": "轻量", + "model_behavior.label_maximum": "最大", + "model_behavior.label_quick": "迅速", + "model_behavior.label_standard": "标准", + "model_behavior.title_builtin_reasoning": "内置推理", + "model_behavior.title_extended_thinking": "扩展思考", + "model_behavior.title_reasoning_budget": "推理预算", + "model_behavior.title_reasoning_effort": "推理力度", + "model_behavior.title_standard_generation": "标准生成", + "model_picker.chat_model_desc": "选择此对话的模型。如果模型支持推理配置,可在其卡片上进行设置。", + "model_picker.chat_model_title": "对话模型", + "model_picker.connect_provider_hint": "连接此提供商以浏览和保存模型", + "model_picker.default_model_desc": "选择新对话的默认模型,然后在其卡片上微调推理配置后点击完成。", + "model_picker.default_model_title": "默认模型", + "model_picker.model_count": "{count}个模型", + "model_picker.model_count_one": "1个模型", + "model_picker.more_providers": "更多提供商", + "model_picker.no_results": "没有匹配的模型。", + "model_picker.other_connected_models": "其他已连接的模型", + "model_picker.recommended": "推荐", + "onboarding.access_label": "访问权限", + "onboarding.add": "添加", + "onboarding.add_folder_path": "添加文件夹路径", + "onboarding.advanced_settings": "高级设置", + "onboarding.attach": "附加", + "onboarding.attach_description": "附加到此设备上的现有会话。", + "onboarding.authorize_folder": "授权文件夹", + "onboarding.back": "返回", + "onboarding.checking_cli": "正在检查OpenCode CLI...", + "onboarding.choose_workspace_folder": "选择工作区文件夹", + "onboarding.cli_checking": "正在检查安装...", + "onboarding.cli_install_commands": "使用以下命令之一安装OpenCode,然后重启OpenWork。", + "onboarding.cli_label": "OpenCode CLI", + "onboarding.cli_needs_update": "OpenCode CLI需要更新以支持serve。", + "onboarding.cli_not_found": "未找到OpenCode CLI。", + "onboarding.cli_not_found_hint": "未找到。请安装以运行本地服务器。", + "onboarding.cli_ready": "OpenCode CLI就绪。", + "onboarding.cli_recheck": "重新检查", + "onboarding.cli_version": "OpenCode {version}", + "onboarding.cli_version_installed": "已安装", + "onboarding.create_first_workspace": "创建你的第一个工作区", + "onboarding.create_workspace": "创建工作区", + "onboarding.engine_running": "引擎已在运行", + "onboarding.folders_allowed": "已授权{count}个文件夹", + "onboarding.getting_ready": "正在准备中", + "onboarding.install": "安装OpenCode", + "onboarding.install_instruction": "安装OpenCode以启用本地服务器(无需终端)。", + "onboarding.last_checked": "上次检查时间{time}", + "onboarding.manage_access_hint": "你可以在高级设置中管理访问权限。", + "onboarding.open_settings": "打开设置", + "onboarding.open_settings_hint": "需要引擎或访问选项?打开设置。", + "onboarding.pick": "选择", + "onboarding.ready_message": "OpenCode已准备好启动本地服务器。", + "onboarding.remember_choice": "记住我的选择,下次直接启动", + "onboarding.remote_workspace_action": "连接", + "onboarding.remote_workspace_card_description": "连接OpenWork服务器以访问共享工作区。", + "onboarding.remote_workspace_card_title": "连接远程工作区", + "onboarding.remote_workspace_description": "连接OpenWork服务器以随时访问工作区。", + "onboarding.remote_workspace_title": "连接OpenWork服务器", + "onboarding.remove": "移除", + "onboarding.resolved_path": "解析路径", + "onboarding.run_local": "本地运行", + "onboarding.run_local_description": "OpenWork在本地运行OpenCode并保持你的工作私密。", + "onboarding.search_notes": "搜索说明", + "onboarding.searching_host": "正在连接OpenWork服务器...", + "onboarding.serve_help": "serve --help输出", + "onboarding.show_search_notes": "显示搜索说明", + "onboarding.start": "启动OpenWork", + "onboarding.starting_host": "正在启动OpenWork服务器...", + "onboarding.theme_current": "当前:{mode}", + "onboarding.theme_dark": "深色", + "onboarding.theme_label": "主题", + "onboarding.theme_light": "浅色", + "onboarding.theme_system": "系统", + "onboarding.verifying": "验证安全握手", + "onboarding.version": "版本", + "onboarding.welcome_title": "今天想如何运行OpenWork?", + "onboarding.windows_install_instruction": "安装Windows版OpenCode,然后重启OpenWork。确保opencode.exe在PATH中。", + "onboarding.workspace_folder_label": "工作区是一个包含自己的skills、插件和命令的文件夹。", + "plugins.add": "添加", + "plugins.add_hint": "添加npm包名称,例如opencode-wakatime", + "plugins.add_label": "添加插件", + "plugins.added": "已添加", + "plugins.config": "配置", + "plugins.config_label": "配置", + "plugins.desc": "管理项目或全局OpenCode插件的`opencode.json`。", + "plugins.empty": "尚未配置插件。", + "plugins.enabled": "已启用", + "plugins.hide_setup": "隐藏设置", + "plugins.not_loaded": "尚未加载", + "plugins.not_loaded_yet": "尚未加载", + "plugins.remove": "移除", + "plugins.scope_global": "全局", + "plugins.scope_project": "项目", + "plugins.setup": "设置", + "plugins.suggested": "建议的插件", + "plugins.suggested_heading": "建议的插件", + "plugins.title": "OpenCode插件", + "providers.api_key_label": "API密钥", + "providers.api_key_required": "API密钥为必填项", + "providers.auth_failed": "认证失败", + "providers.connect_failed": "连接提供商失败", + "providers.disabled_in_config_suffix": "并已在OpenCode配置中禁用。", + "providers.disconnect_failed": "断开提供商失败", + "providers.disconnected_prefix": "已断开", + "providers.load_failed": "加载提供商失败", + "providers.no_oauth_prefix": "没有可用的OAuth流程:", + "providers.no_providers_available": "没有可用的提供商", + "providers.not_connected": "未连接到服务器", + "providers.not_oauth_flow_prefix": "所选认证方式不是OAuth流程:", + "providers.oauth_failed": "完成OAuth失败", + "providers.oauth_method_required": "OAuth方式为必填项", + "providers.provider_error": "提供商错误({provider})", + "providers.provider_id_required": "提供商ID为必填项", + "providers.rate_limit_exceeded": "请求频率超限", + "providers.removal_unsupported": "此客户端不支持移除提供商认证。", + "providers.request_failed": "请求失败", + "providers.save_api_key_failed": "保存API密钥失败", + "providers.still_connected_suffix": ",但工作区仍报告为已连接。请清除残留的API密钥或OAuth凭据,然后重启工作区以完全断开。", + "providers.unknown_provider": "未知提供商", + "providers.use_api_key_suffix": "请改用API密钥。", + "question_modal.custom_answer_label": "或输入自定义回答", + "question_modal.custom_answer_placeholder": "在此输入你的回答…", + "question_modal.question_counter": "问题{current} / {total}", + "session.allow_for_session": "在会话期间允许", + "session.allow_once": "允许一次", + "session.api_key_saved": "API密钥已保存", + "session.attachments_add_token": "添加服务器令牌以附加文件。", + "session.attachments_connect_server": "连接OpenWork服务器以附加文件。", + "session.back": "返回", + "session.close_quick_actions": "关闭快捷操作", + "session.close_search": "关闭搜索", + "session.cmd_compact_detail": "向OpenCode发送此会话的压缩指令", + "session.cmd_compact_detail_empty": "暂无可压缩的用户消息", + "session.cmd_compact_meta": "压缩", + "session.cmd_compact_title": "压缩对话", + "session.cmd_current_workspace": "当前工作区", + "session.cmd_model_detail": "{model} · {variant}", + "session.cmd_model_fallback": "模型", + "session.cmd_model_meta": "打开", + "session.cmd_model_title": "更换模型", + "session.cmd_new_session_detail": "在当前工作区开始新任务", + "session.cmd_new_session_meta": "创建", + "session.cmd_new_session_title": "新建会话", + "session.cmd_provider_detail": "打开提供商连接流程", + "session.cmd_provider_meta": "打开", + "session.cmd_provider_title": "连接提供商", + "session.cmd_rename_detail_fallback": "为当前会话设置更清晰的名称", + "session.cmd_rename_meta": "重命名", + "session.cmd_rename_title": "重命名当前会话", + "session.cmd_sessions_detail": "{count}个可用,跨工作区", + "session.cmd_sessions_meta": "跳转", + "session.cmd_sessions_title": "搜索会话", + "session.cmd_switch": "切换", + "session.compacted": "会话已压缩。", + "session.compacting": "正在压缩会话上下文…", + "session.compacting_auto": "OpenCode正在自动压缩此会话", + "session.compacting_manual": "OpenCode正在压缩此会话", + "session.compaction_finished": "OpenCode已完成会话上下文压缩。", + "session.compaction_started": "OpenCode已开始压缩会话上下文。", + "session.conflict_sync_toast": "同步{path}时发生冲突。本地更改已保存到{conflictPath}。", + "session.connect_failed": "连接失败", + "session.connect_to_sync": "连接OpenWork服务器以同步远程文件。", + "session.create_or_connect_workspace": "创建或连接工作区", + "session.create_workspace_desc": "打开工作区创建器,选择如何开始。", + "session.create_workspace_title": "创建工作区", + "session.default_agent": "默认智能体", + "session.default_title": "新建会话", + "session.delete": "删除", + "session.delete_named_session_message": "这将永久删除「{title}」及其消息。", + "session.delete_session_generic": "这将永久删除所选会话及其消息。", + "session.delete_session_title": "删除会话?", + "session.deleted": "会话已删除", + "session.deleting": "正在删除…", + "session.deny": "拒绝", + "session.details": "详情", + "session.details_label": "详情", + "session.doom_loop_label": "死循环", + "session.doom_loop_message": "OpenCode检测到使用相同输入的重复工具调用,正在询问是否在多次失败后继续。", + "session.doom_loop_note": "拒绝以停止循环,或允许以让智能体继续尝试。", + "session.doom_loop_repeated_call_label": "重复调用", + "session.doom_loop_repeated_tool_call": "重复的工具调用", + "session.doom_loop_title": "检测到死循环", + "session.doom_loop_tool_label": "工具", + "session.downloading": "下载中", + "session.downloading_percent": "下载中{percent}%", + "session.downloading_update_title": "正在下载更新{version}", + "session.export_already_running": "导出正在进行中。", + "session.export_desktop_only": "导出功能在桌面应用中可用。", + "session.export_desktop_only_local": "导出功能在桌面应用中适用于本地工作区。", + "session.export_local_only": "导出仅支持本地工作区。", + "session.failed_to_compact": "压缩会话失败", + "session.failed_to_create_session": "创建会话失败", + "session.failed_to_delete": "删除会话失败", + "session.failed_to_load_agents": "加载智能体失败", + "session.failed_to_load_providers": "加载提供商失败", + "session.failed_to_redo": "重做失败", + "session.failed_to_save_api_key": "保存API密钥失败", + "session.failed_to_stop": "停止失败", + "session.failed_to_undo": "撤销失败", + "session.file_open_desktop_only": "在桌面应用中可打开文件。", + "session.file_open_failed": "打开文件失败", + "session.file_open_remote_unavailable": "远程工作区不支持打开文件。", + "session.flyout_file_modified": "文件已修改", + "session.flyout_new_task": "新建任务", + "session.install_update": "安装更新", + "session.jump_to_latest": "跳到最新", + "session.jump_to_start": "跳到消息开头", + "session.load_earlier": "加载更早的消息", + "session.loading_detail": "正在拉取此任务的最新消息。", + "session.loading_earlier": "正在加载更早的消息…", + "session.loading_session": "正在加载会话", + "session.loading_title": "正在加载会话", + "session.menu_label": "菜单", + "session.model": "模型", + "session.model_fallback": "模型", + "session.new_task": "新建任务", + "session.next_match": "下一个匹配", + "session.no_matches": "无匹配", + "session.no_matches_command": "无匹配。", + "session.no_session_selected": "未选择会话", + "session.nothing_to_compact": "暂无可压缩的内容。", + "session.nothing_to_redo": "没有可重做的内容。", + "session.nothing_to_retry": "暂无可重试的内容", + "session.nothing_to_undo": "暂无可撤销的内容。", + "session.oauth_failed": "OAuth失败", + "session.obsidian_worker_relative_only": "仅可在Obsidian中打开工作区相对路径的文件。", + "session.open": "打开", + "session.palette_hint_navigate": "方向键导航", + "session.palette_hint_run": "回车执行 · Esc关闭", + "session.palette_placeholder_actions": "搜索操作", + "session.palette_placeholder_sessions": "按会话标题或工作区搜索", + "session.palette_title_actions": "快捷操作", + "session.palette_title_sessions": "搜索会话", + "session.permission_label": "权限", + "session.permission_detail_command": "命令", + "session.permission_detail_cwd": "工作目录", + "session.permission_detail_description": "说明", + "session.permission_detail_diff": "差异", + "session.permission_detail_file": "文件", + "session.permission_detail_files": "文件", + "session.permission_detail_agent": "Agent", + "session.permission_detail_parent_directory": "父目录", + "session.permission_detail_path": "路径", + "session.permission_detail_query": "查询", + "session.permission_detail_target": "目标", + "session.permission_detail_tool": "工具", + "session.permission_detail_url": "URL", + "session.permission_kind_edit": "文件编辑", + "session.permission_kind_external_directory": "外部目录", + "session.permission_kind_question": "问题", + "session.permission_kind_read": "文件读取", + "session.permission_kind_skill": "技能", + "session.permission_kind_task": "子任务", + "session.permission_kind_todowrite": "Todo写入", + "session.permission_message": "OpenCode正在请求权限以继续。", + "session.permission_message_bash": "允许OpenCode继续前,请先检查命令影响范围。", + "session.permission_message_edit": "允许OpenCode修改前,请先检查文件和差异。", + "session.permission_message_external_directory": "允许访问工作区外部前,请先检查目标文件夹。", + "session.permission_message_read": "允许访问前,请先检查请求的文件范围。", + "session.permission_message_task": "允许启动前,请先检查请求的子任务。", + "session.permission_metadata_unavailable": "无法显示元数据。", + "session.permission_required": "需要权限", + "session.permission_review_label": "检查", + "session.permission_scope_empty": "未提供具体范围。", + "session.permission_title_bash": "运行 shell 命令?", + "session.permission_title_edit": "修改文件?", + "session.permission_title_external_directory": "访问外部文件夹?", + "session.permission_title_generic": "批准 {permission}?", + "session.permission_title_read": "读取文件?", + "session.permission_title_task": "启动子任务?", + "session.permission_decision_hint": "仅信任本次请求时选择允许一次;信任这个范围时可在会话期间允许。", + "session.phase_responding": "回复中", + "session.phase_retrying": "重试中", + "session.phase_run_failed": "运行失败", + "session.phase_sending": "发送中", + "session.pick_folder_desc": "选择现有项目或笔记文件夹,OpenWork将把它作为你的工作区。", + "session.pick_folder_title": "选择要使用的文件夹", + "session.pick_workspace_to_open": "选择工作区以打开文件。", + "session.prev_match": "上一个匹配", + "session.provider_auth_in_progress": "提供商认证正在进行中。", + "session.provider_connected": "提供商已连接", + "session.quick_actions_label": "快捷操作", + "session.quick_actions_title": "快捷操作(Ctrl/Cmd+K)", + "session.redo_aria_label": "重做上一条撤销的消息", + "session.redo_label": "重做", + "session.redo_title": "重做上一条撤销的消息", + "session.remote_sync_failed": "远程文件同步失败", + "session.rename_description": "更新此会话名称。", + "session.rename_label": "会话名称", + "session.rename_placeholder": "输入新的名称", + "session.rename_title": "重命名会话", + "session.resize_workspace_column": "调整工作区列宽", + "session.restart_update_title": "重启以应用更新{version}", + "session.restored_message": "已恢复撤销的消息。", + "session.reveal": "在文件管理器中显示", + "session.reveal_desktop_only": "在桌面应用中可使用显示功能。", + "session.revert_label": "撤销", + "session.reverted_last_message": "已撤销上一条用户消息。", + "session.run": "运行", + "session.scope_label": "范围", + "session.search_conversation_label": "搜索对话", + "session.search_conversation_title": "搜索对话(Ctrl/Cmd+F)", + "session.search_next": "下一个", + "session.search_placeholder": "在此对话中搜索", + "session.search_position": "{current} / {total}", + "session.search_prev": "上一个", + "session.share_active_cloud_org": "当前Cloud组织", + "session.share_choose_org": "请先在设置 → Cloud中选择组织再分享。", + "session.share_collaborator_hint": "日常远程访问,不需要所有者权限时使用。", + "session.share_collaborator_host_hint": "日常远程访问此主机,无需所有者权限。", + "session.share_collaborator_label": "协作者令牌", + "session.share_collaborator_token": "协作者令牌", + "session.share_connected_with_hint": "此工作区当前使用此密码连接。", + "session.share_desktop_app_required": "需要桌面应用", + "session.share_desktop_required": "需要桌面应用", + "session.share_host_url_and_token_required": "OpenWork主机URL和令牌为必填项。", + "session.share_local_host_not_ready": "本地OpenWork主机尚未就绪。", + "session.share_missing_host_url": "缺少OpenWork主机URL。", + "session.share_missing_token": "缺少OpenWork令牌。", + "session.share_no_skills": "此工作区中未找到skills。", + "session.share_note_direct_runtime": "引擎运行时设置为直连模式。切换本地工作区可能会重启主机并断开客户端连接。令牌可能在重启后变更。", + "session.share_opencode_base_url": "OpenCode基础URL", + "session.share_openwork_workers_only": "分享服务链接仅适用于OpenWork工作区。", + "session.share_owner_permission_hint": "远程客户端需要回答权限提示时使用。", + "session.share_password": "密码", + "session.share_password_owner_hint": "远程客户端需要回答权限提示时使用。", + "session.share_publish_skills_failed": "发布skills集失败", + "session.share_publish_workspace_failed": "发布工作区配置失败", + "session.share_resolve_local_workspace_failed": "无法在本地OpenWork主机上解析此工作区。", + "session.share_resolve_remote_workspace_failed": "无法在OpenWork主机上解析此工作区。", + "session.share_save_team_template_failed": "保存团队模板失败", + "session.share_saved_to_org": "已保存{name}到{org}。", + "session.share_select_workspace": "请先选择工作区。", + "session.share_set_token_hint": "在工作区设置中设置令牌", + "session.share_sign_in_required": "请在设置中登录OpenWork Cloud以与团队分享。", + "session.share_skills_set_desc": "来自OpenWork工作区的完整skills集。", + "session.share_starting_server": "正在启动服务器…", + "session.share_team_fallback_name": "团队模板", + "session.share_url_resolving_hint": "工作区URL正在解析,当前显示主机URL。", + "session.share_url_worker_hint": "在手机或笔记本上连接此工作区时使用。", + "session.share_worker_url": "工作区URL", + "session.share_worker_url_phones_hint": "在手机或笔记本上连接此工作区时使用。", + "session.share_worker_url_resolving_hint": "工作区URL正在解析,当前显示主机URL。", + "session.shared_folder_upload_failed": "共享文件夹上传失败", + "session.show_earlier": "显示前{count}条消息", + "session.status_active": "会话进行中", + "session.status_compacting": "正在压缩上下文", + "session.status_delegating": "委托中", + "session.status_gathering_context": "正在收集上下文", + "session.status_planning": "规划中", + "session.status_ready": "就绪", + "session.status_ready_session": "会话就绪", + "session.status_running_shell": "执行命令中", + "session.status_searching_codebase": "搜索代码库中", + "session.status_searching_web": "搜索网络中", + "session.status_thinking": "思考中", + "session.status_working": "工作中", + "session.status_writing_file": "写入文件中", + "session.stopped": "已停止。", + "session.stopping_run": "正在停止运行…", + "session.todo_progress": "已完成{completed} / {total}个任务", + "session.trying_again": "正在重试…", + "session.unable_to_open_file": "无法打开文件", + "session.unable_to_open_obsidian": "无法在Obsidian中打开文件", + "session.unable_to_reveal": "无法显示工作区", + "session.undo_label": "撤销", + "session.undo_title": "撤销上一条消息", + "session.update_available": "有可用更新", + "session.update_available_title": "有可用更新{version}", + "session.update_ready": "更新就绪", + "session.update_ready_stop_runs_title": "更新就绪{version}。请停止活动运行以重启。", + "session.upload_connect_server": "连接OpenWork服务器以上传文件到共享文件夹。", + "session.uploaded_to_shared_folder": "已上传到共享文件夹。", + "session.uploaded_with_summary": "已上传到共享文件夹:{summary}", + "session.uploading_to_shared_folder": "正在上传{label}到共享文件夹…", + "session.workspace_fallback": "工作区", + "session.workspace_label": "工作区", + "session.workspace_path_unavailable": "工作区路径不可用。", + "session.workspace_setup_desc": "从引导式OpenWork工作区开始,或选择现有文件夹。", + "session.workspace_setup_label": "工作区设置", + "session.workspace_setup_title": "设置你的第一个工作区", + "settings.action_download": "下载", + "settings.action_install": "安装", + "settings.actor_host": "主机", + "settings.actor_remote": "远程", + "settings.actor_unknown": "未知", + "settings.advanced": "高级", + "settings.advanced_title": "高级", + "settings.api_keys_info": "API密钥由OpenCode存储在本地。环境变量提供商需在工作区环境中修改后重新加载。", + "settings.appearance_hint": "匹配系统或强制浅色/深色模式。", + "settings.appearance_title": "外观", + "settings.audit_error": "错误", + "settings.audit_loading": "加载中", + "settings.audit_log_title": "审计日志", + "settings.audit_ready": "就绪", + "settings.auto_compact": "自动上下文压缩", + "settings.auto_compact_desc": "控制此工作区的OpenCode compaction.auto。更改后请重载引擎。", + "settings.auto_update_desc": "自动下载更新(安装前会提示)", + "settings.auto_update_title": "自动更新", + "settings.available_count": "{count}个可用", + "settings.background_checks_desc": "OpenWork启动时始终检查。同时每天检查一次。", + "settings.background_checks_title": "后台检查", + "settings.base_url_unavailable": "基础URL不可用", + "settings.binary_unavailable": "二进制文件不可用", + "settings.cache_nothing_to_repair": "未找到OpenCode缓存。无需修复。", + "settings.cache_repair_requires_desktop": "缓存修复需要桌面应用", + "settings.cache_repaired": "OpenCode缓存已修复。如果引擎正在运行,请重启。", + "settings.cap_browser_tools": "浏览器工具:{value}", + "settings.cap_commands": "命令:{value}", + "settings.cap_config": "配置:{value}", + "settings.cap_file_tools": "文件工具:{value}", + "settings.cap_inbox_off": "收件箱关闭", + "settings.cap_inbox_on": "收件箱开启", + "settings.cap_mcp": "MCP:{value}", + "settings.cap_outbox_off": "发件箱关闭", + "settings.cap_outbox_on": "发件箱开启", + "settings.cap_plugins": "插件:{value}", + "settings.cap_read": "读取", + "settings.cap_sandbox": "沙箱:{value}", + "settings.cap_skills": "Skills:{value}", + "settings.cap_write": "写入", + "settings.capabilities_title": "OpenWork服务器功能", + "settings.capabilities_unavailable": "功能不可用。请使用客户端令牌连接。", + "settings.change": "更改", + "settings.check_update": "检查", + "settings.checking_for_updates": "正在检查更新", + "settings.choose": "选择", + "settings.clear": "清除", + "settings.clipboard_unavailable": "此环境中剪贴板不可用。", + "settings.configure": "配置", + "settings.connect_opencode_hint": "连接OpenCode以加载提供商。", + "settings.connect_provider": "连接提供商", + "settings.connected_count": "{count}个已连接", + "settings.connection": "连接", + "settings.connection_failed": "连接失败", + "settings.connection_title": "连接", + "settings.copied_debug_report": "已复制运行时报告JSON。", + "settings.copy_failed": "复制运行时报告失败。", + "settings.copy_json": "复制JSON", + "settings.custom_binary_hint": "用于将OpenWork指向本地的OpenCode构建", + "settings.custom_binary_label": "自定义OpenCode二进制文件", + "settings.data_dir_unavailable": "数据目录不可用", + "settings.debug_commit": "提交:{sha}", + "settings.debug_desktop_app": "桌面应用:{version}", + "settings.debug_opencode_version": "OpenCode:{version}", + "settings.debug_openwork_server_version": "OpenWork服务器:{version}", + "settings.debug_section_title": "开发者", + "settings.deeplink_failed": "打开深层链接失败。", + "settings.deeplink_hint": "接受openwork://、openwork-dev://或原始支持的https://share.openworklabs.com/b/... URL。", + "settings.default_model": "默认模型", + "settings.delete_containers": "正在移除容器…", + "settings.delete_local_config": "正在移除本地状态…", + "settings.desktop_only_hint": "在桌面应用中可用。", + "settings.dev_mode_badge": "开发模式", + "settings.developer": "开发者", + "settings.developer_mode_desc": "启用调试工具、诊断信息和开发者标签页。", + "settings.developer_mode_title": "开发者模式", + "settings.developer_panel_disabled": "开发者面板已禁用。", + "settings.developer_panel_enabled": "开发者面板已启用。", + "settings.devlog_cleared": "已清除开发者日志输出。", + "settings.devlog_clipboard_unavailable": "此环境中剪贴板不可用。", + "settings.devlog_copied": "已复制开发者日志输出。", + "settings.devlog_copy_failed": "复制开发者日志输出失败。", + "settings.devlog_export_failed": "导出开发者日志输出失败。", + "settings.devlog_export_unavailable": "此环境中导出不可用。", + "settings.devlog_exported": "已导出开发者日志输出。", + "settings.devtools_desc": "Sidecar健康状态、功能和审计追踪。", + "settings.devtools_title": "开发者工具", + "settings.diag_approval": "审批:{mode}({ms}ms)", + "settings.diag_config_path": "配置路径:{path}", + "settings.diag_daemon_url": "守护进程:{url}", + "settings.diag_default": "默认", + "settings.diag_health_port": "健康端口:{port}", + "settings.diag_healthy_ms": "健康检查:{ms}ms", + "settings.diag_host_token_source": "主机令牌来源:{source}", + "settings.diag_last_attempt": "最后尝试:{time}", + "settings.diag_load_sessions_ms": "加载会话:{ms}ms", + "settings.diag_opencode_binary": "OpenCode二进制文件:{binary}", + "settings.diag_opencode_url": "OpenCode:{url}", + "settings.diag_pending_permissions_ms": "待处理权限:{ms}ms", + "settings.diag_pid": "PID:{pid}", + "settings.diag_providers_ms": "提供商:{ms}ms", + "settings.diag_read_only": "只读:{value}", + "settings.diag_reason": "原因:{reason}", + "settings.diag_runtime_workspace": "运行时工作区:{id}", + "settings.diag_selected_workspace": "已选工作区:{id}", + "settings.diag_sidecar": "Sidecar:{info}", + "settings.diag_started": "已启动:{time}", + "settings.diag_token_source": "令牌来源:{source}", + "settings.diag_total_ms": "总计:{ms}ms", + "settings.diag_version": "版本:{version}", + "settings.diag_workspaces": "工作区:{count}", + "settings.diagnostics_unavailable": "诊断不可用。", + "settings.disable_developer_mode": "禁用开发者模式", + "settings.disabled": "已禁用", + "settings.disconnect": "断开连接", + "settings.disconnect_confirm_suffix": "断开{resolved}?这将移除此提供商的已存储API密钥或OAuth凭据。", + "settings.disconnect_server": "断开服务器", + "settings.disconnected_prefix": "已断开{resolved}。", + "settings.disconnecting": "正在断开…", + "settings.docker_containers_desc": "强制移除OpenWork启动的Docker容器", + "settings.docker_containers_title": "OpenWork Docker容器", + "settings.docker_requires_desktop": "Docker清理需要桌面应用", + "settings.done": "完成", + "settings.downloading_bytes": "正在下载{downloaded}", + "settings.downloading_progress": "正在下载{downloaded} / {total}({percent}%)", + "settings.enable_developer_mode": "启用开发者模式", + "settings.enable_exa": "启用Exa网页搜索", + "settings.enable_exa_desc": "在OpenWork编排器启动OpenCode时生效。", + "settings.enabled": "已启用", + "settings.engine_bundled": "内置(推荐)", + "settings.engine_bundled_hint": "内置引擎是最可靠的选项。使用系统", + "settings.engine_custom_binary": "自定义二进制文件", + "settings.engine_desc": "选择OpenCode在本地运行的方式。", + "settings.engine_runtime_label": "引擎运行时", + "settings.engine_source": "引擎来源", + "settings.engine_source_debug": "引擎来源", + "settings.engine_system_path": "系统安装(PATH)", + "settings.engine_title": "引擎", + "settings.environment.add_button": "新增变量", + "settings.environment.add_title": "新增环境变量", + "settings.environment.cancel": "取消", + "settings.environment.click_to_edit": "点击编辑", + "settings.environment.confirm_delete": "确定要删除 {key} 吗?应用更改后,agent 将不再看到这个变量。", + "settings.environment.close_editor": "关闭编辑器", + "settings.environment.delete": "删除", + "settings.environment.delete_title": "删除环境变量", + "settings.environment.delete_variable": "删除 {key}", + "settings.environment.deleting": "删除中……", + "settings.environment.description": "保存本机 agents、skills 和 MCP servers 使用的 API keys 与 tokens。Secret 只保留在这台设备上;应用更改后即可使用。", + "settings.environment.edit_title": "编辑环境变量", + "settings.environment.empty_body": "添加 ANTHROPIC_API_KEY、GOOGLE_API_KEY、ELEVENLABS_API_KEY、GITHUB_TOKEN 等服务凭证,供 agents 和 MCP servers 使用。", + "settings.environment.empty_title": "还没有环境变量", + "settings.environment.empty_value": "(空)", + "settings.environment.footer_hint": "OPENWORK_ 和 OPENCODE_ 键名保留给应用/运行时使用。OpenCode 运行时设置请通过 shell 配置。", + "settings.environment.override_hint": "OpenWork 启动前已经存在的环境变量,会优先于这里保存的值。", + "settings.environment.hide": "隐藏", + "settings.environment.hide_value": "隐藏 {key} 的值", + "settings.environment.key_hint": "仅允许字母、数字和下划线;不能以数字开头。", + "settings.environment.key_label": "名称", + "settings.environment.loading": "加载中……", + "settings.environment.remote_workspace_hint": "当前工作区是远程工作区。这里不会显示或编辑本机环境变量;请使用云端 LLM Providers,或直接配置 worker 所在主机。", + "settings.environment.apply_button": "应用更改", + "settings.environment.apply_blocked_active_tasks": "应用环境更改前,请先停止正在运行的任务。", + "settings.environment.apply_confirm_body": "OpenWork 会重启本机 agents,让它们使用最新环境变量。正在运行的本机任务可能会停止。", + "settings.environment.apply_no_local_workspace": "OpenWork 尚未连接到本机工作区。", + "settings.environment.apply_pending_body": "应用更改会重启本机 agents,并让最新值可用。", + "settings.environment.apply_pending_body_manual": "重启本机 agents 后,最新值才会可用。", + "settings.environment.apply_pending_title": "更改已保存,但尚未生效", + "settings.environment.apply_refresh_failed": "更改已生效,但 OpenWork 状态没有刷新。如果界面看起来过期,请重新打开应用。", + "settings.environment.apply_success": "环境变量更改已生效。", + "settings.environment.apply_title": "应用环境变量更改?", + "settings.environment.apply_unavailable": "只有桌面应用可以直接应用更改。", + "settings.environment.applying": "应用中……", + "settings.environment.restart_required": "已保存。应用更改后即可生效。", + "settings.environment.reveal": "查看", + "settings.environment.reveal_value": "查看 {key} 的值", + "settings.environment.save": "保存", + "settings.environment.saving": "保存中……", + "settings.environment.title": "环境变量", + "settings.environment.validation_duplicate": "已经存在同名变量。", + "settings.environment.validation_empty": "必须填写名称。", + "settings.environment.validation_reserved": "OPENWORK_ 和 OPENCODE_ 名称由 OpenWork/OpenCode 管理。", + "settings.environment.validation_shape": "请使用字母、数字和下划线,且不能以数字开头。", + "settings.environment.value_label": "值", + "settings.exa_restart_hint": "更改此设置后,请重启OpenCode或编排器。", + "settings.export": "导出", + "settings.export_failed": "导出运行时报告失败。", + "settings.export_unavailable": "此环境不支持导出。", + "settings.exported_debug_report": "已导出运行时报告JSON。", + "settings.failed": "失败", + "settings.failed_open_providers": "打开提供商失败", + "settings.feedback_badge": "我们阅读每条反馈", + "settings.feedback_desc": "告诉我们哪里体验流畅、哪里需要改进。反馈直达团队,帮助我们确定下一步优先级。", + "settings.feedback_title": "帮助塑造OpenWork", + "settings.group_global": "全局", + "settings.group_workspace": "工作区", + "settings.hide_titlebar": "隐藏标题栏", + "settings.hide_titlebar_desc": "隐藏窗口标题栏。适用于平铺式窗口管理器。", + "settings.join_discord": "加入Discord", + "settings.language": "语言", + "settings.language.description": "选择你的首选语言", + "settings.last_error": "最后错误", + "settings.last_stderr": "最后stderr", + "settings.last_stdout": "最后stdout", + "settings.loading_providers": "正在加载提供商…", + "settings.logs_on_host": "日志在主机上可用。", + "settings.managed_by_env": "由环境变量管理", + "settings.messaging_bridge_service": "消息桥接服务。", + "settings.messaging_section_desc": "在身份标签页中管理Telegram/Slack身份和绑定。", + "settings.messaging_section_title": "消息", + "settings.model": "模型", + "settings.model_behavior": "模型行为", + "settings.model_behavior_desc": "打开默认模型选择器,选择可用的推理配置。", + "settings.model_default": "默认", + "settings.model_description": "默认模型和运行时的思考控制。", + "settings.model_description_default": "从你配置的提供商中选择。此选择将用于新会话。", + "settings.model_description_session": "从你配置的提供商中选择。此选择适用于你的下一条消息。", + "settings.model_fallback": "备用", + "settings.model_reasoning": "推理", + "settings.model_section_desc": "选择默认对话模型并查看其推理方式。", + "settings.model_title": "模型", + "settings.no_access": "无权限", + "settings.no_active_workspace": "没有活动的本地工作区。", + "settings.no_audit_entries": "暂无审计记录。", + "settings.no_binary_selected": "未选择二进制文件。", + "settings.no_custom_path_set": "未设置自定义路径", + "settings.no_project_directory": "没有项目目录", + "settings.no_stderr": "尚未捕获stderr。", + "settings.no_stdout": "尚未捕获stdout。", + "settings.no_worker_directory": "没有项目目录", + "settings.no_worker_path": "工作区路径不可用", + "settings.nuke_confirm_dev": "此操作不可逆。将删除此开发构建的所有OpenWork数据以及所有隔离的OpenCode开发配置、认证、缓存、数据和状态,然后退出OpenWork。是否继续?", + "settings.nuke_confirm_prod": "此操作不可逆。将删除此开发构建的所有OpenWork数据以及所有隔离的OpenCode开发配置、认证、缓存、数据和状态,然后退出OpenWork。是否继续?", + "settings.nuke_failed": "移除OpenWork和OpenCode状态失败。", + "settings.nuke_hint": "仅在需要完全重置桌面应用及其OpenCode运行时状态时使用。", + "settings.nuke_success": "已移除OpenWork和OpenCode状态。OpenWork正在关闭…", + "settings.off": "关闭", + "settings.offline": "离线", + "settings.on": "开启", + "settings.open_deeplink_action": "正在打开…", + "settings.open_deeplink_button": "隐藏", + "settings.open_deeplink_desc": "粘贴OpenWork深度链接或共享URL以打开。", + "settings.open_deeplink_title": "打开深层链接", + "settings.opencode_cache": "OpenCode缓存", + "settings.opencode_cache_description": "修复用于启动引擎的缓存数据。可放心执行。", + "settings.opencode_engine_desc": "用于智能体、工具和模型提供商的本地运行时。", + "settings.opencode_engine_label": "OpenCode引擎", + "settings.opencode_engine_sidecar_desc": "本地执行sidecar。", + "settings.opencode_sdk_desc": "UI连接诊断。", + "settings.opencode_sdk_title": "OpenCode引擎", + "settings.opencode_section_label": "OpenCode", + "settings.opencode_url_unavailable": "基础URL不可用", + "settings.opening": "打开深层链接", + "settings.openwork_config_sidecar_desc": "配置和审批sidecar。", + "settings.openwork_diagnostics_title": "OpenWork服务器诊断", + "settings.openwork_server_desc": "用于应用同步、工作者和远程连接的会话控制平面。", + "settings.openwork_server_label": "OpenWork服务器", + "settings.pending_permissions": "待处理的权限", + "settings.production_mode_badge": "生产模式", + "settings.provider_default_desc": "使用模型内置的默认推理行为。", + "settings.provider_default_label": "提供商默认", + "settings.provider_source_config": "配置", + "settings.provider_source_custom": "自定义", + "settings.provider_source_env": "环境变量", + "settings.providers_desc": "连接用于模型和工具的服务。", + "settings.providers_title": "提供商", + "settings.quit_hint": "OpenWork在清理后立即退出,以便下次启动时从此模式的全新本地状态开始。", + "settings.recent_events": "最近的事件", + "settings.reconnect_failed": "重新连接失败。请检查服务器URL/令牌后重试。", + "settings.reconnect_server": "正在重新连接…", + "settings.reconnect_server_failed": "重新连接OpenWork服务器失败。", + "settings.reconnected": "已重新连接OpenWork服务器。", + "settings.reconnecting": "正在重新连接…", + "settings.removing_containers": "正在移除容器…", + "settings.removing_local_state": "正在移除本地状态…", + "settings.repair_cache": "修复缓存", + "settings.repairing_cache": "正在修复缓存", + "settings.report_issue": "报告问题", + "settings.reset": "重置", + "settings.reset_app_data": "重置应用数据", + "settings.reset_app_data_description": "更彻底的方式。清除OpenWork缓存和应用数据。", + "settings.reset_app_data_title": "重置应用数据", + "settings.reset_app_data_warning": "清除此设备上的OpenWork缓存和应用数据。", + "settings.reset_button": "重置", + "settings.reset_cancel": "取消", + "settings.reset_config_defaults": "正在重置…", + "settings.reset_config_failed": "重置应用配置失败。", + "settings.reset_confirm_button": "重置并重启", + "settings.reset_confirmation_hint": "输入 {resetWord} 以确认。OpenWork将重启。", + "settings.reset_confirmation_label": "确认", + "settings.reset_confirmation_placeholder": "输入RESET", + "settings.reset_onboarding": "重置入门", + "settings.reset_onboarding_description": "清除OpenWork偏好设置并重启应用。", + "settings.reset_onboarding_title": "重置入门", + "settings.reset_onboarding_warning": "清除OpenWork本地偏好设置和工作区入门标记。", + "settings.reset_openwork_desc_dev": "在开发模式下,仅清除openwork-dev-data中的隔离OpenCode开发状态。", + "settings.reset_openwork_desc_prod": "在开发模式下,仅清除openwork-dev-data中的隔离OpenCode开发状态。", + "settings.reset_openwork_title": "重置OpenWork + OpenCode状态", + "settings.reset_recovery_desc": "清除数据或重新启动设置流程。", + "settings.reset_recovery_title": "重置和恢复", + "settings.reset_requires_confirm": "需要输入RESET,应用将重启。", + "settings.reset_startup": "重置默认启动模式", + "settings.reset_startup_pref": "重置启动偏好", + "settings.reset_stop_active_runs": "重置前停止活动运行。", + "settings.resetting": "正在重置…", + "settings.restart_blocked_message": "OpenWork需要重启以完成此更新。为避免中断当前工作,安装已暂停,直到你的活动运行完成或你手动停止它们。", + "settings.restart_failed": "重启失败。请检查日志后重试。", + "settings.restart_opencode": "正在重启…", + "settings.restart_openwork_server": "正在重启…", + "settings.restart_server_failed": "重启本地服务器失败。", + "settings.restarted": "本地服务器已重启。", + "settings.restarting": "正在重启…", + "settings.reveal_config": "显示配置", + "settings.reveal_config_failed": "显示工作区配置失败。", + "settings.reveal_config_requires_desktop": "显示配置需要桌面应用", + "settings.revealed_workspace_config": "已显示工作区配置。", + "settings.run_sandbox_probe": "正在运行探测…", + "settings.running_probe": "正在运行探测…", + "settings.runtime_applies_hint": "将在引擎下次启动或重新加载时生效。", + "settings.runtime_debug_desc": "可读的诊断快照,支持一键导出。", + "settings.runtime_debug_title": "运行时调试报告", + "settings.runtime_desc": "本地引擎和OpenWork服务器的状态。", + "settings.runtime_direct": "直连(OpenCode)", + "settings.runtime_title": "运行时", + "settings.sandbox_error": "错误", + "settings.sandbox_export_hint": "使用上方运行时调试报告中的导出功能", + "settings.sandbox_probe_desc": "运行临时Docker沙箱启动检查并", + "settings.sandbox_probe_errors": "沙箱探测完成,存在错误。", + "settings.sandbox_probe_failed": "沙箱探测失败。", + "settings.sandbox_probe_success": "沙箱探测成功。导出调试报告以获取支持。", + "settings.sandbox_probe_title": "沙箱探测", + "settings.sandbox_ready": "就绪", + "settings.sandbox_requires_desktop": "沙箱探测需要桌面应用", + "settings.sandbox_result": "结果:{status}", + "settings.sandbox_run_id": "运行ID:{id}", + "settings.sandbox_stop_runs_hint": "探测前请停止活动运行", + "settings.search_models": "搜索模型…", + "settings.select_binary": "选择OpenCode二进制文件", + "settings.select_workspace_first": "请先选择本地工作区再显示配置。", + "settings.send_feedback": "发送反馈", + "settings.service_restarts_desc": "无需离开此页面即可重启特定主机服务", + "settings.service_restarts_title": "服务重启", + "settings.session_model": "模型", + "settings.show_model_reasoning": "显示模型推理", + "settings.show_model_reasoning_desc": "当模型提供推理过程时,在界面中展开推理轨迹。", + "settings.showing_models": "显示{count} / {total}", + "settings.sidecar_config_unavailable": "Sidecar配置不可用", + "settings.startup": "启动", + "settings.startup_local": "启动本地服务器", + "settings.startup_not_set": "连接服务器", + "settings.startup_remote_warning": "启动偏好当前为远程。引擎设置", + "settings.startup_reset_hint": "这将清除你保存的偏好并显示连接", + "settings.startup_server": "连接服务器", + "settings.startup_title": "启动", + "settings.stop_local_server": "停止本地服务器", + "settings.stop_runs_before_cleanup": "清理前请停止活动运行", + "settings.stop_runs_before_reset_config": "重置配置前请停止活动运行", + "settings.stop_runs_to_reset": "停止活动运行以重置", + "settings.switch": "切换", + "settings.tab_advanced": "高级", + "settings.tab_appearance": "外观", + "settings.tab_cloud": "Cloud", + "settings.tab_debug": "调试", + "settings.tab_description_advanced": "检查运行时健康状态、连接状态和面向开发者的控制项。", + "settings.tab_description_appearance": "调整OpenWork在桌面、系统主题和应用外观方面的显示效果。", + "settings.tab_description_debug": "查看运行时诊断信息、日志和底层调试工具。", + "settings.tab_description_den": "管理OpenWork Cloud连接、托管工作区和工作区访问。", + "settings.tab_description_extensions": "管理此工作区的MCP应用和OpenCode插件。", + "settings.tab_description_general": "连接提供商、选择默认模型、授权文件夹,以及控制所选OpenWork工作区和运行时连接。", + "settings.tab_description_environment": "保存本机 agents、skills 和 MCP servers 使用的 API keys 与 tokens。Secret 只保留在这台设备上。", + "settings.tab_description_messaging": "从工作区设置中配置路由身份和收件箱行为。", + "settings.tab_description_model": "调整默认模型、运行时行为和助手输出设置。", + "settings.tab_description_recovery": "修复迁移状态、重置工作区默认值和恢复本地设置。", + "settings.tab_description_skills": "在设置中浏览、编辑和安装skills。", + "settings.tab_description_updates": "通过静默后台检查和安装控制保持应用为最新版本。", + "settings.tab_environment": "环境变量", + "settings.tab_extensions": "扩展", + "settings.tab_general": "设置", + "settings.tab_messaging": "消息", + "settings.tab_model": "模型", + "settings.tab_recovery": "恢复", + "settings.tab_skills": "Skills(技能)", + "settings.tab_updates": "更新", + "settings.theme_dark": "深色", + "settings.theme_light": "浅色", + "settings.theme_system": "系统", + "settings.theme_system_hint": "系统模式自动跟随你的操作系统偏好。", + "settings.toolbar_ready_to_install": "准备安装", + "settings.update": "更新", + "settings.update_available": "可用更新:v", + "settings.update_available_version": "可用更新:v{version}", + "settings.update_check_button": "检查", + "settings.update_check_failed": "更新检查失败", + "settings.update_checking": "检查中...", + "settings.update_download_button": "下载", + "settings.update_downloading": "下载中...", + "settings.update_error": "更新检查失败", + "settings.update_install_button": "安装并重启", + "settings.update_last_checked": "上次检查{time}", + "settings.update_published": "发布时间{date}", + "settings.update_ready": "准备安装:v", + "settings.update_ready_version": "准备安装:v{version}", + "settings.update_uptodate": "已是最新", + "settings.updates": "更新", + "settings.updates_desc": "保持OpenWork为最新版本。", + "settings.updates_desktop_only": "更新仅在桌面应用中可用。", + "settings.updates_not_supported": "此环境不支持更新。", + "settings.updates_title": "更新", + "settings.version": "版本", + "settings.versions_desc": "Sidecar + 桌面版构建信息。", + "settings.versions_title": "版本", + "settings.window_appearance_desc": "自定义窗口外观。", + "settings.worker_id_label": "工作区{id}", + "settings.worker_unresolved": "工作区{runtimeWorkspaceId}", + "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_title": "工作区配置", + "settings.workspace_debug_events_label": "工作区调试事件", + "settings.workspace_fallback_name": "工作区", + "share.active_cloud_org": "当前Cloud组织", + "share.back_hint": "返回分享选项", + "share.chooser_subtitle": "选择分享此工作区的方式。", + "share.close_hint": "关闭", + "share.cloud_signin_note": "OpenWork Cloud将在浏览器中打开,登录后自动返回。", + "share.collaborator_hint": "日常访问,无需权限审批。", + "share.connect_messaging_desc": "从Slack、Telegram等平台使用此工作区。", + "share.connect_messaging_title": "连接消息平台", + "share.connection_details_label": "连接详情", + "share.copy_hint": "复制", + "share.copy_link_hint": "复制链接", + "share.create_template_link": "创建模板链接", + "share.credentials_disabled_hint": "启用远程访问并点击保存以重启工作区,显示此工作区的实时连接详情。", + "share.field_password": "密码", + "share.field_worker_url": "工作区URL", + "share.hide_password": "隐藏密码", + "share.included_in_template": "此模板包含的内容", + "share.option_access_desc": "显示从其他设备访问此运行中工作区所需的实时连接详情。", + "share.option_access_title": "远程访问工作区", + "share.option_public_desc": "创建任何人都可以使用的分享链接。", + "share.option_public_title": "公开模板", + "share.option_team_title": "与团队分享", + "share.option_template_desc": "打包此配置,让他人可以从相同环境开始。", + "share.optional_collaborator": "可选的协作者访问", + "share.public_intro": "将此工作区作为公开模板链接分享。", + "share.publishing": "正在发布…", + "share.regenerate_link": "重新生成链接", + "share.remote_access_desc": "默认关闭。仅在需要从其他设备访问此工作区时开启。", + "share.remote_access_disabled": "远程访问当前已关闭。", + "share.remote_access_enabled": "远程访问当前已开启。", + "share.remote_access_title": "远程访问", + "share.remote_save": "保存", + "share.remote_save_busy": "正在保存…", + "share.reveal_password": "显示密码", + "share.save_to_team": "保存到团队", + "share.saving": "正在保存…", + "share.setup": "配置", + "share.sign_in_to_share": "登录以与团队分享", + "share.subtitle_access": "显示从其他设备访问此工作区所需的实时连接详情。", + "share.team_intro": "将此模板保存到当前OpenWork Cloud组织,队友可稍后从Cloud设置中打开。", + "share.template_intro": "分享可复用的配置,无需授予对运行中工作区的实时访问。", + "share.template_item_config": "命令和配置", + "share.template_item_config_desc": "可复用的命令和OpenWork/OpenCode配置。", + "share.template_item_settings": "工作区设置", + "share.template_item_settings_desc": "共享的工作区配置文件和默认行为。", + "share.template_item_skills": "包含的skills", + "share.template_item_skills_desc": "保存在此工作区中的自定义skills。", + "share.template_name_label": "模板名称", + "share.title": "分享工作区", + "share.view_access": "远程访问工作区", + "share.warning_basic": "仅与信任的人分享。这些凭据授予此工作区的实时访问权限。", + "share.warning_full": "这些凭据授予此工作区的实时访问权限。远程分享此工作区可能允许网络上的任何人控制你的工作区。", + "share.workspace_fallback": "工作区", + "share.workspace_template_desc": "分享核心配置和工作区默认值。", + "share.workspace_template_title": "工作区模板", + "share_skill_destination.add_to_workspace": "添加到工作区", + "share_skill_destination.adding": "正在添加…", + "share_skill_destination.confirm_busy": "正在添加skill…", + "share_skill_destination.confirm_button": "将skill添加到工作区", + "share_skill_destination.connect_remote": "连接远程工作区", + "share_skill_destination.connect_remote_desc": "连接OpenWork主机,然后从列表中选择以导入此skill。", + "share_skill_destination.connect_remote_hint": "连接远程工作区以导入skill", + "share_skill_destination.create_worker": "新建工作区", + "share_skill_destination.create_worker_desc": "打开工作区创建流程,新工作区就绪后添加此skill。", + "share_skill_destination.create_worker_hint": "创建新工作区以导入skill", + "share_skill_destination.current_badge": "当前", + "share_skill_destination.existing_workers": "现有工作区", + "share_skill_destination.fallback_skill_name": "共享skill", + "share_skill_destination.footer_idle": "选择一个工作区以继续。", + "share_skill_destination.footer_selected": "已选择工作区:", + "share_skill_destination.local_badge": "本地", + "share_skill_destination.more_options": "更多选项", + "share_skill_destination.new_destination": "新建目标", + "share_skill_destination.no_workers": "暂无就绪的工作区。请创建或连接远程工作区以安装此skill。", + "share_skill_destination.remote_badge": "远程", + "share_skill_destination.sandbox_badge": "沙箱", + "share_skill_destination.selected_badge": "已选择", + "share_skill_destination.selected_hint": "已选择。请确认下方的目标后提交。", + "share_skill_destination.skill_label": "共享skill", + "share_skill_destination.subtitle": "选择现有工作区或新建一个,然后导入此共享skill。", + "share_skill_destination.title": "这个skill放在哪里?", + "share_skill_destination.trigger_label": "触发器", + "sidebar.active": "活动", + "sidebar.add_workspace": "添加新工作区", + "sidebar.collapse": "收起", + "sidebar.connect_remote": "连接远程", + "sidebar.delete_session": "删除会话", + "sidebar.drag_reorder": "拖动排序", + "sidebar.edit_connection": "编辑连接", + "sidebar.expand": "展开", + "sidebar.import_config": "导入配置", + "sidebar.needs_attention": "需要关注", + "sidebar.new_worker": "新建工作区", + "sidebar.no_workspaces": "此会话中暂无工作区。添加一个以开始。", + "sidebar.progress": "进度", + "sidebar.show_fewer": "收起", + "sidebar.show_more": "显示更多{count}个", + "sidebar.stop_sandbox": "停止沙箱", + "sidebar.switch": "切换", + "sidebar.test_connection": "测试连接", + "skills.add_custom_repo": "添加自定义GitHub仓库", + "skills.add_git_repo": "添加Git仓库", + "skills.add_openwork_hub": "添加OpenWork Hub", + "skills.available_from_hub": "从Hub获取", + "skills.catalog_search_placeholder": "搜索已安装、团队和中心skills", + "skills.cloud_add_skill": "添加skill", + "skills.cloud_choose_org_detail": "在Cloud面板中选择活动组织,然后刷新此列表。", + "skills.cloud_choose_org_hint": "在设置 → Cloud中选择组织以加载团队skills。", + "skills.cloud_footer_label": "团队", + "skills.cloud_hub_label": "Hub:{name}", + "skills.cloud_install_need_server": "连接具有skills写入权限的OpenWork服务器以安装团队skills。", + "skills.cloud_installed": "已在此工作区安装{name}。", + "skills.cloud_installing": "正在安装{title}…", + "skills.cloud_installing_short": "安装中", + "skills.cloud_no_search_matches": "没有匹配的skills。", + "skills.cloud_org_empty": "暂无可用的组织skills。", + "skills.cloud_org_fallback": "OpenWork Cloud", + "skills.cloud_org_load_failed": "加载组织skills失败。", + "skills.cloud_refresh": "刷新团队skills", + "skills.cloud_section_subtitle": "通过OpenWork Cloud与你共享的skills — 包括你可访问的团队skill中心。", + "skills.cloud_section_title": "来自你的组织", + "skills.cloud_shared_org": "组织", + "skills.cloud_shared_public": "公开", + "skills.cloud_sign_in": "登录Cloud", + "skills.cloud_sign_in_hint": "登录OpenWork Cloud以浏览团队和组织skills。", + "skills.copy_link_failed": "复制链接失败", + "skills.create_in_chat": "在聊天中创建skill", + "skills.desktop_required": "Skills管理需要桌面应用。", + "skills.enter_plugin_name": "输入插件包名称。", + "skills.failed_load_active": "加载活动插件失败。", + "skills.failed_load_opencode": "加载opencode.json失败", + "skills.failed_parse_opencode": "解析opencode.json失败", + "skills.failed_to_load": "加载skills失败", + "skills.failed_update_opencode": "更新opencode.json失败", + "skills.filter_all": "全部", + "skills.filter_cloud": "团队", + "skills.filter_hub": "Hub", + "skills.filter_installed": "已安装", + "skills.from_repo": "来自{owner}/{repo}", + "skills.github_repo_hint": "输入owner/repo格式的GitHub仓库。", + "skills.host_mode_only": "仅本地工作区", + "skills.host_only_error": "Skills管理需要本地工作区或已连接的OpenWork服务器。", + "skills.hub_desc": "浏览来自GitHub支持的Hub的共享skills,并将其添加到此工作区。", + "skills.hub_label": "Hub", + "skills.import": "导入", + "skills.import_failed": "导入失败({status})", + "skills.import_local": "导入本地skill", + "skills.import_local_hint": "将现有skill文件夹复制到此工作区。", + "skills.import_local_skill": "导入本地skill", + "skills.imported": "已导入。", + "skills.install": "安装", + "skills.install_failed": "Skills安装失败。", + "skills.install_name_title": "安装{name}", + "skills.install_skill_creator": "安装skill creator", + "skills.install_skill_creator_hint": "此skill可让你在聊天中创建其他skills。", + "skills.installed": "已安装的skills", + "skills.installed_desc": "已安装的skills存储在此工作区中,可以编辑或分享。", + "skills.installed_label": "已安装", + "skills.installed_status": "已安装", + "skills.installing": "添加skill", + "skills.installing_prefix": "正在安装{name}…", + "skills.installing_skill_creator": "正在安装skill creator...", + "skills.link_copied": "链接已复制", + "skills.loading": "正在加载…", + "skills.no_description": "暂无描述。", + "skills.no_hub_repo_label": "未选择Hub仓库", + "skills.no_hub_repo_selected": "暂无可用的Hub skills。", + "skills.no_hub_skills": "未选择Hub仓库。添加GitHub仓库以浏览skills。", + "skills.no_opencode_found": "尚未找到opencode.json。添加插件以创建一个。", + "skills.no_opencode_workspace": "此工作区中还没有opencode.json。", + "skills.no_skills": "在`.opencode/skills`、`.claude/skills`或`~/.agents/skills`中未检测到skills。", + "skills.no_skills_found": "还没有找到skills。", + "skills.owner_label": "所有者", + "skills.owner_repo_required": "所有者和仓库为必填项。", + "skills.pick_project_first": "先选择一个项目文件夹。", + "skills.pick_project_for_active": "选择项目文件夹以加载活动插件。", + "skills.pick_project_for_plugins": "选择项目文件夹以管理项目插件。", + "skills.pick_workspace_first": "先选择一个工作区文件夹。", + "skills.plugin_already_listed": "插件已在opencode.json中列出。", + "skills.plugin_management_host_only": "插件管理需要桌面应用。", + "skills.plugins_host_only": "插件仅在桌面应用中可用。", + "skills.ref_label": "引用(分支/标签/提交)", + "skills.refresh": "刷新", + "skills.refresh_hub": "刷新Hub", + "skills.refresh_hub_title": "刷新Hub目录", + "skills.remove_saved_repo": "移除已保存的仓库", + "skills.repo_label": "仓库", + "skills.reveal_failed": "打开skills文件夹失败。", + "skills.reveal_folder": "打开skills文件夹", + "skills.reveal_folder_hint": "在文件管理器中打开skills目录。", + "skills.save_and_load": "保存并加载", + "skills.save_failed": "保存skill失败。", + "skills.select_skill_folder": "选择skill文件夹", + "skills.share_back": "返回", + "skills.share_chooser_subtitle": "保存到OpenWork Cloud组织,或发布公开安装链接。", + "skills.share_close": "关闭", + "skills.share_copy_link": "复制", + "skills.share_done": "完成", + "skills.share_option_public_desc": "创建任何人都可以使用的安装链接。", + "skills.share_option_public_title": "公开链接", + "skills.share_option_team_desc": "将此skill添加到当前OpenWork Cloud组织。", + "skills.share_option_team_title": "与团队分享", + "skills.share_public_create": "创建链接", + "skills.share_public_creating": "发布中…", + "skills.share_public_intro": "发布公开链接。拥有URL的任何人都可以安装此skill。", + "skills.share_public_regenerate": "重新生成链接", + "skills.share_publisher_label": "发布方", + "skills.share_subtitle_public": "拥有链接的任何人都可以安装此skill。", + "skills.share_subtitle_team": "保存在组织中供队友使用。", + "skills.share_team_choose_org": "请先在设置 → Cloud中选择组织。", + "skills.share_team_hub_label": "添加到skill中心(可选)", + "skills.share_team_hub_none": "仅组织 — 不加入中心", + "skills.share_team_hubs_loading": "正在加载中心…", + "skills.share_team_intro": "保存到当前组织,队友可从Cloud安装。", + "skills.share_team_org_fallback": "当前Cloud组织", + "skills.share_team_save": "保存到团队", + "skills.share_team_saving": "保存中…", + "skills.share_team_sign_in": "登录后与团队分享", + "skills.share_team_sign_in_hint": "浏览器将打开OpenWork Cloud。登录后返回此处。", + "skills.share_team_success": "已保存到{org}。队友可从组织skill安装。", + "skills.share_title": "分享skill", + "skills.shown_count": "显示{count}个", + "skills.skill_creator_already_installed": "Skill creator已经安装过了。", + "skills.skill_creator_installed": "Skill creator已安装。", + "skills.skill_load_failed": "加载skill失败。", + "skills.source_label": "来源", + "skills.subtitle": "管理此工作区的skills。", + "skills.title": "Skills(技能)", + "skills.trigger_label": "触发器:{trigger}", + "skills.uninstall": "卸载", + "skills.uninstall_failed": "卸载skill失败。", + "skills.uninstall_title": "卸载skill?", + "skills.uninstall_warning": "这将永久从工作区删除`{name}` skill。", + "skills.uninstalled": "Skill已删除。", + "skills.unknown_error": "未知错误", + "skills.worker_profile_desc": "Skills是此工作区的核心能力。从Hub发现、管理已安装的skills,并在聊天中直接创建新的skills。", + "status.back": "返回上一页", + "status.connected": "已连接", + "status.connecting": "正在连接", + "status.creating_task": "正在创建新任务", + "status.creating_workspace": "正在创建工作区", + "status.developer_mode": "开发者模式", + "status.disconnected": "已断开", + "status.disconnected_hint": "打开设置以重新连接", + "status.disconnected_label": "已断开", + "status.disconnecting": "正在断开连接", + "status.docs": "文档", + "status.feedback": "反馈", + "status.idle": "空闲", + "status.installing_opencode": "正在安装OpenCode", + "status.limited_hint": "重新连接以恢复完整OpenWork功能", + "status.limited_mcp_hint": "{count}个MCP已连接 · 重新连接以恢复完整功能", + "status.limited_mode": "受限模式", + "status.live": "实时", + "status.loading_session": "正在加载会话", + "status.mcp_connected": "{count}个MCP已连接", + "status.open_docs": "打开文档", + "status.openwork_ready": "OpenWork就绪", + "status.providers_connected": "{count}个提供商已连接", + "status.ready_for_tasks": "可接受新任务", + "status.reloading_engine": "正在重新加载引擎", + "status.restarting_engine": "正在重启引擎", + "status.running": "运行中", + "status.send_feedback": "发送反馈", + "status.settings": "设置", + "status.starting_engine": "正在启动引擎", + "system.cache_repair_requires_desktop": "缓存修复需要桌面应用。", + "system.docker_cleanup_requires_desktop": "Docker清理需要桌面应用。", + "system.reload_body_agents": "OpenCode在启动时加载Agent。重新加载引擎以使更新的Agent可用。", + "system.reload_body_commands": "OpenCode在启动时加载命令。重新加载引擎以使更新的命令可用。", + "system.reload_body_config": "OpenCode在启动时读取opencode.json。重新加载引擎以应用配置变更。", + "system.reload_body_default": "OpenWork检测到需要重新加载OpenCode实例的变更。", + "system.reload_body_mcp": "OpenCode在启动时加载MCP服务器。重新加载引擎以激活新连接。", + "system.reload_body_mixed": "OpenWork检测到OpenCode配置变更。重新加载引擎以应用。", + "system.reload_body_plugins": "OpenCode在启动时加载npm插件。重新加载引擎以应用opencode.json变更。", + "system.reload_body_skills": "OpenCode会缓存技能发现/状态。重新加载引擎以使新安装的技能可用。", + "system.reload_failed": "重新加载引擎失败。", + "system.reload_required": "需要重新加载", + "system.reload_unavailable": "此工作区不支持重新加载。", + "system.stop_active_runs_before_reset": "请先停止活跃的运行再重置。", + "system.stop_runs_before_update": "安装更新前请先停止活跃的运行。", + "system.updates_not_supported": "此环境不支持更新。", + "time.hours_ago": "{count}小时前", + "time.just_now": "刚刚", + "time.minutes_ago": "{count}分钟前", + "time.seconds_ago": "{count}秒前", + "workspace.loading_tasks": "正在加载任务…", + "workspace.local_badge": "本地", + "workspace.new_task_inline": "+ 新建任务", + "workspace.no_tasks": "暂无任务。", + "workspace.remote_badge": "远程", + "workspace.rename_description": "更新侧边栏中显示的名称。", + "workspace.rename_label": "工作区名称", + "workspace.rename_placeholder": "设计团队工作区", + "workspace.rename_title": "编辑工作区名称", + "workspace.sandbox_badge": "沙箱", + "workspace.selected": "已选择", + "workspace.switch": "切换", + "workspace.switching_status_connecting": "正在检查连接", + "workspace.switching_status_loading": "正在加载最近任务", + "workspace.switching_status_preparing": "正在准备", + "workspace.switching_subtitle": "马上带你回到最近的工作。", + "workspace.switching_title": "正在打开{name}", + "workspace.switching_title_unknown": "正在打开工作区", + "workspace_list.add_workspace": "添加工作区", + "workspace_list.connect_remote": "连接远程工作区", + "workspace_list.connecting": "正在连接…", + "workspace_list.delete_session": "删除会话", + "workspace_list.desktop_only_hint": "本地工作区需在桌面应用中创建。", + "workspace_list.edit_connection": "编辑连接", + "workspace_list.edit_name": "编辑名称", + "workspace_list.hide_child_sessions": "隐藏子会话", + "workspace_list.import_config": "导入配置", + "workspace_list.new_workspace": "新建工作区", + "workspace_list.recover": "恢复", + "workspace_list.remove_workspace": "移除工作区", + "workspace_list.rename_session": "重命名会话", + "workspace_list.reveal_explorer": "在资源管理器中显示", + "workspace_list.reveal_finder": "在Finder中显示", + "workspace_list.session_actions": "会话操作", + "workspace_list.share": "分享…", + "workspace_list.show_child_sessions": "显示子会话", + "workspace_list.show_more": "显示更多{count}个", + "workspace_list.show_more_fallback": "显示更多", + "workspace_list.test_connection": "测试连接", + "workspace_list.workspace_fallback": "工作区", + "workspace_list.workspace_options": "工作区选项", + "workspace_sidebar.close_sidebar": "关闭侧边栏", + "workspace_sidebar.collapse_sidebar": "收起侧边栏", + "workspace_sidebar.configuration": "配置", + "workspace_sidebar.expand_sidebar": "展开侧边栏", + "workspace_sidebar.extensions": "扩展", + "workspace_sidebar.messaging": "消息", +} as const; diff --git a/apps/app/src/index.react.tsx b/apps/app/src/index.react.tsx new file mode 100644 index 0000000000..ba443c0e5b --- /dev/null +++ b/apps/app/src/index.react.tsx @@ -0,0 +1,51 @@ +/** @jsxImportSource react */ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, HashRouter } from "react-router-dom"; + +import { initializeDenBootstrapConfig } from "./app/lib/den"; +import { getOpenWorkDeployment } from "./app/lib/openwork-deployment"; +import { bootstrapTheme } from "./app/theme"; +import { isDesktopRuntime } from "./app/utils"; +import { initLocale } from "./i18n"; +import { getReactQueryClient } from "./react-app/infra/query-client"; +import { + createDefaultPlatform, + PlatformProvider, +} from "./react-app/kernel/platform"; +import { AppProviders } from "./react-app/shell/providers"; +import { AppRoot } from "./react-app/shell/app-root"; +import { startDeepLinkBridge } from "./react-app/shell/startup-deep-links"; +import "./app/index.css"; + +bootstrapTheme(); +initLocale(); +startDeepLinkBridge(); +await initializeDenBootstrapConfig(); + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Root element not found"); +} + +root.dataset.openworkDeployment = getOpenWorkDeployment(); + +const platform = createDefaultPlatform(); +const queryClient = getReactQueryClient(); +const Router = isDesktopRuntime() ? HashRouter : BrowserRouter; + +ReactDOM.createRoot(root).render( + + + + + + + + + + + , +); diff --git a/apps/app/src/react-app/ARCHITECTURE.md b/apps/app/src/react-app/ARCHITECTURE.md new file mode 100644 index 0000000000..15fd2e3ac2 --- /dev/null +++ b/apps/app/src/react-app/ARCHITECTURE.md @@ -0,0 +1,123 @@ +# React App Architecture (`src/react-app/`) + +This document captures the domain-based layout for the React runtime being migrated into +`apps/app`. The Solid runtime still ships by default; the React tree is being built up domain by +domain so it can take over in a single hard cut. + +## Top-level layout + +```text +src/react-app/ +├── shell/ App bootstrap, providers composition, startup effects +├── kernel/ App-wide state + provider contracts (replaces app/context/*) +├── infra/ React-only runtime infra +├── design-system/ Reusable presentational primitives + small modal primitives +└── domains/ Feature-scoped code, one folder per product domain + ├── session/ + │ ├── chat/ Route chrome (status bar, question/permission surfaces) + │ ├── surface/ Transcript, composer, markdown, tool-call, debug panel + │ ├── sync/ Session state plumbing (store, runtime, chat adapter) + │ └── modals/ Model picker, question, rename-session + ├── workspace/ Create + share + rename workspace flows + ├── settings/ + │ ├── state/ Settings-scoped hooks/providers + │ ├── pages/ Plugins, extensions, config, ... (tab bodies) + │ └── modals/ Reset modal, ... + ├── connections/ + │ └── modals/ Add-MCP, Chrome-setup, ... + ├── bundles/ Import / start / skill-destination flows + agnostic re-exports + └── shell-feedback/ Status toasts, reload banner, top-right notifications +``` + +## Why domains + +The Solid tree grew pseudo-flat (`app/components/*`, `app/context/*`, `app/pages/*`). +The React tree uses explicit domain ownership so every feature has one obvious home. + +- `session/` owns everything the session route renders, including the state layer under `sync/`. +- `workspace/` owns every workspace-modal flow, so create/share/rename live together. +- `settings/` owns settings state, the full settings shell once it lands, and each tab body as a + stateless page under `pages/`. +- `connections/` owns MCP and provider auth UI. +- `bundles/` owns import/start/destination modals plus re-exports of the framework-agnostic + helpers. +- `shell-feedback/` owns toasts and notifications that the shell shows on top of everything. + +Cross-domain imports go through module boundaries, not a shared blob. + +## Data flow + +```text +┌────────────────────────────────────────────────────────────┐ +│ src/index.react.tsx │ React entry +└────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ react-app/shell/providers.tsx (AppProviders composition) │ +│ ServerProvider │ +│ └─ GlobalSDKProvider │ +│ └─ GlobalSyncProvider │ +│ └─ LocalProvider │ +│ └─ (QueryClientProvider + PlatformProvider │ +│ wrap AppProviders in index.react.tsx) │ +└────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ react-app/shell/app-root.tsx │ Route root (placeholder today) +└────────────────────────────────────────────────────────────┘ + │ + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + domains/session domains/workspace domains/settings + │ │ │ + ▼ ▼ ▼ + surface/, sync/, create-/share-/ pages/ (plugins, + chat/, modals/ rename-*.tsx config, ...), + modals/, state/ +``` + +## State ownership + +- `react-app/kernel/store.ts`: Zustand store, the React replacement for the Solid context bag. + Domain selectors in `kernel/selectors.ts`. +- `react-app/kernel/{server,global-sdk,global-sync,local}-provider.tsx`: the Solid provider stack + re-expressed in React context. Same composition order as `app/entry.tsx`. +- `react-app/kernel/platform.tsx`: `PlatformProvider` + `createDefaultPlatform()` helper + (Tauri-vs-web). +- `react-app/kernel/system-state.ts`: `useSystemState()` for reload + reset modal state. +- `react-app/kernel/model-config.ts`: framework-agnostic model parse/serialize helpers plus + `useDefaultModel()` (the heavier workspace overrides and auto-compact logic still live in + Solid and will be ported with the settings shell). +- `react-app/infra/query-client.ts`: TanStack Query singleton. +- Feature-specific state that is tightly coupled to one domain lives inside that domain + (`domains/session/sync/`, `domains/settings/state/`). + +## Framework-agnostic boundary + +Anything that is already Solid-free stays under `src/app/` and is re-exported from the React +tree when a domain-scoped import path is clearer. Examples: + +- `app/lib/*` (opencode, tauri, den, openwork-server, ...) — consumed directly by React. +- `app/types.ts`, `app/constants.ts`, `app/theme.ts`, `app/utils/*` — shared across both runtimes. +- `app/session/composer-tools.ts` — shared session helpers. +- `app/bundles/{types,schema,url-policy,sources,apply,publish,skill-org-publish,index}` — bundle + logic consumed by both runtimes; React side re-exports from `domains/bundles/*`. + +## Porting pattern + +1. **Move, don't rewrite, for framework-free files.** Re-export from the React domain folder so + Solid can keep importing through the old path during the transition. +2. **Invert contexts to props** when porting pages that depended on Solid context. The React + version takes the data/actions it needs as props; the parent wires it up. This lets domain + pages land before their provider layer is fully ported. +3. **Each port is its own commit.** The Solid runtime stays green the entire time; the React + entry (`src/index.react.tsx`) builds and typechecks after every commit. + +## Active shims + +During the transition, files under `src/react/**` are thin re-exports pointing at the new +`react-app/**` locations. They exist so the Solid runtime (which imports from the old paths via +`ReactIsland`) keeps compiling. All `src/react/**` files are deleted in the final Phase 8 cutover +along with the Solid tree under `src/app/**`. diff --git a/apps/app/src/react-app/design-system/button.tsx b/apps/app/src/react-app/design-system/button.tsx new file mode 100644 index 0000000000..53db2777c1 --- /dev/null +++ b/apps/app/src/react-app/design-system/button.tsx @@ -0,0 +1,37 @@ +/** @jsxImportSource react */ +import { forwardRef, type ButtonHTMLAttributes } from "react"; + +export type ButtonProps = ButtonHTMLAttributes & { + variant?: "primary" | "secondary" | "ghost" | "outline" | "danger"; +}; + +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", +}; + +export const Button = forwardRef( + function Button({ variant, className, type, disabled, ...rest }, ref) { + const effectiveVariant = variant ?? "primary"; + return ( + + + + + + + ); +} diff --git a/apps/app/src/react-app/design-system/provider-icon.tsx b/apps/app/src/react-app/design-system/provider-icon.tsx new file mode 100644 index 0000000000..ce3f2dca66 --- /dev/null +++ b/apps/app/src/react-app/design-system/provider-icon.tsx @@ -0,0 +1,91 @@ +/** @jsxImportSource react */ + +export type ProviderIconProps = { + providerId?: string | null; + /** + * Optional provider display name. When the id is an opaque cloud id + * (e.g. a uuid), the name is what tells us whether it's an Anthropic / + * OpenAI / OpenCode provider. Ported from dev 022b68a8 ("key cloud + * providers by cloud id") so the icon still resolves by family. + */ + providerName?: string | null; + className?: string; + size?: number; +}; + +export function ProviderIcon(props: ProviderIconProps) { + const size = props.size ?? 16; + const normalizedId = props.providerId?.trim().toLowerCase() ?? ""; + const normalizedName = props.providerName?.trim().toLowerCase() ?? ""; + const hasProviderFamily = (family: string) => + normalizedId === family || normalizedName.includes(family); + + const isAnthropic = hasProviderFamily("anthropic"); + const isOpenAI = hasProviderFamily("openai"); + const isOpenCode = hasProviderFamily("opencode"); + + const fallbackLetters = (() => { + if (normalizedId === "openrouter") return "OR"; + if (normalizedId === "deepseek") return "DS"; + if (normalizedId === "google") return "GO"; + if (normalizedId.length >= 2) return normalizedId.substring(0, 2).toUpperCase(); + return "AI"; + })(); + + return ( +
+ {isOpenAI ? ( + + + + ) : isAnthropic ? ( + + + + ) : isOpenCode ? ( + + + + + + ) : ( +
+ {fallbackLetters} +
+ )} +
+ ); +} diff --git a/apps/app/src/react-app/design-system/restriction-notice-modal.tsx b/apps/app/src/react-app/design-system/restriction-notice-modal.tsx new file mode 100644 index 0000000000..5081d3772a --- /dev/null +++ b/apps/app/src/react-app/design-system/restriction-notice-modal.tsx @@ -0,0 +1,60 @@ +/** @jsxImportSource react */ +import { X } from "lucide-react"; + +import { currentLocale, t } from "../../i18n"; +import { Button } from "./button"; + +export type RestrictionNoticeModalProps = { + open: boolean; + title: string; + message: string; + onClose: () => void; +}; + +/** + * React port of the Solid `RestrictionNoticeModal` + * (`apps/app/src/app/components/restriction-notice-modal.tsx` on dev — added + * as part of #1505 "enforce desktop restriction policies"). + * + * Purposefully framework-free except for the design-system Button: this is + * a thin, declarative surface driven by the cloud domain when an org gates + * a feature (blockZenModel, disallowNonCloudModels, blockMultipleWorkspaces). + */ +export function RestrictionNoticeModal(props: RestrictionNoticeModalProps) { + if (!props.open) return null; + + return ( +
+
+
+
+

+ {props.title} +

+
+ +
+ +
+

+ {props.message} +

+
+ +
+
+
+
+ ); +} + +export default RestrictionNoticeModal; diff --git a/apps/app/src/react-app/design-system/select-menu.tsx b/apps/app/src/react-app/design-system/select-menu.tsx new file mode 100644 index 0000000000..896897dff3 --- /dev/null +++ b/apps/app/src/react-app/design-system/select-menu.tsx @@ -0,0 +1,123 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronDown } from "lucide-react"; + +export type SelectMenuOption = { + value: string; + label: string; +}; + +type SelectMenuProps = { + options: SelectMenuOption[]; + value: string; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; + id?: string; + ariaLabelledBy?: string; + ariaLabel?: string; +}; + +const triggerClass = + "flex w-full items-center justify-between gap-2 rounded-xl border border-dls-border bg-dls-surface px-3.5 py-2.5 text-left text-[14px] text-dls-text shadow-none transition-[border-color,box-shadow] hover:border-dls-border focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.14)] disabled:cursor-not-allowed disabled:opacity-60"; + +const panelClass = + "absolute left-0 right-0 top-[calc(100%+6px)] z-[100] max-h-56 overflow-auto rounded-xl border border-dls-border bg-dls-surface py-1 shadow-[var(--dls-shell-shadow)]"; + +const optionRowClass = + "flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] text-dls-text transition-colors hover:bg-dls-hover"; + +export function SelectMenu(props: SelectMenuProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + const displayLabel = useMemo(() => { + const match = props.options.find((o) => o.value === props.value); + if (match) return match.label; + return props.placeholder?.trim() || ""; + }, [props.options, props.placeholder, props.value]); + + const close = useCallback(() => setOpen(false), []); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (rootRef.current && target && !rootRef.current.contains(target)) { + close(); + } + }; + window.addEventListener("pointerdown", onPointerDown, true); + return () => + window.removeEventListener("pointerdown", onPointerDown, true); + }, [close, open]); + + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [close, open]); + + return ( +
+ + + {open && !props.disabled ? ( +
+ {props.options.map((opt) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/app/src/react-app/design-system/text-input.tsx b/apps/app/src/react-app/design-system/text-input.tsx new file mode 100644 index 0000000000..595f9f24b0 --- /dev/null +++ b/apps/app/src/react-app/design-system/text-input.tsx @@ -0,0 +1,31 @@ +/** @jsxImportSource react */ +import { forwardRef, type InputHTMLAttributes } from "react"; + +export type TextInputProps = InputHTMLAttributes & { + label?: string; + hint?: string; +}; + +export const TextInput = forwardRef( + function TextInput({ label, hint, className, ...rest }, ref) { + return ( + + ); + }, +); diff --git a/apps/app/src/react-app/design-system/web-unavailable-surface.tsx b/apps/app/src/react-app/design-system/web-unavailable-surface.tsx new file mode 100644 index 0000000000..dce81dfeb0 --- /dev/null +++ b/apps/app/src/react-app/design-system/web-unavailable-surface.tsx @@ -0,0 +1,63 @@ +/** @jsxImportSource react */ +import type { ReactNode } from "react"; +import { ArrowUpRight } from "lucide-react"; + +export type WebUnavailableSurfaceProps = { + unavailable: boolean; + children: ReactNode; + compact?: boolean; + className?: string; + contentClassName?: string; +}; + +const MESSAGE = + "This feature is currently unavailable in OpenWork Web, check OpenWork Desktop for full functionality."; + +export function WebUnavailableSurface(props: WebUnavailableSurfaceProps) { + const innerProps = props.unavailable + ? { + inert: true, + "aria-disabled": true as const, + className: "opacity-55", + } + : { + className: "", + }; + + return ( +
+ {props.unavailable ? ( + + ) : null} + +
+
{props.children}
+ {props.unavailable ? ( + +
+ ); +} diff --git a/apps/app/src/react-app/domains/bundles/apply.ts b/apps/app/src/react-app/domains/bundles/apply.ts new file mode 100644 index 0000000000..de1662d499 --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/apply.ts @@ -0,0 +1 @@ +export * from "../../../app/bundles/apply"; diff --git a/apps/app/src/react-app/domains/bundles/import-modal.tsx b/apps/app/src/react-app/domains/bundles/import-modal.tsx new file mode 100644 index 0000000000..f37bf10b76 --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/import-modal.tsx @@ -0,0 +1,222 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useState } from "react"; +import { + Boxes, + ChevronDown, + ChevronRight, + Plus, + Sparkles, + X, +} from "lucide-react"; + +import type { BundleWorkerOption } from "./types"; + +export type BundleImportModalProps = { + open: boolean; + title: string; + description: string; + items: string[]; + workers: BundleWorkerOption[]; + busy?: boolean; + error?: string | null; + onClose: () => void; + onCreateNewWorker: () => void; + onSelectWorker: (workspaceId: string) => void; +}; + +export function BundleImportModal(props: BundleImportModalProps) { + const [showWorkers, setShowWorkers] = useState(false); + + useEffect(() => { + if (!props.open) return; + setShowWorkers(false); + }, [props.open]); + + useEffect(() => { + if (!props.open) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + props.onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [props]); + + const visibleItems = useMemo( + () => props.items.filter(Boolean).slice(0, 4), + [props.items], + ); + const hiddenItemCount = useMemo( + () => + Math.max( + 0, + props.items.filter(Boolean).length - visibleItems.length, + ), + [props.items, visibleItems.length], + ); + + if (!props.open) return null; + const busy = Boolean(props.busy); + + return ( +
+
+
+
+
+
+ +
+
+

+ {props.title} +

+

+ {props.description} +

+
+
+ +
+ + {visibleItems.length > 0 ? ( +
+ {visibleItems.map((item) => ( + + {item} + + ))} + {hiddenItemCount > 0 ? ( + + +{hiddenItemCount} more + + ) : null} +
+ ) : null} +
+ +
+ {props.error?.trim() ? ( +
+ {props.error} +
+ ) : null} + + + +
+ + + {showWorkers ? ( +
+ {props.workers.length === 0 ? ( +
+ No configured workers are available yet. Create a new worker + to import this bundle. +
+ ) : ( + props.workers.map((worker) => { + const disabledReason = worker.disabledReason?.trim() ?? ""; + const disabled = Boolean(disabledReason) || busy; + return ( + + ); + }) + )} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/bundles/index.ts b/apps/app/src/react-app/domains/bundles/index.ts new file mode 100644 index 0000000000..bd9a2e5295 --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/index.ts @@ -0,0 +1,3 @@ +// Re-export the aggregate entry point from the framework-agnostic bundle +// module so the React tree pulls bundle helpers through a domain-scoped path. +export * from "../../../app/bundles/index"; diff --git a/apps/app/src/react-app/domains/bundles/publish.ts b/apps/app/src/react-app/domains/bundles/publish.ts new file mode 100644 index 0000000000..ca4b64f790 --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/publish.ts @@ -0,0 +1 @@ +export * from "../../../app/bundles/publish"; diff --git a/apps/app/src/react-app/domains/bundles/schema.ts b/apps/app/src/react-app/domains/bundles/schema.ts new file mode 100644 index 0000000000..4b306eeb69 --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/schema.ts @@ -0,0 +1,2 @@ +// Framework-agnostic bundle schema validators re-exported for React imports. +export * from "../../../app/bundles/schema"; diff --git a/apps/app/src/react-app/domains/bundles/skill-destination-modal.tsx b/apps/app/src/react-app/domains/bundles/skill-destination-modal.tsx new file mode 100644 index 0000000000..bbd830f253 --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/skill-destination-modal.tsx @@ -0,0 +1,396 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useState } from "react"; +import { + CheckCircle2, + Folder, + FolderPlus, + Globe, + Loader2, + Sparkles, + X, +} from "lucide-react"; + +import type { WorkspaceInfo } from "../../../app/lib/desktop"; +import { currentLocale, t } from "../../../i18n"; +import { isSandboxWorkspace } from "../../../app/utils"; +import { Button } from "../../design-system/button"; + +type SkillSummary = { + name: string; + description?: string | null; + trigger?: string | null; +}; + +export type SkillDestinationModalProps = { + 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 displayName = (workspace: WorkspaceInfo, fallback: string): string => + workspace.displayName?.trim() || + workspace.openworkWorkspaceName?.trim() || + workspace.name?.trim() || + workspace.directory?.trim() || + workspace.path?.trim() || + workspace.baseUrl?.trim() || + fallback; + +export function SkillDestinationModal(props: SkillDestinationModalProps) { + const translate = (key: string) => t(key, currentLocale()); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState< + string | null + >(null); + + const footerBusy = Boolean(props.busyWorkspaceId?.trim()); + const selectedWorkspace = useMemo( + () => + props.workspaces.find( + (workspace) => workspace.id === selectedWorkspaceId, + ) ?? null, + [props.workspaces, selectedWorkspaceId], + ); + + useEffect(() => { + if (!props.open) return; + const activeMatch = + props.workspaces.find( + (workspace) => workspace.id === props.selectedWorkspaceId, + ) ?? + props.workspaces[0] ?? + null; + setSelectedWorkspaceId(activeMatch?.id ?? null); + }, [props.open, props.selectedWorkspaceId, props.workspaces]); + + const subtitle = (workspace: WorkspaceInfo): string => { + 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): string => { + 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 workspaceCircleClass = ( + workspace: WorkspaceInfo, + selected: boolean, + ): string => { + 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"; + }; + + const submitSelectedWorkspace = () => { + const workspaceId = selectedWorkspaceId?.trim(); + if (!workspaceId || footerBusy) return; + void props.onSubmitWorkspace(workspaceId); + }; + + if (!props.open) return null; + + 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() ? ( +

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

+ ) : null} + {props.skill?.trigger?.trim() ? ( +
+ + {translate("share_skill_destination.trigger_label")} + + + {props.skill.trigger.trim()} + +
+ ) : null} +
+
+
+
+

+ {translate("share_skill_destination.title")} +

+

+ {translate("share_skill_destination.subtitle")} +

+
+
+ + +
+
+ +
+
+
+
+ {translate("share_skill_destination.existing_workers")} +
+ {props.workspaces.length > 0 ? ( + + {props.workspaces.length} + + ) : null} +
+ + {props.workspaces.length === 0 ? ( +
+ {translate("share_skill_destination.no_workers")} +
+ ) : ( +
+ {props.workspaces.map((workspace) => { + const isActive = workspace.id === props.selectedWorkspaceId; + const isSelected = workspace.id === selectedWorkspaceId; + const isBusy = workspace.id === props.busyWorkspaceId; + + return ( + + ); + })} +
+ )} +
+ + {props.onCreateWorker || props.onConnectRemote ? ( +
+
+ {translate("share_skill_destination.more_options")} +
+
+ {props.onCreateWorker ? ( + + ) : null} + + {props.onConnectRemote ? ( + + ) : null} +
+
+ ) : null} +
+ +
+
+ {selectedWorkspace ? ( +
+ + {displayName(selectedWorkspace, "Worker")} + + · + + {subtitle(selectedWorkspace)} + +
+ ) : null} + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/bundles/skill-org-publish.ts b/apps/app/src/react-app/domains/bundles/skill-org-publish.ts new file mode 100644 index 0000000000..1ddb4b6c8d --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/skill-org-publish.ts @@ -0,0 +1 @@ +export * from "../../../app/bundles/skill-org-publish"; diff --git a/apps/app/src/react-app/domains/bundles/sources.ts b/apps/app/src/react-app/domains/bundles/sources.ts new file mode 100644 index 0000000000..a366aa625a --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/sources.ts @@ -0,0 +1 @@ +export * from "../../../app/bundles/sources"; diff --git a/apps/app/src/react-app/domains/bundles/start-modal.tsx b/apps/app/src/react-app/domains/bundles/start-modal.tsx new file mode 100644 index 0000000000..4515a188cc --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/start-modal.tsx @@ -0,0 +1,186 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { FolderPlus, Loader2, Rocket, X } from "lucide-react"; + +import { Button } from "../../design-system/button"; + +export type BundleStartModalProps = { + open: boolean; + templateName: string; + description?: string | null; + items?: string[]; + busy?: boolean; + onClose: () => void; + onPickFolder: () => Promise; + onConfirm: (folder: string | null) => void | Promise; +}; + +export function BundleStartModal(props: BundleStartModalProps) { + const pickFolderRef = useRef(null); + const [selectedFolder, setSelectedFolder] = useState(null); + const [pickingFolder, setPickingFolder] = useState(false); + + useEffect(() => { + if (!props.open) return; + setSelectedFolder(null); + const frame = requestAnimationFrame(() => pickFolderRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [props.open]); + + useEffect(() => { + 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); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [props, props.busy, props.open]); + + const visibleItems = useMemo( + () => (props.items ?? []).filter(Boolean).slice(0, 4), + [props.items], + ); + const hiddenItemCount = useMemo( + () => + Math.max( + 0, + (props.items ?? []).filter(Boolean).length - visibleItems.length, + ), + [props.items, visibleItems.length], + ); + const canSubmit = useMemo( + () => Boolean(selectedFolder?.trim()) && !props.busy && !pickingFolder, + [pickingFolder, props.busy, selectedFolder], + ); + + const handlePickFolder = async () => { + if (pickingFolder || props.busy) return; + setPickingFolder(true); + try { + const next = await props.onPickFolder(); + if (next) setSelectedFolder(next); + } finally { + setPickingFolder(false); + } + }; + + if (!props.open) return null; + + return ( +
+
+
+
+
+
+ +
+
+

+ Start with {props.templateName} +

+

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

+
+
+ +
+ + {visibleItems.length > 0 ? ( +
+ {visibleItems.map((item) => ( + + {item} + + ))} + {hiddenItemCount > 0 ? ( + + +{hiddenItemCount} more + + ) : null} +
+ ) : null} +
+ +
+
+
+ Workspace folder +
+

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

+
+ {selectedFolder?.trim() ? ( + + {selectedFolder} + + ) : ( + + No folder selected yet. + + )} +
+
+ +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/bundles/types.ts b/apps/app/src/react-app/domains/bundles/types.ts new file mode 100644 index 0000000000..6e2b621f3d --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/types.ts @@ -0,0 +1,3 @@ +// Framework-agnostic bundle types live in src/app/bundles/types.ts. +// Re-exported here so React-side bundle code uses a domain-scoped path. +export * from "../../../app/bundles/types"; diff --git a/apps/app/src/react-app/domains/bundles/url-policy.ts b/apps/app/src/react-app/domains/bundles/url-policy.ts new file mode 100644 index 0000000000..4226824ce6 --- /dev/null +++ b/apps/app/src/react-app/domains/bundles/url-policy.ts @@ -0,0 +1 @@ +export * from "../../../app/bundles/url-policy"; diff --git a/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx new file mode 100644 index 0000000000..ec8ab98c0a --- /dev/null +++ b/apps/app/src/react-app/domains/cloud/den-auth-provider.tsx @@ -0,0 +1,204 @@ +/** @jsxImportSource react */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { + clearDenSession, + createDenClient, + DenApiError, + ensureDenActiveOrganization, + readDenSettings, + writeDenSettings, + type DenUser, +} from "../../../app/lib/den"; +import { + denSessionUpdatedEvent, + dispatchDenSessionUpdated, +} from "../../../app/lib/den-session-events"; +import { + deepLinkBridgeEvent, + drainPendingDeepLinks, + type DeepLinkBridgeDetail, +} from "../../../app/lib/deep-link-bridge"; +import { parseDenAuthDeepLink } from "../../../app/lib/openwork-links"; + +export type DenAuthStatus = "checking" | "signed_in" | "signed_out"; + +export type DenAuthStore = { + status: DenAuthStatus; + user: DenUser | null; + error: string | null; + isSignedIn: boolean; + refresh: () => Promise; +}; + +const DenAuthContext = createContext(undefined); + +type DenAuthProviderProps = { + children: ReactNode; +}; + +/** + * React port of the Solid `DenAuthProvider` (`apps/app/src/app/cloud/den-auth-provider.tsx` + * on dev). Drives the Den auth status signal the forced-signin gate and + * desktop-config reader rely on, and syncs Better-Auth's active organization + * on every refresh so subsequent requests resolve against the right org. + */ +export function DenAuthProvider({ children }: DenAuthProviderProps) { + const [status, setStatus] = useState("checking"); + const [user, setUser] = useState(null); + const [error, setError] = useState(null); + // Monotonic token so stale async refreshes can't clobber a newer result. + const refreshTokenRef = useRef(0); + const handledGrantsRef = useRef>(new Set()); + + const refresh = useCallback(async () => { + const currentRun = ++refreshTokenRef.current; + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + + if (!token) { + setUser(null); + setError(null); + setStatus("signed_out"); + return; + } + + setStatus("checking"); + + try { + const nextUser = await createDenClient({ + baseUrl: settings.baseUrl, + apiBaseUrl: settings.apiBaseUrl, + token, + }).getSession(); + + if (currentRun !== refreshTokenRef.current) return; + + await ensureDenActiveOrganization({ + forceServerSync: + !settings.activeOrgId?.trim() || !settings.activeOrgSlug?.trim(), + }).catch(() => null); + + if (currentRun !== refreshTokenRef.current) return; + + setUser(nextUser); + setError(null); + setStatus("signed_in"); + } catch (nextError) { + if (currentRun !== refreshTokenRef.current) 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"); + } + }, []); + + useEffect(() => { + void refresh(); + + if (typeof window === "undefined") return; + + const handleSessionUpdated = () => { + void refresh(); + }; + + window.addEventListener(denSessionUpdatedEvent, handleSessionUpdated); + return () => { + window.removeEventListener(denSessionUpdatedEvent, handleSessionUpdated); + }; + }, [refresh]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const handleUrls = (urls: readonly string[]) => { + for (const rawUrl of urls) { + const parsed = parseDenAuthDeepLink(rawUrl); + if (!parsed || handledGrantsRef.current.has(parsed.grant)) continue; + handledGrantsRef.current.add(parsed.grant); + + void createDenClient({ baseUrl: parsed.denBaseUrl }) + .exchangeDesktopHandoff(parsed.grant) + .then((result) => { + if (!result.token) { + throw new Error("Failed to sign in to OpenWork Cloud."); + } + + writeDenSettings({ + baseUrl: parsed.denBaseUrl, + authToken: result.token, + activeOrgId: null, + activeOrgSlug: null, + activeOrgName: null, + }); + + dispatchDenSessionUpdated({ + status: "success", + baseUrl: parsed.denBaseUrl, + token: result.token, + user: result.user, + email: result.user?.email ?? null, + }); + }) + .catch((error) => { + handledGrantsRef.current.delete(parsed.grant); + dispatchDenSessionUpdated({ + status: "error", + message: + error instanceof Error + ? error.message + : "Failed to sign in to OpenWork Cloud.", + }); + }); + } + }; + + handleUrls(drainPendingDeepLinks(window)); + const handleDeepLink = (event: Event) => { + handleUrls(((event as CustomEvent).detail?.urls ?? []) as string[]); + }; + + window.addEventListener(deepLinkBridgeEvent, handleDeepLink); + return () => window.removeEventListener(deepLinkBridgeEvent, handleDeepLink); + }, []); + + const value = useMemo( + () => ({ + status, + user, + error, + isSignedIn: status === "signed_in", + refresh, + }), + [error, refresh, status, user], + ); + + return ( + {children} + ); +} + +export function useDenAuth(): DenAuthStore { + const context = useContext(DenAuthContext); + if (!context) { + throw new Error("useDenAuth must be used within a DenAuthProvider"); + } + return context; +} diff --git a/apps/app/src/react-app/domains/cloud/den-signin-surface.tsx b/apps/app/src/react-app/domains/cloud/den-signin-surface.tsx new file mode 100644 index 0000000000..a44d313e7a --- /dev/null +++ b/apps/app/src/react-app/domains/cloud/den-signin-surface.tsx @@ -0,0 +1,204 @@ +/** @jsxImportSource react */ +import { ArrowUpRight, Cloud } from "lucide-react"; + +import { currentLocale, t } from "../../../i18n"; +import { DEFAULT_DEN_BASE_URL } from "../../../app/lib/den"; +import { Button } from "../../design-system/button"; +import { TextInput } from "../../design-system/text-input"; + +export type DenSignInSurfaceVariant = "panel" | "fullscreen"; + +export type DenSignInSurfaceProps = { + variant?: DenSignInSurfaceVariant; + 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; +}; + +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 errorBannerClass = + "rounded-xl border border-red-7/30 bg-red-1/40 px-3 py-2 text-xs text-red-11"; + +/** + * React port of the Solid `DenSignInSurface` + * (`apps/app/src/app/cloud/den-signin-surface.tsx` on dev). + * + * Stateless presentation: all state + actions are driven by the parent + * (ForcedSigninPage for the full-screen gate, or the Den settings panel + * for the embedded "panel" variant). Matches the Solid contract 1:1 so + * feature parity is obvious. + */ +export function DenSignInSurface(props: DenSignInSurfaceProps) { + const tr = (key: string) => t(key, currentLocale()); + const variant: DenSignInSurfaceVariant = props.variant ?? "panel"; + + const content = ( +
+
+
+
+ + {tr("den.cloud_section_title")} +
+
+
+ {tr("den.signin_title")} +
+
+
+
+ + {props.developerMode ? ( +
+ + 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} + /> +
+ + + +
+
+ ) : null} + + {props.baseUrlError ? ( +
{props.baseUrlError}
+ ) : null} + + {props.statusMessage && !props.authError ? ( +
{props.statusMessage}
+ ) : null} + +
+
+ {tr("den.auto_reconnect_hint")} +
+
+ +
+ + + +
+ + {props.manualAuthOpen ? ( +
+ + 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")} +
+
+
+ ) : null} + + {props.authError ? ( +
{props.authError}
+ ) : null} +
+ ); + + if (variant === "fullscreen") { + return ( +
+
+
{content}
+
+
+ ); + } + + return content; +} diff --git a/apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx b/apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx new file mode 100644 index 0000000000..cd9959bcc8 --- /dev/null +++ b/apps/app/src/react-app/domains/cloud/desktop-config-provider.tsx @@ -0,0 +1,248 @@ +/** @jsxImportSource react */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { + checkDesktopAppRestriction, + type DesktopAppRestrictionChecker, +} from "../../../app/cloud/desktop-app-restrictions"; +import { + createDenClient, + DenApiError, + ensureDenActiveOrganization, + normalizeDenDesktopConfig, + readDenSettings, + type DenDesktopConfig, +} from "../../../app/lib/den"; +import { + denSessionUpdatedEvent, + denSettingsChangedEvent, +} from "../../../app/lib/den-session-events"; +import { useDenAuth } from "./den-auth-provider"; + +export type DesktopConfigStore = { + config: DenDesktopConfig; + loading: boolean; + refresh: () => Promise; + /** + * Stable checker function that matches the `DesktopAppRestrictionChecker` + * shape Solid passes to its stores. Useful when wiring restriction gates + * from non-hook code paths. + */ + checkRestriction: DesktopAppRestrictionChecker; +}; + +const DesktopConfigContext = createContext( + undefined, +); + +const DEFAULT_DESKTOP_CONFIG: DenDesktopConfig = {}; +const DESKTOP_CONFIG_REFRESH_MS = 60 * 60 * 1000; +const DESKTOP_CONFIG_CACHE_PREFIX = "openwork.den.desktopConfig:"; + +function getDesktopConfigCacheKey(): string { + 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 { + // Quota / private-browsing failures are non-fatal — we just miss the cache next boot. + } +} + +type DesktopConfigProviderProps = { + children: ReactNode; +}; + +/** + * React port of the Solid `DesktopConfigProvider` + * (`apps/app/src/app/cloud/desktop-config-provider.tsx` on dev). + * + * Fetches the org-scoped "desktop app restrictions" config (new + * `packages/types/den/desktop-app-restrictions.ts` shape) and caches it in + * localStorage so gates like `blockZenModel` can apply immediately on the + * next boot without waiting for the HTTP round-trip. Re-fetches on Den + * session / settings events and on a one-hour interval. + */ +export function DesktopConfigProvider({ children }: DesktopConfigProviderProps) { + const denAuth = useDenAuth(); + const [config, setConfig] = useState(DEFAULT_DESKTOP_CONFIG); + const [loading, setLoading] = useState(false); + // Bumped whenever the browser tells us the Den session or settings changed. + const [settingsVersion, setSettingsVersion] = useState(0); + // Monotonic run id — same guard-against-stale-resolution pattern as DenAuthProvider. + const refreshRunRef = useRef(0); + const isSignedIn = denAuth.isSignedIn; + + const refresh = useCallback(async () => { + const currentRun = ++refreshRunRef.current; + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + const cacheKey = getDesktopConfigCacheKey(); + + if (!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 !== refreshRunRef.current) return; + + writeCachedDesktopConfig(cacheKey, nextConfig); + setConfig(nextConfig); + } catch (error) { + if (currentRun !== refreshRunRef.current) return; + + // If the server says the active org doesn't exist, re-sync Better Auth + // so the next refresh hits a valid org. Same recovery path as Solid. + 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 === refreshRunRef.current) { + setLoading(false); + } + } + }, [isSignedIn]); + + // Re-run whenever auth flips or Den settings change. Read the cache + // synchronously so gated UI never flickers through "unrestricted" just + // because we haven't finished the HTTP call yet. + useEffect(() => { + // settingsVersion is read to tie this effect to settings-change events. + void settingsVersion; + + if (!isSignedIn) { + setConfig(DEFAULT_DESKTOP_CONFIG); + setLoading(false); + return; + } + + const cacheKey = getDesktopConfigCacheKey(); + const cached = readCachedDesktopConfig(cacheKey); + setConfig(cached ?? DEFAULT_DESKTOP_CONFIG); + setLoading(!cached); + void refresh(); + }, [isSignedIn, refresh, settingsVersion]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const handleSettingsChanged = () => { + setSettingsVersion((value) => value + 1); + }; + + window.addEventListener(denSessionUpdatedEvent, handleSettingsChanged); + window.addEventListener(denSettingsChangedEvent, handleSettingsChanged); + + const interval = window.setInterval(() => { + if (!isSignedIn) return; + void refresh(); + }, DESKTOP_CONFIG_REFRESH_MS); + + return () => { + window.removeEventListener(denSessionUpdatedEvent, handleSettingsChanged); + window.removeEventListener(denSettingsChangedEvent, handleSettingsChanged); + window.clearInterval(interval); + }; + }, [isSignedIn, refresh]); + + const value = useMemo(() => { + // Bind the checker to the latest `config` so callers see the most + // recent org restrictions without having to recompute every render. + const checkRestriction: DesktopAppRestrictionChecker = ({ restriction }) => + checkDesktopAppRestriction({ config, restriction }); + return { config, loading, refresh, checkRestriction }; + }, [config, loading, refresh]); + + return ( + + {children} + + ); +} + +export function useDesktopConfig(): DesktopConfigStore { + const context = useContext(DesktopConfigContext); + if (!context) { + throw new Error("useDesktopConfig must be used within a DesktopConfigProvider"); + } + return context; +} + +/** + * Convenience hook that returns the raw `DesktopAppRestrictions` flags + * (e.g. `{ blockZenModel: true }`). Callers usually just want the flags, + * not the loading state — feature gates should read through this. + */ +export function useOrgRestrictions(): DenDesktopConfig { + return useDesktopConfig().config; +} + +/** + * Hook variant that returns the stable `checkRestriction` function so + * feature sites that already receive a "checker" (e.g. helpers ported + * from Solid stores) can call it directly without reshaping. + */ +export function useCheckDesktopRestriction(): DesktopAppRestrictionChecker { + return useDesktopConfig().checkRestriction; +} + +/** + * Single-restriction hook — returns true/false for a specific key. + * Use this at feature sites that only care about one flag + * (e.g. `useDesktopRestriction("blockMultipleWorkspaces")`). + */ +export function useDesktopRestriction( + restriction: Parameters[0]["restriction"], +): boolean { + return useDesktopConfig().checkRestriction({ restriction }); +} diff --git a/apps/app/src/react-app/domains/cloud/forced-signin-page.tsx b/apps/app/src/react-app/domains/cloud/forced-signin-page.tsx new file mode 100644 index 0000000000..0457895a85 --- /dev/null +++ b/apps/app/src/react-app/domains/cloud/forced-signin-page.tsx @@ -0,0 +1,292 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useState } from "react"; + +import { currentLocale, t } from "../../../i18n"; +import { + buildDenAuthUrl, + clearDenSession, + createDenClient, + DEFAULT_DEN_BASE_URL, + normalizeDenBaseUrl, + readDenBootstrapConfig, + readDenSettings, + resolveDenBaseUrls, + setDenBootstrapConfig, + writeDenSettings, +} from "../../../app/lib/den"; +import { + denSessionUpdatedEvent, + dispatchDenSessionUpdated, + type DenSessionUpdatedDetail, +} from "../../../app/lib/den-session-events"; +import { usePlatform } from "../../kernel/platform"; +import { useBootState } from "../../shell/boot-state"; +import { useDenAuth } from "./den-auth-provider"; +import { useDesktopConfig } from "./desktop-config-provider"; +import { DenSignInSurface } from "./den-signin-surface"; + +export type ForcedSigninPageProps = { + developerMode: boolean; +}; + +/** + * Parse a pasted manual-auth input. Accepts either a raw handoff grant + * string (>= 12 chars) or an `openwork://den-auth?grant=…` deep link. + * Matches the Solid ForcedSigninPage exactly so flows stay fungible. + */ +function 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; +} + +/** + * React port of the Solid `ForcedSigninPage` + * (`apps/app/src/app/cloud/forced-signin-page.tsx` on dev). + * + * Full-screen sign-in gate rendered when the desktop bootstrap config has + * `requireSignin: true` and the user is not yet signed in. Owns the local + * draft state (base URL, manual auth input) and pipes it into the + * shared `DenSignInSurface` presentation layer. + */ +export function ForcedSigninPage({ developerMode }: ForcedSigninPageProps) { + const platform = usePlatform(); + const denAuth = useDenAuth(); + const desktopConfig = useDesktopConfig(); + const { markRouteReady } = useBootState(); + const tr = useCallback((key: string) => t(key, currentLocale()), []); + + const initial = readDenSettings(); + const initialBaseUrl = initial.baseUrl || DEFAULT_DEN_BASE_URL; + + const [baseUrl, setBaseUrl] = useState(initialBaseUrl); + const [baseUrlDraft, setBaseUrlDraft] = useState(initialBaseUrl); + const [baseUrlError, setBaseUrlError] = useState(null); + const [authBusy, setAuthBusy] = useState(false); + const [baseUrlBusy, setBaseUrlBusy] = useState(false); + const [manualAuthOpen, setManualAuthOpen] = useState(false); + const [manualAuthInput, setManualAuthInput] = useState(""); + const [authError, setAuthError] = useState(null); + const [statusMessage, setStatusMessage] = useState(null); + + const openControlPlane = useCallback(() => { + platform.openLink(resolveDenBaseUrls(baseUrl).baseUrl); + }, [baseUrl, platform]); + + const openBrowserAuth = useCallback( + (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); + }, + [baseUrl, platform, tr], + ); + + const submitManualAuth = useCallback(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, + }).exchangeDesktopHandoff(parsed.grant); + if (!result.token) { + throw new Error(tr("den.error_no_token")); + } + + if (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); + } + }, [authBusy, baseUrl, developerMode, manualAuthInput, tr]); + + const applyBaseUrl = useCallback(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); + setBaseUrl(resolved.baseUrl); + setBaseUrlDraft(resolved.baseUrl); + clearDenSession({ includeBaseUrls: !developerMode }); + writeDenSettings( + { + baseUrl: resolved.baseUrl, + apiBaseUrl: resolved.apiBaseUrl, + authToken: null, + activeOrgId: null, + activeOrgSlug: null, + activeOrgName: null, + }, + { persistBootstrap: false }, + ); + setAuthError(null); + setStatusMessage(tr("den.status_base_url_updated")); + void desktopConfig.refresh(); + void denAuth.refresh(); + } catch (error) { + setBaseUrlError( + error instanceof Error + ? error.message + : tr("den.error_base_url"), + ); + } finally { + setBaseUrlBusy(false); + } + }, [baseUrlDraft, denAuth, desktopConfig, developerMode, tr]); + + // Listen for Den session events broadcast from the Tauri deep-link handler, + // a successful browser auth, or an org switch, and reflect the result in + // the sign-in surface's status/error banners. + useEffect(() => { + markRouteReady(); + }, [markRouteReady]); + + useEffect(() => { + 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); + const email = customEvent.detail.email?.trim(); + setStatusMessage( + email + ? t("den.status_cloud_signed_in_as", currentLocale(), { email }) + : 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, + ); + }; + }, [tr]); + + 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/react-app/domains/cloud/restriction-notice-provider.tsx b/apps/app/src/react-app/domains/cloud/restriction-notice-provider.tsx new file mode 100644 index 0000000000..1e85fa848e --- /dev/null +++ b/apps/app/src/react-app/domains/cloud/restriction-notice-provider.tsx @@ -0,0 +1,89 @@ +/** @jsxImportSource react */ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { RestrictionNoticeModal } from "../../design-system/restriction-notice-modal"; + +export type RestrictionNoticePayload = { + title: string; + message: string; +}; + +export type RestrictionNoticeController = { + /** + * Show a restriction notice modal. Replaces any currently-shown notice. + */ + show: (payload: RestrictionNoticePayload) => void; + /** + * Dismiss whichever notice is currently visible. Safe to call when none is shown. + */ + dismiss: () => void; +}; + +const RestrictionNoticeContext = createContext( + undefined, +); + +type RestrictionNoticeProviderProps = { + children: ReactNode; +}; + +/** + * App-wide restriction notice surface ported from Solid. Owns one active + * `RestrictionNoticeModal` and exposes `{ show, dismiss }` to callers via + * `useRestrictionNotice()`. Call sites: + * + * const notice = useRestrictionNotice(); + * if (checkDesktopRestriction({ restriction: "blockMultipleWorkspaces" })) { + * notice.show({ title: "...", message: "..." }); + * return; + * } + * + * The modal lives inside the provider so consumers don't need to render it; + * this matches Solid's app.tsx wiring where `RestrictionNoticeModal` is a + * single child of the root shell. + */ +export function RestrictionNoticeProvider({ children }: RestrictionNoticeProviderProps) { + const [notice, setNotice] = useState(null); + + const show = useCallback((payload: RestrictionNoticePayload) => { + setNotice(payload); + }, []); + + const dismiss = useCallback(() => { + setNotice(null); + }, []); + + const value = useMemo( + () => ({ show, dismiss }), + [dismiss, show], + ); + + return ( + + {children} + + + ); +} + +export function useRestrictionNotice(): RestrictionNoticeController { + const context = useContext(RestrictionNoticeContext); + if (!context) { + throw new Error( + "useRestrictionNotice must be used within a RestrictionNoticeProvider", + ); + } + return context; +} diff --git a/apps/app/src/react-app/domains/cloud/use-cloud-provider-auto-sync.ts b/apps/app/src/react-app/domains/cloud/use-cloud-provider-auto-sync.ts new file mode 100644 index 0000000000..4e7a948ff9 --- /dev/null +++ b/apps/app/src/react-app/domains/cloud/use-cloud-provider-auto-sync.ts @@ -0,0 +1,63 @@ +/** @jsxImportSource react */ +import { useEffect, useRef } from "react"; + +import { CLOUD_SYNC_INTERVAL_MS } from "../../../app/cloud/sync/constants"; +import { useDenAuth } from "./den-auth-provider"; + +type CloudProviderSyncReason = "sign_in" | "app_launch" | "interval" | "settings_cloud_opened"; +type SyncFn = (reason: CloudProviderSyncReason) => Promise; + +/** + * Periodic cloud-provider reconciliation, ported from dev #1509 "auto-sync + * cloud providers". Runs the provided sync function every + * `CLOUD_SYNC_INTERVAL_MS` while the Den session is signed-in; suspends while + * signed-out and lets the provider-auth store own user-visible errors. + * + * Mount once (e.g. from the settings route) — the hook is idempotent + * within a single mount, and avoids overlapping ticks using an in-flight + * ref guard. + */ +export function useCloudProviderAutoSync(sync: SyncFn) { + const denAuth = useDenAuth(); + const syncRef = useRef(sync); + const inFlightRef = useRef(false); + + // Keep the ref current so we always call the latest closure (store + // identity can change between mounts and we don't want to restart the + // timer just because the parent re-rendered). + useEffect(() => { + syncRef.current = sync; + }, [sync]); + + useEffect(() => { + if (!denAuth.isSignedIn) return; + + let cancelled = false; + + const tick = async () => { + if (inFlightRef.current || cancelled) return; + inFlightRef.current = true; + try { + await syncRef.current("interval"); + } catch { + // Network errors, org misconfig, etc. are non-fatal — we'll try + // again on the next interval. The refresh function owns surfacing + // any user-visible error state. + } finally { + inFlightRef.current = false; + } + }; + + // Immediate pass so users see server state quickly after sign-in. + void tick(); + + const interval = window.setInterval(() => { + void tick(); + }, CLOUD_SYNC_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [denAuth.isSignedIn]); +} diff --git a/apps/app/src/react-app/domains/connections/mcp-auth-modal.tsx b/apps/app/src/react-app/domains/connections/mcp-auth-modal.tsx new file mode 100644 index 0000000000..b8f3856504 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/mcp-auth-modal.tsx @@ -0,0 +1,891 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import { CheckCircle2, Loader2, RefreshCcw, X } from "lucide-react"; + +import type { McpDirectoryInfo } from "../../../app/constants"; +import { openDesktopUrl, opencodeMcpAuth } from "../../../app/lib/desktop"; +import { unwrap } from "../../../app/lib/opencode"; +import { validateMcpServerName } from "../../../app/mcp"; +import type { Client } from "../../../app/types"; +import { isDesktopRuntime, normalizeDirectoryPath } from "../../../app/utils"; +import { t, type Language } from "../../../i18n"; +import { Button } from "../../design-system/button"; +import { TextInput } from "../../design-system/text-input"; + +const MCP_AUTH_POLL_INTERVAL_MS = 2_000; +const MCP_AUTH_TIMEOUT_MS = 90_000; +const MCP_AUTH_DISCOVERY_TIMEOUT_MS = 15_000; + +type McpStatusEntry = { + status?: string; + error?: string; +}; + +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 function McpAuthModal(props: McpAuthModalProps) { + const translate = (key: string, replacements?: Record) => { + let result = t(key, props.language); + if (replacements) { + for (const [placeholder, value] of Object.entries(replacements)) { + result = result.replace(`{${placeholder}}`, value); + } + } + return result; + }; + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [needsReload, setNeedsReload] = useState(false); + const [alreadyConnected, setAlreadyConnected] = useState(false); + const [authInProgress, setAuthInProgress] = useState(false); + const [statusChecking, setStatusChecking] = useState(false); + const [reloadNotice, setReloadNotice] = useState(null); + const [authorizationUrl, setAuthorizationUrl] = useState(null); + const [callbackInput, setCallbackInput] = useState(""); + const [manualAuthBusy, setManualAuthBusy] = useState(false); + const [cliAuthBusy, setCliAuthBusy] = useState(false); + const [cliAuthResult, setCliAuthResult] = useState(null); + const [authUrlCopied, setAuthUrlCopied] = useState(false); + const [resolvedDir, setResolvedDir] = useState(""); + const [awaitingReload, setAwaitingReload] = useState(false); + const [reloadStarting, setReloadStarting] = useState(false); + const [reloadSatisfied, setReloadSatisfied] = useState(false); + const [forceStopBusySessionID, setForceStopBusySessionID] = useState(null); + + const statusPollRef = useRef(null); + const authCopyTimeoutRef = useRef(null); + const previousOpenRef = useRef(false); + const previousEntryNameRef = useRef(null); + + const stopStatusPolling = () => { + if (statusPollRef.current !== null) { + window.clearInterval(statusPollRef.current); + statusPollRef.current = null; + } + }; + + useEffect(() => { + const normalized = normalizeDirectoryPath(props.projectDir ?? ""); + const collapsed = normalized.replace(/^\/private\/tmp(?=\/|$)/, "/tmp"); + setResolvedDir(collapsed); + }, [props.projectDir]); + + useEffect(() => { + return () => { + stopStatusPolling(); + if (authCopyTimeoutRef.current !== null) { + window.clearTimeout(authCopyTimeoutRef.current); + authCopyTimeoutRef.current = null; + } + }; + }, []); + + const openAuthorizationUrl = async (url: string) => { + if (isDesktopRuntime()) { + await openDesktopUrl(url); + return; + } + + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener,noreferrer"); + } + }; + + const handleCopyAuthorizationUrl = async () => { + if (!authorizationUrl) return; + + try { + await navigator.clipboard.writeText(authorizationUrl); + setAuthUrlCopied(true); + if (authCopyTimeoutRef.current !== null) { + window.clearTimeout(authCopyTimeoutRef.current); + } + authCopyTimeoutRef.current = window.setTimeout(() => { + setAuthUrlCopied(false); + authCopyTimeoutRef.current = null; + }, 2_000); + } catch { + // ignore clipboard failures + } + }; + + const fetchMcpStatus = async (slug: string) => { + if (!props.entry || !props.client) return null; + + try { + const directory = resolvedDir.trim(); + if (!directory) return null; + const result = await props.client.mcp.status({ directory }); + const status = result.data?.[slug] as McpStatusEntry | undefined; + return status ?? null; + } catch { + return null; + } + }; + + const resolveDirectory = async () => { + const current = resolvedDir.trim(); + if (current) return current; + if (!props.client) return ""; + + try { + const info = unwrap(await props.client.path.get()); + const normalized = normalizeDirectoryPath(info.directory ?? ""); + const collapsed = normalized.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(); + statusPollRef.current = 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) => { + if (!props.entry || !props.client) return; + + let slug = ""; + try { + slug = resolveSlug(props.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 (!props.isRemoteWorkspace) { + const result = await props.client.mcp.auth.authenticate({ + name: slug, + directory, + }); + const status = unwrap(result) as McpStatusEntry; + + 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 props.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 = () => { + if (!error) return false; + const normalized = error.toLowerCase(); + return ( + normalized.includes("invalidgranterror") || + normalized.includes("invalid refresh token") || + normalized.includes("invalid_refresh_token") + ); + }; + + const handleCliReauth = async () => { + if (!props.entry || cliAuthBusy || props.isRemoteWorkspace || !isDesktopRuntime()) return; + + setCliAuthBusy(true); + setCliAuthResult(null); + + try { + const result = await opencodeMcpAuth(props.projectDir, props.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); + } + }; + + useEffect(() => { + if (!props.open || !props.entry || !props.client) { + previousOpenRef.current = props.open; + previousEntryNameRef.current = props.entry?.name ?? null; + return; + } + + const previousEntryName = previousEntryNameRef.current; + const isInitialOpen = !previousOpenRef.current; + const entryChanged = previousEntryName !== props.entry.name; + + if (isInitialOpen || entryChanged) { + setReloadSatisfied(false); + } + + previousOpenRef.current = props.open; + previousEntryNameRef.current = props.entry.name; + + if (props.reloadRequired && !reloadSatisfied) { + setAwaitingReload(true); + return; + } + + void startAuth(false); + }, [props.open, props.entry, props.client, props.reloadRequired]); + + useEffect(() => { + if (!props.open || !awaitingReload || props.reloadBlocked || !props.onReloadEngine || !props.entry || reloadStarting) { + return; + } + + let cancelled = false; + + void (async () => { + setReloadStarting(true); + setError(null); + setNeedsReload(false); + setReloadNotice(null); + + try { + await props.onReloadEngine?.(); + if (cancelled) return; + + const slug = resolveSlug(props.entry!.name); + const status = await waitForMcpAvailability(slug); + if (cancelled) return; + + if (!status) { + setAwaitingReload(false); + setNeedsReload(true); + setReloadNotice( + props.reloadBlocked + ? translate("mcp.auth.reload_blocked") + : translate("mcp.auth.reload_notice"), + ); + return; + } + + setReloadSatisfied(true); + setAwaitingReload(false); + await startAuth(false, false); + } catch (err) { + const message = err instanceof Error ? err.message : translate("mcp.auth.reload_failed"); + if (cancelled) return; + setAwaitingReload(false); + setNeedsReload(true); + setError(message); + } finally { + if (!cancelled) { + setReloadStarting(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [props.open, awaitingReload, props.reloadBlocked, props.onReloadEngine, props.entry, reloadStarting]); + + const handleRetry = () => { + void startAuth(true); + }; + + 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(); + await 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 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 () => { + if (!props.entry || !props.client) return; + + let slug = ""; + try { + slug = resolveSlug(props.entry.name); + } 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 props.client.mcp.auth.callback({ + name: slug, + directory, + code, + }); + const status = unwrap(result) as McpStatusEntry; + 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 () => { + if (!props.entry || !props.client) return; + + setError(null); + setStatusChecking(true); + + let slug = ""; + try { + slug = resolveSlug(props.entry.name); + } 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); + }; + + if (!props.open) return null; + + const isBusy = loading || statusChecking || manualAuthBusy; + const isPreparingReload = awaitingReload || reloadStarting; + const serverName = props.entry?.name ?? "MCP Server"; + + return ( +
+
+ +
event.stopPropagation()} + > +
+
+

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

+

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

+
+ +
+ +
+ {isBusy ? ( +
+
+ +
+
+

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

+

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

+ +
+
+ ) : null} + + {!isBusy && isPreparingReload ? ( +
+
+ +
+
+

+ {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")} +

+
+ {props.reloadBlocked && (props.activeSessions?.length ?? 0) > 0 ? ( +
+ {(props.activeSessions ?? []).map((session) => ( +
+ + {translate("mcp.auth.waiting_for_session", { session: session.title })} + + +
+ ))} +
+ ) : null} +
+ ) : null} + + {!isBusy && alreadyConnected ? ( +
+
+
+ +
+
+

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

+

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

+
+
+

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

+
+ ) : null} + + {reloadNotice ? ( +
+

{reloadNotice}

+ +
+ {props.onReloadEngine ? ( + + ) : null} + +
+
+ ) : null} + + {error ? ( +
+

{error}

+ + {needsReload ? ( +
+ {props.onReloadEngine ? ( + + ) : null} + +
+ ) : ( +
+ +
+ )} + + {isInvalidRefreshToken() ? ( +
+

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

+ {!props.isRemoteWorkspace ? ( + isDesktopRuntime() ? ( + + ) : ( +
+ {translate("mcp.auth.reauth_cli_hint", { server: serverName })} +
+ ) + ) : ( +
{translate("mcp.auth.reauth_remote_hint")}
+ )} + {cliAuthResult ?
{cliAuthResult}
: null} +
+ ) : null} +
+ ) : null} + + {!isBusy && authorizationUrl && props.isRemoteWorkspace && !alreadyConnected ? ( +
+
{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")}
+
+ +
+
+ ) : null} + + {!isBusy && !isPreparingReload && !error && !reloadNotice && !alreadyConnected ? ( + <> +
+
+
+ 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")}

+ +
+
+ + ) : null} +
+ +
+ {alreadyConnected ? ( + + ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/connections/mcp-view.tsx b/apps/app/src/react-app/domains/connections/mcp-view.tsx new file mode 100644 index 0000000000..04888c9737 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/mcp-view.tsx @@ -0,0 +1,56 @@ +/** @jsxImportSource react */ +import type { McpDirectoryInfo } from "../../../app/constants"; +import type { OpencodeConfigFile } from "../../../app/lib/desktop"; +import type { McpServerEntry, McpStatusMap } from "../../../app/types"; + +import PresentationalMcpView from "../settings/pages/mcp-view"; + +export type ConnectionsMcpStore = { + readConfigFile?: (scope: "project" | "global") => Promise; + mcpServers: McpServerEntry[]; + mcpStatus: string | null; + mcpLastUpdatedAt: number | null; + mcpStatuses: McpStatusMap; + mcpConnectingName: string | null; + selectedMcp: string | null; + setSelectedMcp: (name: string | null) => void; + quickConnect: McpDirectoryInfo[]; + connectMcp: (entry: McpDirectoryInfo) => void; + authorizeMcp: (entry: McpServerEntry) => void; + logoutMcpAuth: (name: string) => Promise | void; + removeMcp: (name: string) => void; +}; + +export type ConnectionsMcpViewProps = { + busy: boolean; + selectedWorkspaceRoot: string; + isRemoteWorkspace: boolean; + showHeader?: boolean; + connections: ConnectionsMcpStore; +}; + +export default function ConnectionsMcpView(props: ConnectionsMcpViewProps) { + const { connections } = props; + + return ( + + ); +} diff --git a/apps/app/src/react-app/domains/connections/modals.tsx b/apps/app/src/react-app/domains/connections/modals.tsx new file mode 100644 index 0000000000..3ac2ad8a4a --- /dev/null +++ b/apps/app/src/react-app/domains/connections/modals.tsx @@ -0,0 +1,46 @@ +/** @jsxImportSource react */ +import type { Client } from "../../../app/types"; +import type { Language } from "../../../i18n"; +import type { McpDirectoryInfo } from "../../../app/constants"; + +import { McpAuthModal } from "./mcp-auth-modal"; + +export type ConnectionsModalsState = { + mcpAuthModalOpen: boolean; + mcpAuthEntry: McpDirectoryInfo | null; + mcpAuthNeedsReload: boolean; +}; + +export type ConnectionsModalsProps = { + client: Client | null; + projectDir: string; + language: Language; + reloadBlocked: boolean; + activeSessions: Array<{ id: string; title: string }>; + isRemoteWorkspace: boolean; + onForceStopSession: (sessionID: string) => void | Promise; + onReloadEngine: () => void | Promise; + modalState: ConnectionsModalsState; + onCloseMcpAuthModal: () => void; + onCompleteMcpAuthModal: () => void | Promise; +}; + +export default function ConnectionsModals(props: ConnectionsModalsProps) { + return ( + + ); +} diff --git a/apps/app/src/react-app/domains/connections/modals/add-mcp-modal.tsx b/apps/app/src/react-app/domains/connections/modals/add-mcp-modal.tsx new file mode 100644 index 0000000000..29480d5d75 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/modals/add-mcp-modal.tsx @@ -0,0 +1,257 @@ +/** @jsxImportSource react */ +import { useState } from "react"; +import { Loader2, Plus, X } from "lucide-react"; + +import { Button } from "../../../design-system/button"; +import { TextInput } from "../../../design-system/text-input"; +import type { McpDirectoryInfo } from "../../../../app/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 function AddMcpModal(props: AddMcpModalProps) { + const tr = (key: string) => t(key, props.language); + + const [name, setName] = useState(""); + const [serverType, setServerType] = useState<"remote" | "local">("remote"); + const [url, setUrl] = useState(""); + const [command, setCommand] = useState(""); + const [oauthRequired, setOauthRequired] = useState(false); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(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(); + }; + + if (!props.open) return null; + + return ( +
+
+
event.stopPropagation()} + > +
+
+

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

+

+ {tr("mcp.add_modal_subtitle")} +

+
+ +
+ +
+ setName(event.currentTarget.value)} + autoFocus + /> + +
+
+ {tr("mcp.server_type")} +
+
+ + +
+ {props.isRemoteWorkspace ? ( +
+ {tr("mcp.remote_workspace_url_hint")} +
+ ) : null} +
+ + {serverType === "remote" ? ( +
+ setUrl(event.currentTarget.value)} + /> +
+
+ {tr("mcp.sign_in_section_label")} +
+ +
+
+ ) : null} + + {serverType === "local" ? ( + setCommand(event.currentTarget.value)} + /> + ) : null} + + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/connections/modals/control-chrome-setup-modal.tsx b/apps/app/src/react-app/domains/connections/modals/control-chrome-setup-modal.tsx new file mode 100644 index 0000000000..4426b4e174 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/modals/control-chrome-setup-modal.tsx @@ -0,0 +1,183 @@ +/** @jsxImportSource react */ +import { useEffect, useState } from "react"; +import { + Check, + ExternalLink, + Loader2, + MonitorSmartphone, + Settings2, + X, +} from "lucide-react"; + +import { t, type Language } from "../../../../i18n"; +import { Button } from "../../../design-system/button"; + +export type ControlChromeSetupModalProps = { + open: boolean; + busy: boolean; + language: Language; + mode: "connect" | "edit"; + initialUseExistingProfile: boolean; + onClose: () => void; + onSave: (useExistingProfile: boolean) => void; +}; + +export function ControlChromeSetupModal(props: ControlChromeSetupModalProps) { + const tr = (key: string) => t(key, props.language); + const [useExistingProfile, setUseExistingProfile] = useState( + props.initialUseExistingProfile, + ); + + useEffect(() => { + if (!props.open) return; + setUseExistingProfile(props.initialUseExistingProfile); + }, [props.initialUseExistingProfile, props.open]); + + if (!props.open) return null; + + const ctaLabel = + props.mode === "edit" + ? tr("mcp.control_chrome_save") + : tr("mcp.control_chrome_connect"); + + 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/react-app/domains/connections/openwork-server-provider.tsx b/apps/app/src/react-app/domains/connections/openwork-server-provider.tsx new file mode 100644 index 0000000000..5e2ab90cab --- /dev/null +++ b/apps/app/src/react-app/domains/connections/openwork-server-provider.tsx @@ -0,0 +1,33 @@ +/** @jsxImportSource react */ +import { + createContext, + useContext, + useSyncExternalStore, + type ReactNode, +} from "react"; + +import type { OpenworkServerStore } from "./openwork-server-store"; + +const OpenworkServerContext = createContext(null); + +export function OpenworkServerProvider(props: { + store: OpenworkServerStore; + children: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useOpenworkServer() { + const store = useContext(OpenworkServerContext); + if (!store) { + throw new Error("useOpenworkServer must be used within an OpenworkServerProvider"); + } + + useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + + return store; +} diff --git a/apps/app/src/react-app/domains/connections/openwork-server-store.ts b/apps/app/src/react-app/domains/connections/openwork-server-store.ts new file mode 100644 index 0000000000..62e2c9f890 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/openwork-server-store.ts @@ -0,0 +1,788 @@ +import { useSyncExternalStore } from "react"; + +import { t, currentLocale } from "../../../i18n"; +import type { StartupPreference, WorkspaceDisplay } from "../../../app/types"; +import { isDesktopRuntime } from "../../../app/utils"; +import { + openworkServerInfo, + openworkServerRestart, + type OpenworkServerInfo, +} from "../../../app/lib/desktop"; +import { + clearOpenworkServerSettings, + createOpenworkServerClient, + isLoopbackOpenworkServerUrl, + normalizeOpenworkServerUrl, + readOpenworkServerSettings, + writeOpenworkServerSettings, + type OpenworkAuditEntry, + type OpenworkServerCapabilities, + type OpenworkServerClient, + type OpenworkServerDiagnostics, + type OpenworkServerError, + type OpenworkServerSettings, + type OpenworkServerStatus, +} from "../../../app/lib/openwork-server"; + +type SetStateAction = T | ((current: T) => T); + +type RemoteWorkspaceInput = { + openworkHostUrl: string; + openworkToken?: string | null; + directory?: string | null; + displayName?: string | null; +}; + +export type OpenworkServerStoreSnapshot = { + openworkServerSettings: OpenworkServerSettings; + shareRemoteAccessBusy: boolean; + shareRemoteAccessError: string | null; + openworkServerUrl: string; + openworkServerBaseUrl: string; + openworkServerAuth: { token?: string; hostToken?: string }; + openworkServerClient: OpenworkServerClient | null; + openworkServerStatus: OpenworkServerStatus; + openworkServerCapabilities: OpenworkServerCapabilities | null; + openworkServerReady: boolean; + openworkServerWorkspaceReady: boolean; + resolvedOpenworkCapabilities: OpenworkServerCapabilities | null; + openworkServerCanWriteSkills: boolean; + openworkServerCanWritePlugins: boolean; + openworkServerHostInfo: OpenworkServerInfo | null; + openworkServerDiagnostics: OpenworkServerDiagnostics | null; + openworkReconnectBusy: boolean; + openworkAuditEntries: OpenworkAuditEntry[]; + openworkAuditStatus: "idle" | "loading" | "error"; + openworkAuditError: string | null; + devtoolsWorkspaceId: string | null; +}; + +export type OpenworkServerStore = ReturnType; + +type CreateOpenworkServerStoreOptions = { + startupPreference: () => StartupPreference | null; + documentVisible: () => boolean; + developerMode: () => boolean; + runtimeWorkspaceId: () => string | null; + activeClient: () => unknown | null; + selectedWorkspaceDisplay: () => WorkspaceDisplay; + restartLocalServer: () => Promise; + createRemoteWorkspaceFlow: (input: RemoteWorkspaceInput) => Promise; +}; + +type MutableState = { + openworkServerSettings: OpenworkServerSettings; + shareRemoteAccessBusy: boolean; + shareRemoteAccessError: string | null; + openworkServerUrl: string; + openworkServerStatus: OpenworkServerStatus; + openworkServerCapabilities: OpenworkServerCapabilities | null; + openworkServerCheckedAt: number | null; + openworkServerHostInfo: OpenworkServerInfo | null; + openworkServerHostInfoReady: boolean; + openworkServerDiagnostics: OpenworkServerDiagnostics | null; + openworkReconnectBusy: boolean; + openworkAuditEntries: OpenworkAuditEntry[]; + openworkAuditStatus: "idle" | "loading" | "error"; + openworkAuditError: string | null; + devtoolsWorkspaceId: string | null; +}; + +const applyStateAction = (current: T, next: SetStateAction) => + typeof next === "function" ? (next as (value: T) => T)(current) : next; + +export function createOpenworkServerStore(options: CreateOpenworkServerStoreOptions) { + const bootStartedAt = Date.now(); + const listeners = new Set<() => void>(); + const intervals = new Map(); + + let clientCacheKey = ""; + let clientCacheValue: OpenworkServerClient | null = null; + let started = false; + let disposed = false; + let healthTimeoutId: number | null = null; + let healthBusy = false; + let healthDelayMs = 10_000; + let snapshot: OpenworkServerStoreSnapshot; + + let state: MutableState = { + openworkServerSettings: readOpenworkServerSettings(), + shareRemoteAccessBusy: false, + shareRemoteAccessError: null, + openworkServerUrl: "", + openworkServerStatus: "disconnected", + openworkServerCapabilities: null, + openworkServerCheckedAt: null, + openworkServerHostInfo: null, + openworkServerHostInfoReady: !isDesktopRuntime(), + openworkServerDiagnostics: null, + openworkReconnectBusy: false, + openworkAuditEntries: [], + openworkAuditStatus: "idle", + openworkAuditError: null, + devtoolsWorkspaceId: null, + }; + + const emitChange = () => { + for (const listener of listeners) listener(); + }; + + const getBaseUrl = () => { + const pref = options.startupPreference(); + const hostInfo = state.openworkServerHostInfo; + const settingsUrl = normalizeOpenworkServerUrl(state.openworkServerSettings.urlOverride ?? "") ?? ""; + + if (pref === "local") return hostInfo?.baseUrl ?? ""; + if (pref === "server" && settingsUrl && isLoopbackOpenworkServerUrl(settingsUrl) && hostInfo?.baseUrl) { + return hostInfo.baseUrl; + } + if (pref === "server") return settingsUrl; + return hostInfo?.baseUrl ?? settingsUrl; + }; + + const getAuth = () => { + const pref = options.startupPreference(); + const hostInfo = state.openworkServerHostInfo; + const settingsUrl = normalizeOpenworkServerUrl(state.openworkServerSettings.urlOverride ?? "") ?? ""; + const settingsToken = state.openworkServerSettings.token?.trim() ?? ""; + const settingsHostToken = state.openworkServerSettings.hostToken?.trim() ?? ""; + const clientToken = hostInfo?.clientToken?.trim() ?? ""; + const hostToken = hostInfo?.hostToken?.trim() ?? ""; + + if (pref === "local") { + return { token: clientToken || undefined, hostToken: hostToken || undefined }; + } + if (pref === "server" && settingsUrl && isLoopbackOpenworkServerUrl(settingsUrl) && hostInfo?.baseUrl) { + return { + token: clientToken || settingsToken || undefined, + hostToken: hostToken || settingsHostToken || undefined, + }; + } + if (pref === "server") { + return { + token: settingsToken || undefined, + hostToken: settingsUrl && isLoopbackOpenworkServerUrl(settingsUrl) ? settingsHostToken || undefined : undefined, + }; + } + if (hostInfo?.baseUrl) { + return { token: clientToken || undefined, hostToken: hostToken || undefined }; + } + return { + token: settingsToken || undefined, + hostToken: settingsUrl && isLoopbackOpenworkServerUrl(settingsUrl) ? settingsHostToken || undefined : undefined, + }; + }; + + const getClient = () => { + const baseUrl = getBaseUrl().trim(); + if (!baseUrl) { + clientCacheKey = ""; + clientCacheValue = null; + return null; + } + + const auth = getAuth(); + const key = `${baseUrl}::${auth.token ?? ""}::${auth.hostToken ?? ""}`; + if (key !== clientCacheKey) { + clientCacheKey = key; + clientCacheValue = createOpenworkServerClient({ + baseUrl, + token: auth.token, + hostToken: auth.hostToken, + }); + } + return clientCacheValue; + }; + + const refreshSnapshot = () => { + const openworkServerBaseUrl = getBaseUrl().trim(); + const openworkServerAuth = getAuth(); + const openworkServerClient = getClient(); + const openworkServerReady = state.openworkServerStatus === "connected"; + const openworkServerWorkspaceReady = Boolean(options.runtimeWorkspaceId()); + const resolvedOpenworkCapabilities = state.openworkServerCapabilities; + + const pref = options.startupPreference(); + const info = state.openworkServerHostInfo; + const hostUrl = info?.connectUrl ?? info?.lanUrl ?? info?.mdnsUrl ?? info?.baseUrl ?? ""; + const settingsUrl = normalizeOpenworkServerUrl(state.openworkServerSettings.urlOverride ?? "") ?? ""; + + let openworkServerUrl = hostUrl || settingsUrl; + if (pref === "local") openworkServerUrl = hostUrl; + if (pref === "server") openworkServerUrl = settingsUrl; + state.openworkServerUrl = openworkServerUrl; + + snapshot = { + openworkServerSettings: state.openworkServerSettings, + shareRemoteAccessBusy: state.shareRemoteAccessBusy, + shareRemoteAccessError: state.shareRemoteAccessError, + openworkServerUrl, + openworkServerBaseUrl, + openworkServerAuth, + openworkServerClient, + openworkServerStatus: state.openworkServerStatus, + openworkServerCapabilities: state.openworkServerCapabilities, + openworkServerReady, + openworkServerWorkspaceReady, + resolvedOpenworkCapabilities, + openworkServerCanWriteSkills: + openworkServerReady && + openworkServerWorkspaceReady && + (resolvedOpenworkCapabilities?.skills?.write ?? false), + openworkServerCanWritePlugins: + openworkServerReady && + openworkServerWorkspaceReady && + (resolvedOpenworkCapabilities?.plugins?.write ?? false), + openworkServerHostInfo: state.openworkServerHostInfo, + openworkServerDiagnostics: state.openworkServerDiagnostics, + openworkReconnectBusy: state.openworkReconnectBusy, + openworkAuditEntries: state.openworkAuditEntries, + openworkAuditStatus: state.openworkAuditStatus, + openworkAuditError: state.openworkAuditError, + devtoolsWorkspaceId: state.devtoolsWorkspaceId, + }; + }; + + const mutateState = (updater: (current: MutableState) => MutableState) => { + state = updater(state); + refreshSnapshot(); + emitChange(); + }; + + const setStateField = (key: K, value: MutableState[K]) => { + if (Object.is(state[key], value)) return; + mutateState((current) => ({ ...current, [key]: value })); + }; + + const setOpenworkServerSettings = (next: SetStateAction) => { + const resolved = applyStateAction(state.openworkServerSettings, next); + mutateState((current) => ({ ...current, openworkServerSettings: resolved })); + queueHealthCheck(0); + }; + + const updateOpenworkServerSettings = (next: OpenworkServerSettings) => { + const stored = writeOpenworkServerSettings(next); + mutateState((current) => ({ ...current, openworkServerSettings: stored })); + queueHealthCheck(0); + }; + + const resetOpenworkServerSettings = () => { + clearOpenworkServerSettings(); + mutateState((current) => ({ ...current, openworkServerSettings: {} })); + queueHealthCheck(0); + }; + + const shouldWaitForLocalHostInfo = () => + isDesktopRuntime() && + options.startupPreference() !== "server" && + !state.openworkServerHostInfoReady; + + const shouldRetryStartupCheck = (status: OpenworkServerStatus) => + status !== "connected" && + isDesktopRuntime() && + options.startupPreference() !== "server" && + Date.now() - bootStartedAt < 5_000; + + 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 capabilities = await client.capabilities(); + return { status: "connected" as OpenworkServerStatus, capabilities }; + } 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 clearHealthTimeout = () => { + if (healthTimeoutId !== null) { + window.clearTimeout(healthTimeoutId); + healthTimeoutId = null; + } + }; + + const queueHealthCheck = (delayMs: number) => { + if (disposed || typeof window === "undefined") return; + clearHealthTimeout(); + healthTimeoutId = window.setTimeout(() => { + healthTimeoutId = null; + void runHealthCheck(); + }, Math.max(0, delayMs)); + }; + + const runHealthCheck = async () => { + if (disposed || typeof window === "undefined") return; + if (!options.documentVisible()) return; + if (shouldWaitForLocalHostInfo()) return; + if (healthBusy) return; + + const url = getBaseUrl().trim(); + const auth = getAuth(); + if (!url) { + mutateState((current) => ({ + ...current, + openworkServerStatus: "disconnected", + openworkServerCapabilities: null, + openworkServerCheckedAt: Date.now(), + })); + return; + } + + healthBusy = true; + try { + let result = await checkOpenworkServer(url, auth.token, auth.hostToken); + + if (shouldRetryStartupCheck(result.status)) { + await new Promise((resolve) => window.setTimeout(resolve, 250)); + if (disposed) return; + + try { + const info = await openworkServerInfo(); + if (disposed) return; + + mutateState((current) => ({ + ...current, + openworkServerHostInfo: info, + openworkServerHostInfoReady: 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 { + // Preserve the original check result when the retry probe fails. + } + } + + if (disposed) return; + healthDelayMs = + result.status === "connected" || result.status === "limited" + ? 10_000 + : Math.min(healthDelayMs * 2, 60_000); + + mutateState((current) => ({ + ...current, + openworkServerStatus: result.status, + openworkServerCapabilities: result.capabilities, + openworkServerCheckedAt: Date.now(), + })); + } catch { + healthDelayMs = Math.min(healthDelayMs * 2, 60_000); + mutateState((current) => ({ + ...current, + openworkServerCheckedAt: Date.now(), + })); + } finally { + healthBusy = false; + if (!disposed) queueHealthCheck(healthDelayMs); + } + }; + + const syncFromOptions = () => { + refreshSnapshot(); + emitChange(); + + if (!isDesktopRuntime()) return; + const port = state.openworkServerHostInfo?.port; + if (!port) return; + if (state.openworkServerSettings.portOverride === port) return; + + updateOpenworkServerSettings({ + ...state.openworkServerSettings, + portOverride: port, + }); + }; + + const startInterval = (key: string, fn: () => void, ms: number) => { + if (typeof window === "undefined") return; + if (intervals.has(key)) return; + intervals.set(key, window.setInterval(fn, ms)); + }; + + const stopInterval = (key: string) => { + const id = intervals.get(key); + if (id === undefined) return; + window.clearInterval(id); + intervals.delete(key); + }; + + const start = () => { + if (typeof window === "undefined") return; + if (started) return; + // Allow restart after a prior dispose() (React 18 StrictMode double-mounts + // each effect in dev: mount → dispose → re-mount). If we early-return when + // `disposed` is true, the real mount never arms polling and the UI stays + // on stale/empty state forever. + disposed = false; + started = true; + + syncFromOptions(); + queueHealthCheck(0); + + const refreshHostInfo = () => { + if (!isDesktopRuntime()) return; + if (!options.documentVisible()) return; + void (async () => { + try { + const info = await openworkServerInfo(); + if (disposed) return; + mutateState((current) => ({ + ...current, + openworkServerHostInfo: info, + openworkServerHostInfoReady: true, + })); + } catch { + if (disposed) return; + mutateState((current) => ({ + ...current, + openworkServerHostInfo: null, + openworkServerHostInfoReady: true, + })); + } + })(); + }; + refreshHostInfo(); + startInterval("hostInfo", refreshHostInfo, 10_000); + + const refreshDiagnostics = () => { + if (!options.documentVisible()) return; + if (!options.developerMode()) { + setStateField("openworkServerDiagnostics", null); + return; + } + + const client = getClient(); + if (!client || state.openworkServerStatus === "disconnected") { + setStateField("openworkServerDiagnostics", null); + return; + } + + void (async () => { + try { + const status = await client.status(); + if (!disposed) setStateField("openworkServerDiagnostics", status); + } catch { + if (!disposed) setStateField("openworkServerDiagnostics", null); + } + })(); + }; + refreshDiagnostics(); + startInterval("diagnostics", refreshDiagnostics, 10_000); + + const refreshDevtoolsWorkspace = () => { + if (!options.documentVisible()) return; + if (!options.developerMode()) { + setStateField("devtoolsWorkspaceId", null); + return; + } + + const client = getClient(); + if (!client) { + setStateField("devtoolsWorkspaceId", null); + return; + } + + void (async () => { + try { + const response = await client.listWorkspaces(); + if (disposed) return; + const items = Array.isArray(response.items) ? response.items : []; + const activeMatch = response.activeId + ? items.find((item) => item.id === response.activeId) + : null; + setStateField("devtoolsWorkspaceId", activeMatch?.id ?? items[0]?.id ?? null); + } catch { + if (!disposed) setStateField("devtoolsWorkspaceId", null); + } + })(); + }; + refreshDevtoolsWorkspace(); + startInterval("devtoolsWorkspace", refreshDevtoolsWorkspace, 20_000); + + const refreshAudit = () => { + if (!options.documentVisible()) return; + if (!options.developerMode()) { + mutateState((current) => ({ + ...current, + openworkAuditEntries: [], + openworkAuditStatus: "idle", + openworkAuditError: null, + })); + return; + } + + const client = getClient(); + const workspaceId = state.devtoolsWorkspaceId; + if (!client || !workspaceId) { + mutateState((current) => ({ + ...current, + openworkAuditEntries: [], + openworkAuditStatus: "idle", + openworkAuditError: null, + })); + return; + } + + mutateState((current) => ({ + ...current, + openworkAuditStatus: "loading", + openworkAuditError: null, + })); + + void (async () => { + try { + const result = await client.listAudit(workspaceId, 50); + if (disposed) return; + mutateState((current) => ({ + ...current, + openworkAuditEntries: Array.isArray(result.items) ? result.items : [], + openworkAuditStatus: "idle", + })); + } catch (error) { + if (disposed) return; + mutateState((current) => ({ + ...current, + openworkAuditEntries: [], + openworkAuditStatus: "error", + openworkAuditError: + error instanceof Error + ? error.message + : t("app.error_audit_load", currentLocale()), + })); + } + })(); + }; + refreshAudit(); + startInterval("audit", refreshAudit, 15_000); + }; + + const dispose = () => { + disposed = true; + started = false; + clearHealthTimeout(); + for (const key of [...intervals.keys()]) stopInterval(key); + }; + + const testOpenworkServerConnection = async (next: OpenworkServerSettings) => { + const derived = normalizeOpenworkServerUrl(next.urlOverride ?? ""); + if (!derived) { + mutateState((current) => ({ + ...current, + openworkServerStatus: "disconnected", + openworkServerCapabilities: null, + openworkServerCheckedAt: Date.now(), + })); + return false; + } + + const result = await checkOpenworkServer(derived, next.token); + mutateState((current) => ({ + ...current, + openworkServerStatus: result.status, + openworkServerCapabilities: result.capabilities, + openworkServerCheckedAt: Date.now(), + })); + + const ok = result.status === "connected" || result.status === "limited"; + if (ok && !isDesktopRuntime()) { + 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 (state.openworkReconnectBusy) return false; + setStateField("openworkReconnectBusy", true); + + try { + let hostInfo = state.openworkServerHostInfo; + if (isDesktopRuntime()) { + try { + hostInfo = await openworkServerInfo(); + mutateState((current) => ({ ...current, openworkServerHostInfo: hostInfo })); + } catch { + hostInfo = null; + setStateField("openworkServerHostInfo", null); + } + } + + if (hostInfo?.clientToken?.trim() && options.startupPreference() !== "server") { + const liveToken = hostInfo.clientToken.trim(); + const settings = state.openworkServerSettings; + if ((settings.token?.trim() ?? "") !== liveToken) { + updateOpenworkServerSettings({ ...settings, token: liveToken }); + } + } + + const url = getBaseUrl().trim(); + const auth = getAuth(); + if (!url) { + mutateState((current) => ({ + ...current, + openworkServerStatus: "disconnected", + openworkServerCapabilities: null, + openworkServerCheckedAt: Date.now(), + })); + return false; + } + + const result = await checkOpenworkServer(url, auth.token, auth.hostToken); + mutateState((current) => ({ + ...current, + openworkServerStatus: result.status, + openworkServerCapabilities: result.capabilities, + openworkServerCheckedAt: Date.now(), + })); + return result.status === "connected" || result.status === "limited"; + } finally { + setStateField("openworkReconnectBusy", false); + } + }; + + async function ensureLocalOpenworkServerClient(): Promise { + let hostInfo = state.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 { + // Fall through to a local restart. + } + } + + if (!isDesktopRuntime()) return null; + + try { + hostInfo = await openworkServerRestart({ + remoteAccessEnabled: state.openworkServerSettings.remoteAccessEnabled === true, + }); + mutateState((current) => ({ ...current, openworkServerHostInfo: 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 (state.shareRemoteAccessBusy) return; + const previous = state.openworkServerSettings; + const next: OpenworkServerSettings = { + ...previous, + remoteAccessEnabled: enabled, + }; + + mutateState((current) => ({ + ...current, + shareRemoteAccessBusy: true, + shareRemoteAccessError: null, + })); + updateOpenworkServerSettings(next); + + try { + if (isDesktopRuntime() && 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); + mutateState((current) => ({ + ...current, + shareRemoteAccessError: + error instanceof Error + ? error.message + : t("app.error_remote_access", currentLocale()), + })); + return; + } finally { + setStateField("shareRemoteAccessBusy", false); + } + }; + + refreshSnapshot(); + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + const getSnapshot = () => snapshot; + + return { + subscribe, + getSnapshot, + start, + dispose, + syncFromOptions, + setOpenworkServerSettings, + updateOpenworkServerSettings, + resetOpenworkServerSettings, + saveShareRemoteAccess, + checkOpenworkServer, + testOpenworkServerConnection, + reconnectOpenworkServer, + ensureLocalOpenworkServerClient, + }; +} + +export function useOpenworkServerStoreSnapshot(store: OpenworkServerStore) { + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} diff --git a/apps/app/src/react-app/domains/connections/provider-auth/index.ts b/apps/app/src/react-app/domains/connections/provider-auth/index.ts new file mode 100644 index 0000000000..34e2274534 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/index.ts @@ -0,0 +1,44 @@ +import { + createContext, + createElement, + useContext, + useSyncExternalStore, + type ReactNode, +} from "react"; + +import type { ProviderAuthStore } from "./store"; + +export { createProviderAuthStore, useProviderAuthStoreSnapshot } from "./store"; +export type { + ProviderAuthMethod, + ProviderAuthProvider, + ProviderAuthStoreSnapshot, + ProviderOAuthStartResult, + ProviderAuthStore, +} from "./store"; +export { default as ProviderAuthModal } from "./provider-auth-modal"; + +const ProviderAuthContext = createContext(null); + +type ProviderAuthStoreProviderProps = { + store: ProviderAuthStore; + children: ReactNode; +}; + +export function ProviderAuthStoreProvider({ + store, + children, +}: ProviderAuthStoreProviderProps) { + return createElement(ProviderAuthContext.Provider, { value: store }, children); +} + +export function useProviderAuth() { + const store = useContext(ProviderAuthContext); + if (!store) { + throw new Error("useProviderAuth must be used within a ProviderAuthStoreProvider"); + } + + useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + + return store; +} diff --git a/apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx b/apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx new file mode 100644 index 0000000000..16d137ef38 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx @@ -0,0 +1,1010 @@ +/** @jsxImportSource react */ +import { + CheckCircle2, + ChevronRight, + Loader2, + Search, + X, +} from "lucide-react"; +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; + +import { openDesktopUrl } from "../../../../app/lib/desktop"; +import { isDesktopRuntime } from "../../../../app/utils"; +import { compareProviders } from "../../../../app/utils/providers"; +import { Button } from "../../../design-system/button"; +import { ProviderIcon } from "../../../design-system/provider-icon"; +import { TextInput } from "../../../design-system/text-input"; +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; + 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 workerType = props.workerType === "remote" ? "remote" : "local"; + const isRemoteWorker = workerType === "remote"; + + const [view, setView] = useState< + "list" | "method" | "api" | "cloud" | "oauth-code" | "oauth-auto" + >("list"); + const [selectedProviderId, setSelectedProviderId] = useState(null); + const [selectedCloudMethod, setSelectedCloudMethod] = useState(null); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [oauthCodeInput, setOauthCodeInput] = useState(""); + const [oauthSession, setOauthSession] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [activeEntryIndex, setActiveEntryIndex] = useState(0); + const [localError, setLocalError] = useState(null); + const [pollingBusy, setPollingBusy] = useState(false); + const [oauthAutoBusy, setOauthAutoBusy] = useState(false); + const [oauthCodeCopied, setOauthCodeCopied] = useState(false); + const [oauthBrowserOpened, setOauthBrowserOpened] = useState(false); + const [autoOpenedPreferredProviderId, setAutoOpenedPreferredProviderId] = useState(null); + + const searchInputRef = useRef(null); + const providerPollRef = useRef(null); + const oauthAutoPollRef = useRef(null); + const oauthCodeCopiedResetRef = useRef(null); + + 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 === "openai"; + }; + + const isAnthropicProvider = (id: string, fallbackName?: string) => { + const normalizedId = id.trim().toLowerCase(); + const normalizedName = fallbackName?.trim().toLowerCase() ?? ""; + return normalizedId === "anthropic" || normalizedName === "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 = useMemo(() => { + 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); + }, [isRemoteWorker, props.authMethods, props.connectedProviderIds, props.providers]); + + const selectedEntry = useMemo( + () => entries.find((entry) => entry.id === selectedProviderId) ?? null, + [entries, selectedProviderId], + ); + + const resolvedView = selectedEntry ? view : "list"; + const errorMessage = localError ?? props.error; + + const filteredEntries = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return entries; + return entries.filter((entry) => { + const methodText = entry.methods.map((method) => method.label || (method.type === "oauth" ? "OAuth" : "API key")).join(" "); + return `${entry.name} ${entry.id} ${methodText}`.toLowerCase().includes(query); + }); + }, [entries, searchQuery]); + + const oauthInstructions = oauthSession?.authorization.instructions?.trim() ?? ""; + const isOpenAiHeadlessSession = Boolean( + oauthSession && oauthSession.providerId === "openai" && oauthSession.methodLabel.toLowerCase().includes("headless"), + ); + const shouldStartOauthAutoPolling = + props.open && + resolvedView === "oauth-auto" && + oauthSession && + (!isOpenAiHeadlessSession || oauthBrowserOpened); + + const oauthDisplayCode = useMemo(() => { + if (!oauthInstructions) return ""; + const matched = oauthInstructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0]; + if (matched) return matched; + if (oauthInstructions.includes(":")) { + return oauthInstructions.split(":").slice(1).join(":").trim(); + } + return oauthInstructions; + }, [oauthInstructions]); + + const methodLabel = (method: ProviderAuthMethod) => + method.label || (method.type === "oauth" ? "OAuth" : "API key"); + + const actionDisabled = props.loading || props.submitting; + + const resetState = () => { + if (oauthCodeCopiedResetRef.current !== null && typeof window !== "undefined") { + window.clearTimeout(oauthCodeCopiedResetRef.current); + oauthCodeCopiedResetRef.current = null; + } + setView("list"); + setSelectedProviderId(null); + setSelectedCloudMethod(null); + setApiKeyInput(""); + setOauthCodeInput(""); + setOauthSession(null); + setSearchQuery(""); + setActiveEntryIndex(0); + setLocalError(null); + setOauthCodeCopied(false); + setOauthBrowserOpened(false); + }; + + const stopProviderPolling = () => { + if (providerPollRef.current !== null) { + window.clearInterval(providerPollRef.current); + providerPollRef.current = null; + } + }; + + const stopOauthAutoPolling = () => { + if (oauthAutoPollRef.current !== null) { + window.clearInterval(oauthAutoPollRef.current); + oauthAutoPollRef.current = null; + } + }; + + const handleClose = () => { + void props.onRefreshProviders?.(); + stopOauthAutoPolling(); + stopProviderPolling(); + resetState(); + props.onClose(); + }; + + useEffect(() => { + if (!props.open) { + setAutoOpenedPreferredProviderId(null); + resetState(); + } + }, [props.open]); + + useEffect(() => { + 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))); + }, [filteredEntries.length, props.open, resolvedView]); + + useEffect(() => { + if (!props.open || resolvedView !== "list") return; + queueMicrotask(() => searchInputRef.current?.focus()); + }, [props.open, resolvedView]); + + useEffect(() => { + 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); + }); + }, [ + autoOpenedPreferredProviderId, + entries, + props.loading, + props.open, + props.preferredProviderId, + resolvedView, + ]); + + useEffect(() => { + return () => { + stopOauthAutoPolling(); + stopProviderPolling(); + if (oauthCodeCopiedResetRef.current !== null) { + window.clearTimeout(oauthCodeCopiedResetRef.current); + oauthCodeCopiedResetRef.current = null; + } + }; + }, []); + + const isOauthView = resolvedView === "oauth-code" || resolvedView === "oauth-auto"; + const activeProviderId = oauthSession?.providerId ?? selectedProviderId; + const isActiveProviderConnected = + !!activeProviderId && (props.connectedProviderIds ?? []).includes(activeProviderId); + + const pollProviders = async () => { + const id = activeProviderId; + if (!id || pollingBusy) return; + setPollingBusy(true); + try { + await props.onRefreshProviders?.(); + } finally { + setPollingBusy(false); + } + if ((props.connectedProviderIds ?? []).includes(id)) { + handleClose(); + } + }; + + const startProviderPolling = () => { + if (typeof window === "undefined") return; + if (providerPollRef.current !== null) return; + void pollProviders(); + providerPollRef.current = window.setInterval(() => { + void pollProviders(); + }, 2000); + }; + + useEffect(() => { + if (!props.open || !isOauthView) { + stopProviderPolling(); + return; + } + if (isActiveProviderConnected) { + handleClose(); + return; + } + startProviderPolling(); + }, [isActiveProviderConnected, isOauthView, props.open]); + + const openOauthUrl = async (url: string) => { + if (!url) return; + if (isDesktopRuntime()) { + await openDesktopUrl(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 (oauthCodeCopiedResetRef.current !== null) { + window.clearTimeout(oauthCodeCopiedResetRef.current); + } + oauthCodeCopiedResetRef.current = window.setTimeout(() => { + setOauthCodeCopied(false); + oauthCodeCopiedResetRef.current = 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 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 (oauthAutoPollRef.current !== null) return; + void attemptOauthAutoCompletion(); + oauthAutoPollRef.current = window.setInterval(() => { + void attemptOauthAutoCompletion(); + }, 2000); + }; + + useEffect(() => { + if (!shouldStartOauthAutoPolling) { + stopOauthAutoPolling(); + return; + } + startOauthAutoPolling(); + }, [shouldStartOauthAutoPolling]); + + 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 handleMethodSelect = async (method: ProviderAuthMethod) => { + if (!selectedEntry || actionDisabled) return; + setLocalError(null); + setSelectedCloudMethod(null); + + if (method.type === "oauth") { + await startOauth(selectedEntry, method.methodIndex); + return; + } + + if (method.type === "cloud") { + setSelectedCloudMethod(method); + setView("cloud"); + return; + } + + setView("api"); + }; + + 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 handleApiSubmit = async () => { + if (!selectedEntry || actionDisabled) return; + + const trimmed = apiKeyInput.trim(); + if (!trimmed) { + setLocalError("API key is required."); + return; + } + + setLocalError(null); + try { + await props.onSubmitApiKey(selectedEntry.id, trimmed); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save API key"; + setLocalError(message); + } + }; + + const handleCloudSubmit = async () => { + if (!selectedCloudMethod?.cloudProviderId || actionDisabled) return; + + setLocalError(null); + try { + await props.onConnectCloudProvider(selectedCloudMethod.cloudProviderId); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to connect organization provider"; + setLocalError(message); + } + }; + + const handleOauthCodeSubmit = async () => { + if (!selectedEntry || !oauthSession || actionDisabled) return; + + const trimmed = oauthCodeInput.trim(); + if (!trimmed) { + setLocalError("Authorization code is required."); + return; + } + + await submitOauth(selectedEntry.id, oauthSession.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") { + const nativeEvent = event.nativeEvent as globalThis.KeyboardEvent & { keyCode?: number }; + if (nativeEvent.isComposing || nativeEvent.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."; + }; + + if (!props.open) return null; + + return ( +
+
+
+
+

Connect providers

+

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

+
+ +
+ +
+
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : props.loading ? ( +
+ Loading providers... +
+ ) : null} +
+ + {!props.loading ? ( +
+ {resolvedView === "list" ? ( +
+
+ + { + setSearchQuery(event.currentTarget.value); + setActiveEntryIndex(0); + }} + autoComplete="off" + autoCapitalize="off" + spellCheck={false} + disabled={actionDisabled} + className="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" + /> +
+ + {filteredEntries.length ? ( + filteredEntries.map((entry, index) => ( + + )) + ) : ( +
+ {entries.length ? "No providers match your search." : "No providers available."} +
+ )} + +
Arrow keys to navigate, Enter to select.
+
+ ) : null} + + {resolvedView === "method" && selectedEntry ? ( +
+
+
+
{selectedEntry.name}
+
Choose how you'd like to connect.
+
+ +
+
+ {selectedEntry.methods.map((method) => ( + + ))} +
+
+ ) : null} + + {resolvedView === "api" && selectedEntry ? ( +
+
+
+
{selectedEntry.name}
+
Paste your API key to connect.
+
+ +
+ { + setApiKeyInput(event.currentTarget.value); + if (localError) setLocalError(null); + }} + autoComplete="off" + autoCapitalize="off" + spellCheck={false} + disabled={actionDisabled} + /> + {selectedEntry.env.length > 0 ? ( +
+ Env vars: {selectedEntry.env.join(", ")} +
+ ) : null} +
+
Keys are stored locally by OpenCode.
+ +
+
+ ) : null} + + {resolvedView === "cloud" && selectedEntry && selectedCloudMethod ? ( +
+
+
+
{selectedEntry.name}
+
Connect with the provider managed by your organization.
+
+ +
+
+ {selectedCloudMethod.description ?? "Use the provider and credential managed by your organization."} +
+ {(selectedCloudMethod.modelCount ?? 0) > 0 ? ( +
+ {(selectedCloudMethod.modelCount ?? 0)} curated model{(selectedCloudMethod.modelCount ?? 0) === 1 ? "" : "s"} will be added to this workspace. +
+ ) : null} + {(selectedCloudMethod.env?.length ?? 0) > 0 ? ( +
+ Env vars: {selectedCloudMethod.env?.join(", ")} +
+ ) : null} +
+
+ OpenWork will install the provider config and use the credential stored for your org. +
+ +
+
+ ) : null} + + {resolvedView === "oauth-code" && selectedEntry && oauthSession ? ( +
+
+
+
{selectedEntry.name}
+
Finish OAuth by pasting the authorization code.
+
+ +
+
+ Complete sign-in in your browser, then paste the code here. +
+ {oauthInstructions ? ( +
+ {oauthInstructions} +
+ ) : null} + { + 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} + /> +
+ + +
+
+ ) : null} + + {resolvedView === "oauth-auto" && selectedEntry && oauthSession ? ( +
+
+
+
{selectedEntry.name}
+
Waiting for browser confirmation.
+
+ +
+ {isOpenAiHeadlessSession ? ( +
+
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".
+
+ ) : ( +
+ Sign in in the browser tab we just opened. We will complete the connection automatically. +
+ )} + {oauthDisplayCode ? ( +
+
+
Confirmation code
+
{oauthDisplayCode}
+
+ +
+ ) : null} + {isOpenAiHeadlessSession && !oauthBrowserOpened ? ( +
+ Authorization checks will start after you click Open Browser. +
+ ) : ( +
+ + Checking connection status automatically... +
+ )} +
+ +
+ This window will close once the provider is connected. +
+
+
+ ) : null} +
+ ) : null} +
+ +
+
+ {props.submitting ? submittingLabel() : null} +
+ +
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts new file mode 100644 index 0000000000..f99457633b --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -0,0 +1,1639 @@ +import { useSyncExternalStore } from "react"; + +import { applyEdits, modify, parse } from "jsonc-parser"; +import type { + ProviderAuthAuthorization, + ProviderConfig, + ProviderListResponse, +} from "@opencode-ai/sdk/v2/client"; + +import { t } from "../../../../i18n"; +import { + createDenClient, + readDenSettings, + type DenOrgLlmProvider, + type DenOrgLlmProviderConnection, +} from "../../../../app/lib/den"; +import { unwrap, waitForHealthy } from "../../../../app/lib/opencode"; +import { + readOpencodeConfig, + writeOpencodeConfig, + workspaceOpenworkRead, + workspaceOpenworkWrite, +} from "../../../../app/lib/desktop"; +import type { + Client, + ProviderListItem, + WorkspaceDisplay, +} from "../../../../app/types"; +import { isDesktopRuntime, safeStringify } from "../../../../app/utils"; +import { + compareProviders, + filterProviderList, + mapConfigProvidersToList, +} from "../../../../app/utils/providers"; +import type { OpenworkServerStore } from "../openwork-server-store"; +import { + denSessionUpdatedEvent, + type DenSessionUpdatedDetail, +} from "../../../../app/lib/den-session-events"; +import { + readWorkspaceCloudImports, + withWorkspaceCloudImports, + type CloudImportedProvider, +} from "../../../../app/cloud/import-state"; + +type ProviderReturnFocusTarget = "none" | "composer"; +type CloudProviderSyncReason = "sign_in" | "app_launch" | "interval" | "settings_cloud_opened"; + +export type ProviderAuthMethod = { + type: "oauth" | "api" | "cloud"; + label: string; + methodIndex?: number; + cloudProviderId?: string; + description?: string; + env?: string[]; + modelCount?: number; +}; + +export type ProviderAuthProvider = { + id: string; + name: string; + env: string[]; +}; + +export type ProviderOAuthStartResult = { + methodIndex: number; + authorization: ProviderAuthAuthorization; +}; + +export type ProviderAuthStoreSnapshot = { + providerAuthModalOpen: boolean; + providerAuthBusy: boolean; + providerAuthError: string | null; + providerAuthMethods: Record; + providerAuthPreferredProviderId: string | null; + providerAuthWorkerType: "local" | "remote"; + providerAuthProviders: ProviderAuthProvider[]; + cloudOrgProviders: DenOrgLlmProvider[]; + importedCloudProviders: Record; +}; + +type CreateProviderAuthStoreOptions = { + client: () => Client | null; + providers: () => ProviderListItem[]; + providerDefaults: () => Record; + providerConnectedIds: () => string[]; + disabledProviders: () => string[]; + selectedWorkspaceDisplay: () => WorkspaceDisplay; + selectedWorkspaceRoot: () => string; + runtimeWorkspaceId: () => string | null; + openworkServer: OpenworkServerStore; + setProviders: (value: ProviderListItem[]) => void; + setProviderDefaults: (value: Record) => void; + setProviderConnectedIds: (value: string[]) => void; + setDisabledProviders: (value: string[]) => void; + markOpencodeConfigReloadRequired: () => void; + focusPromptSoon?: () => void; +}; + +type MutableState = { + providerAuthModalOpen: boolean; + providerAuthBusy: boolean; + providerAuthError: string | null; + providerAuthMethods: Record; + providerAuthPreferredProviderId: string | null; + providerAuthReturnFocusTarget: ProviderReturnFocusTarget; + cloudOrgProviders: DenOrgLlmProvider[]; + importedCloudProviders: Record; +}; + +export type ProviderAuthStore = ReturnType; + +export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) { + const listeners = new Set<() => void>(); + + let snapshot: ProviderAuthStoreSnapshot; + let disposed = false; + let started = false; + let denSessionCleanup: (() => void) | null = null; + let lastWorkspaceKey = ""; + + let state: MutableState = { + providerAuthModalOpen: false, + providerAuthBusy: false, + providerAuthError: null, + providerAuthMethods: {}, + providerAuthPreferredProviderId: null, + providerAuthReturnFocusTarget: "none", + cloudOrgProviders: [], + importedCloudProviders: {}, + }; + + let cloudOrgProvidersLoadKey = ""; + let cloudOrgProvidersInFlightKey = ""; + let cloudOrgProvidersInFlight: Promise | null = null; + let cloudProviderSyncInFlight: Promise | null = null; + let cloudProviderSyncQueuedReason: CloudProviderSyncReason | null = null; + let cloudProviderSyncContextKey = ""; + + const emitChange = () => { + for (const listener of listeners) listener(); + }; + + const getStringList = (value: unknown) => + Array.isArray(value) + ? value.filter( + (entry): entry is string => + typeof entry === "string" && entry.trim().length > 0, + ) + : []; + + const getCloudProviderEnv = (config: Record) => + getStringList(config.env); + const sortStrings = (values: string[]) => [...values].sort(); + const sameStringList = (a: string[], b: string[]) => + a.length === b.length && a.every((value, index) => value === b[index]); + + const getCloudManagedProviderId = ( + provider: Pick, + ) => provider.id.trim(); + + const getProviderAuthWorkerType = (): "local" | "remote" => + options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; + + const getProviderAuthProviders = (): ProviderAuthProvider[] => { + const merged = new Map(); + + for (const provider of options.providers()) { + const id = provider.id?.trim(); + if (!id) continue; + merged.set(id, { + id, + name: provider.name?.trim() || id, + env: Array.isArray(provider.env) ? provider.env : [], + }); + } + + for (const provider of state.cloudOrgProviders) { + const id = provider.providerId.trim(); + if (!id || merged.has(id)) continue; + merged.set(id, { + id, + name: provider.name.trim() || id, + env: getCloudProviderEnv(provider.providerConfig), + }); + } + + return [...merged.values()].sort(compareProviders); + }; + + const refreshSnapshot = () => { + snapshot = { + providerAuthModalOpen: state.providerAuthModalOpen, + providerAuthBusy: state.providerAuthBusy, + providerAuthError: state.providerAuthError, + providerAuthMethods: state.providerAuthMethods, + providerAuthPreferredProviderId: state.providerAuthPreferredProviderId, + providerAuthWorkerType: getProviderAuthWorkerType(), + providerAuthProviders: getProviderAuthProviders(), + cloudOrgProviders: state.cloudOrgProviders, + importedCloudProviders: state.importedCloudProviders, + }; + }; + + const mutateState = (updater: (current: MutableState) => MutableState) => { + state = updater(state); + refreshSnapshot(); + emitChange(); + }; + + const setStateField = ( + key: K, + value: MutableState[K], + ) => { + if (Object.is(state[key], value)) return; + mutateState((current) => ({ ...current, [key]: value })); + }; + + const buildCloudProviderMethod = ( + provider: DenOrgLlmProvider, + ): ProviderAuthMethod => ({ + type: "cloud", + label: + provider.name.trim().toLowerCase() === + provider.providerId.trim().toLowerCase() + ? "Use organization provider" + : `Use ${provider.name}`, + cloudProviderId: provider.id, + description: + provider.models.length > 0 + ? `${provider.models.length} curated model${ + provider.models.length === 1 ? "" : "s" + } managed by your organization.` + : "Use the provider and credential managed by your organization.", + env: getCloudProviderEnv(provider.providerConfig), + modelCount: provider.models.length, + }); + + const buildCloudProviderConfig = ( + provider: DenOrgLlmProviderConnection, + ): ProviderConfig => { + const models = Object.fromEntries( + provider.models.map((model) => { + const next: NonNullable[string] = { + id: model.id, + name: model.name, + }; + const raw = model.config; + for (const key of [ + "family", + "release_date", + "attachment", + "reasoning", + "temperature", + "tool_call", + "interleaved", + "cost", + "limit", + "modalities", + "status", + "options", + "headers", + "provider", + "variants", + ] as const) { + const value = raw[key]; + if (value !== undefined) { + (next as Record)[key] = value; + } + } + return [model.id, next]; + }), + ); + + const next: ProviderConfig = { + id: provider.providerId, + name: provider.name, + env: getCloudProviderEnv(provider.providerConfig), + models, + }; + + if ( + typeof provider.providerConfig.npm === "string" && + provider.providerConfig.npm.trim() + ) { + next.npm = provider.providerConfig.npm; + } + if ( + typeof provider.providerConfig.api === "string" && + provider.providerConfig.api.trim() + ) { + next.api = provider.providerConfig.api; + } + if ( + provider.providerConfig.options && + typeof provider.providerConfig.options === "object" + ) { + next.options = provider.providerConfig.options as Record; + } + if (Array.isArray(provider.providerConfig.whitelist)) { + next.whitelist = getStringList(provider.providerConfig.whitelist); + } + if (Array.isArray(provider.providerConfig.blacklist)) { + next.blacklist = getStringList(provider.providerConfig.blacklist); + } + + return next; + }; + + const readWorkspaceOpenworkConfigRecord = async (): Promise< + Record + > => { + const root = options.selectedWorkspaceRoot().trim(); + const isLocalWorkspace = + options.selectedWorkspaceDisplay().workspaceType === "local"; + const openworkSnapshot = options.openworkServer.getSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = openworkSnapshot.openworkServerCapabilities; + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.read; + + if (canUseOpenworkServer) { + const config = await openworkClient.getConfig(openworkWorkspaceId); + return config.openwork ?? {}; + } + + if (isLocalWorkspace && isDesktopRuntime() && root) { + return (await workspaceOpenworkRead({ + workspacePath: root, + })) as unknown as Record; + } + + return {}; + }; + + const writeWorkspaceOpenworkConfigRecord = async ( + config: Record, + ) => { + const root = options.selectedWorkspaceRoot().trim(); + const isLocalWorkspace = + options.selectedWorkspaceDisplay().workspaceType === "local"; + const openworkSnapshot = options.openworkServer.getSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = openworkSnapshot.openworkServerCapabilities; + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.write; + + if (canUseOpenworkServer) { + await openworkClient.patchConfig(openworkWorkspaceId, { openwork: config }); + return true; + } + + if (isLocalWorkspace && isDesktopRuntime() && root) { + const result = await workspaceOpenworkWrite({ + workspacePath: root, + config: config as never, + }); + if (!result.ok) { + throw new Error( + result.stderr || result.stdout || "Failed to write .opencode/openwork.json", + ); + } + return true; + } + + return false; + }; + + const refreshImportedCloudProviders = async () => { + try { + const config = await readWorkspaceOpenworkConfigRecord(); + const cloudImports = readWorkspaceCloudImports(config); + setStateField("importedCloudProviders", cloudImports.providers); + return cloudImports.providers; + } catch { + setStateField("importedCloudProviders", {}); + return {}; + } + }; + + const persistImportedCloudProviders = async ( + nextProviders: Record, + ) => { + const config = await readWorkspaceOpenworkConfigRecord(); + const cloudImports = readWorkspaceCloudImports(config); + const nextConfig = withWorkspaceCloudImports(config, { + ...cloudImports, + providers: nextProviders, + }); + const persisted = await writeWorkspaceOpenworkConfigRecord(nextConfig); + if (!persisted) { + throw new Error( + "OpenWork server unavailable. Connect to manage imported cloud providers.", + ); + } + setStateField("importedCloudProviders", nextProviders); + }; + + const readProjectConfigFile = async () => { + const root = options.selectedWorkspaceRoot().trim(); + const isLocalWorkspace = + options.selectedWorkspaceDisplay().workspaceType === "local"; + const openworkSnapshot = options.openworkServer.getSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = openworkSnapshot.openworkServerCapabilities; + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.read && + typeof openworkClient.readOpencodeConfigFile === "function"; + + if (canUseOpenworkServer) { + return await openworkClient.readOpencodeConfigFile(openworkWorkspaceId, "project"); + } + + if (isLocalWorkspace && isDesktopRuntime() && root) { + return await readOpencodeConfig("project", root); + } + + return null; + }; + + const writeProjectConfigFile = async (content: string) => { + const root = options.selectedWorkspaceRoot().trim(); + const isLocalWorkspace = + options.selectedWorkspaceDisplay().workspaceType === "local"; + const openworkSnapshot = options.openworkServer.getSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = openworkSnapshot.openworkServerCapabilities; + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.write && + typeof openworkClient.writeOpencodeConfigFile === "function"; + + if (canUseOpenworkServer) { + const result = await openworkClient.writeOpencodeConfigFile( + openworkWorkspaceId, + "project", + content, + ); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); + } + return true; + } + + if (isLocalWorkspace && isDesktopRuntime() && root) { + const result = await writeOpencodeConfig("project", root, content); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); + } + return true; + } + + return false; + }; + + const updateProjectConfigFile = async ( + updater: (raw: string) => string, + fallbackUpdate?: (config: Record) => Record, + ) => { + const configFile = await readProjectConfigFile(); + if (configFile) { + const raw = configFile.content?.trim() + ? configFile.content + : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; + await writeProjectConfigFile(updater(raw)); + return true; + } + + if (!fallbackUpdate) { + return false; + } + + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + const config = unwrap(await c.config.get()); + const next = fallbackUpdate(config); + await c.config.update({ config: next }); + return true; + }; + + const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + const cloudProviderComment = (provider: Pick) => + `// OpenWork Cloud import: ${provider.name + .replace(/\s+/g, " ") + .trim()} (${provider.id}). Manage this entry from Cloud settings.`; + + const removeCloudProviderComment = (raw: string, providerId: string) => + raw.replace( + new RegExp( + `(^[ \t]*)// OpenWork Cloud import:.*\\n\\1(?="${escapeRegExp(providerId)}":)`, + "m", + ), + "$1", + ); + + const addCloudProviderComment = ( + raw: string, + provider: Pick, + localProviderId: string, + ) => { + const withoutExisting = removeCloudProviderComment(raw, localProviderId); + const propertyPattern = new RegExp( + `^([ \t]*)"${escapeRegExp(localProviderId)}":`, + "m", + ); + return withoutExisting.replace( + propertyPattern, + `$1${cloudProviderComment(provider)}\n$1"${localProviderId}":`, + ); + }; + + const getProviderModelIds = (provider: Pick) => + provider.models.map((model) => model.id.trim()).filter(Boolean).sort(); + + const formatConfigWithCloudProvider = ( + raw: string, + provider: DenOrgLlmProviderConnection, + localProviderId: string, + previousProviderId?: string | null, + ) => { + const nextProviderConfig = buildCloudProviderConfig( + provider, + ) as unknown as Record; + let updated = raw.trim() + ? raw + : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; + + if (previousProviderId && previousProviderId !== localProviderId) { + updated = removeCloudProviderComment(updated, previousProviderId); + const previousEdits = modify(updated, ["provider", previousProviderId], undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + updated = applyEdits(updated, previousEdits); + } + + const providerEdits = modify(updated, ["provider", localProviderId], nextProviderConfig, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + updated = applyEdits(updated, providerEdits); + updated = addCloudProviderComment(updated, provider, localProviderId); + + const disabledToRemove = new Set([localProviderId, previousProviderId ?? ""]); + const currentDisabled = options.disabledProviders(); + if (currentDisabled.some((id) => disabledToRemove.has(id))) { + const nextDisabled = currentDisabled.filter((id) => !disabledToRemove.has(id)); + const disabledEdits = modify(updated, ["disabled_providers"], nextDisabled, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + updated = applyEdits(updated, disabledEdits); + } + + return updated.endsWith("\n") ? updated : `${updated}\n`; + }; + + const formatConfigWithoutCloudProvider = (raw: string, providerId: string) => { + let updated = raw.trim() + ? raw + : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; + updated = removeCloudProviderComment(updated, providerId); + const providerEdits = modify(updated, ["provider", providerId], undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + updated = applyEdits(updated, providerEdits); + + const nextDisabled = options.disabledProviders().filter((id) => id !== providerId); + const disabledEdits = modify(updated, ["disabled_providers"], nextDisabled, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + updated = applyEdits(updated, disabledEdits); + return updated.endsWith("\n") ? updated : `${updated}\n`; + }; + + const assertCloudProviderImportSafe = async ( + provider: DenOrgLlmProviderConnection, + ) => { + const localProviderId = getCloudManagedProviderId(provider); + const existingImported = state.importedCloudProviders[provider.id] ?? null; + if ( + existingImported && + existingImported.providerId !== localProviderId && + Object.values(state.importedCloudProviders).some( + (entry) => entry.providerId === localProviderId && entry.cloudProviderId !== provider.id, + ) + ) { + throw new Error( + `${localProviderId} is already imported from another cloud provider. Remove it before importing this one.`, + ); + } + + if (!existingImported && options.providerConnectedIds().includes(localProviderId)) { + throw new Error( + `${localProviderId} is already connected in this workspace. Disconnect it before importing the cloud-managed version.`, + ); + } + + const configFile = await readProjectConfigFile(); + if (!configFile?.content?.trim() || existingImported) { + return; + } + + const parsed = parse(configFile.content); + const providerSection = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record).provider + : null; + if ( + providerSection && + typeof providerSection === "object" && + !Array.isArray(providerSection) && + localProviderId in (providerSection as Record) + ) { + throw new Error( + `${localProviderId} already has a provider block in opencode.jsonc. Remove it before importing the cloud-managed version.`, + ); + } + }; + + const getCloudOrgProvidersKey = () => { + const settings = readDenSettings(); + return [ + settings.baseUrl, + settings.apiBaseUrl ?? "", + settings.activeOrgId?.trim() ?? "", + settings.authToken?.trim() ?? "", + ].join("::"); + }; + + const refreshCloudOrgProviders = async (optionsArg?: { force?: boolean }) => { + const settings = readDenSettings(); + const loadKey = getCloudOrgProvidersKey(); + const token = settings.authToken?.trim() ?? ""; + const orgId = settings.activeOrgId?.trim() ?? ""; + + if (!optionsArg?.force && cloudOrgProvidersLoadKey === loadKey) { + return state.cloudOrgProviders; + } + + if (cloudOrgProvidersInFlight && cloudOrgProvidersInFlightKey === loadKey) { + return cloudOrgProvidersInFlight; + } + + if (!token || !orgId) { + setStateField("cloudOrgProviders", []); + cloudOrgProvidersLoadKey = loadKey; + return []; + } + + const client = createDenClient({ + baseUrl: settings.baseUrl, + apiBaseUrl: settings.apiBaseUrl, + token, + }); + const request = client + .listOrgLlmProviders(orgId) + .then((providers) => { + setStateField("cloudOrgProviders", providers); + cloudOrgProvidersLoadKey = loadKey; + return providers; + }) + .catch((error) => { + setStateField("cloudOrgProviders", []); + cloudOrgProvidersLoadKey = ""; + throw error; + }) + .finally(() => { + if (cloudOrgProvidersInFlightKey === loadKey) { + cloudOrgProvidersInFlight = null; + cloudOrgProvidersInFlightKey = ""; + } + }); + + cloudOrgProvidersInFlight = request; + cloudOrgProvidersInFlightKey = loadKey; + return request; + }; + + const applyProviderListState = (value: ProviderListResponse) => { + options.setProviders(value.all ?? []); + options.setProviderDefaults(value.default ?? {}); + options.setProviderConnectedIds(value.connected ?? []); + refreshSnapshot(); + emitChange(); + }; + + const removeProviderFromState = (providerId: string) => { + const resolved = providerId.trim(); + if (!resolved) return; + options.setProviders(options.providers().filter((provider) => provider.id !== resolved)); + options.setProviderConnectedIds( + options.providerConnectedIds().filter((id) => id !== resolved), + ); + options.setProviderDefaults( + Object.fromEntries( + Object.entries(options.providerDefaults()).filter(([id]) => id !== resolved), + ), + ); + refreshSnapshot(); + emitChange(); + }; + + const assertNoClientError = (result: unknown) => { + const maybe = result as { error?: unknown } | null | undefined; + if (!maybe || maybe.error === undefined) return; + throw new Error(describeProviderError(maybe.error, t("providers.request_failed"))); + }; + + const removeProviderAuthCredentials = async (providerId: string) => { + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + + const authClient = c.auth as unknown as { + remove?: (options: { providerID: string }) => Promise; + set?: (options: { providerID: string; auth: unknown }) => Promise; + }; + if (typeof authClient.remove === "function") { + const result = await authClient.remove({ providerID: providerId }); + assertNoClientError(result); + return; + } + + const rawClient = (c as unknown as { + client?: { delete?: (options: { url: string }) => Promise }; + }).client; + if (rawClient?.delete) { + await rawClient.delete({ url: `/auth/${encodeURIComponent(providerId)}` }); + return; + } + + if (typeof authClient.set === "function") { + const result = await authClient.set({ providerID: providerId, auth: null }); + assertNoClientError(result); + return; + } + + throw new Error(t("providers.removal_unsupported")); + }; + + const describeProviderError = (error: unknown, fallback: string) => { + const readString = (value: unknown, max = 700) => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, Math.max(0, max - 3))}...`; + }; + + const records: Record[] = []; + const root = error && typeof error === "object" ? (error as Record) : null; + if (root) { + records.push(root); + if (root.data && typeof root.data === "object") { + records.push(root.data as Record); + } + if (root.cause && typeof root.cause === "object") { + const cause = root.cause as Record; + records.push(cause); + if (cause.data && typeof cause.data === "object") { + records.push(cause.data as Record); + } + } + } + + const firstString = (keys: string[]) => { + for (const record of records) { + for (const key of keys) { + const value = readString(record[key]); + if (value) return value; + } + } + return null; + }; + + const firstNumber = (keys: string[]) => { + for (const record of records) { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + } + return null; + }; + + const status = firstNumber(["statusCode", "status"]); + const provider = firstString(["providerID", "providerId", "provider"]); + const code = firstString(["code", "errorCode"]); + const response = firstString(["responseBody", "body", "response"]); + const raw = + (error instanceof Error ? readString(error.message) : null) || + firstString(["message", "detail", "reason", "error"]) || + (typeof error === "string" ? readString(error) : null); + + const generic = raw && /^unknown\s+error$/i.test(raw); + const heading = (() => { + if (status === 401 || status === 403) return t("providers.auth_failed"); + if (status === 429) return t("providers.rate_limit_exceeded"); + if (provider) return t("providers.provider_error", undefined, { provider }); + return fallback; + })(); + + const lines = [heading]; + if (raw && !generic && raw !== heading) lines.push(raw); + if (status && !heading.includes(String(status))) lines.push(`Status: ${status}`); + if (provider && !heading.includes(provider)) lines.push(`Provider: ${provider}`); + if (code) lines.push(`Code: ${code}`); + if (response) lines.push(`Response: ${response}`); + if (lines.length > 1) return lines.join("\n"); + + if (raw && !generic) return raw; + if (error && typeof error === "object") { + const serialized = safeStringify(error); + if (serialized && serialized !== "{}") return serialized; + } + return fallback; + }; + + const buildProviderAuthMethods = ( + methods: Record, + availableProviders: ProviderAuthProvider[], + workerType: "local" | "remote", + cloudProviders: DenOrgLlmProvider[], + ) => { + const merged = Object.fromEntries( + Object.entries(methods ?? {}).map(([id, providerMethods]) => [ + id, + (providerMethods ?? []).map((method, methodIndex) => ({ + ...method, + methodIndex, + })), + ]), + ) as Record; + + for (const provider of availableProviders ?? []) { + const id = provider.id?.trim(); + if (!id || id === "opencode") continue; + if (!Array.isArray(provider.env) || provider.env.length === 0) continue; + const existing = merged[id] ?? []; + if (existing.some((method) => method.type === "api")) continue; + merged[id] = [...existing, { type: "api", label: t("providers.api_key_label") }]; + } + + for (const [id, providerMethods] of Object.entries(merged)) { + const provider = availableProviders.find((item) => item.id === id); + const normalizedId = id.trim().toLowerCase(); + const normalizedName = provider?.name?.trim().toLowerCase() ?? ""; + const isOpenAiProvider = normalizedId === "openai" || normalizedName === "openai"; + if (!isOpenAiProvider) continue; + merged[id] = providerMethods.filter((method) => { + if (method.type !== "oauth") return true; + const label = method.label.toLowerCase(); + const isHeadless = label.includes("headless") || label.includes("device"); + return workerType === "remote" ? isHeadless : !isHeadless; + }); + } + + for (const provider of cloudProviders) { + const id = provider.providerId.trim(); + if (!id) continue; + const existing = merged[id] ?? []; + if ( + existing.some( + (method) => + method.type === "cloud" && method.cloudProviderId === provider.id, + ) + ) { + continue; + } + merged[id] = [...existing, buildCloudProviderMethod(provider)]; + } + + return merged; + }; + + const loadProviderAuthMethods = async (workerType: "local" | "remote") => { + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + const methods = unwrap(await c.provider.auth()); + const cloudProviders = await refreshCloudOrgProviders().catch( + () => [] as DenOrgLlmProvider[], + ); + return buildProviderAuthMethods( + methods as Record, + getProviderAuthProviders(), + workerType, + cloudProviders, + ); + }; + + async function startProviderAuth( + providerId?: string, + methodIndex?: number, + ): Promise { + setStateField("providerAuthError", null); + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + try { + const cachedMethods = state.providerAuthMethods; + const authMethods = Object.keys(cachedMethods).length + ? cachedMethods + : await loadProviderAuthMethods(getProviderAuthWorkerType()); + const providerIds = Object.keys(authMethods).sort(); + if (!providerIds.length) { + throw new Error(t("providers.no_providers_available")); + } + + const resolved = providerId?.trim() ?? ""; + if (!resolved) { + throw new Error(t("providers.provider_id_required")); + } + + const methods = authMethods[resolved]; + if (!methods || !methods.length) { + throw new Error(`${t("providers.unknown_provider")}: ${resolved}`); + } + + const oauthIndex = + methodIndex !== undefined + ? methodIndex + : methods.find((method) => method.type === "oauth")?.methodIndex ?? -1; + if (oauthIndex === -1) { + throw new Error( + `${t("providers.no_oauth_prefix")} ${resolved}. ${t("providers.use_api_key_suffix")}`, + ); + } + + const selectedMethod = methods.find((method) => method.methodIndex === oauthIndex); + if (!selectedMethod || selectedMethod.type !== "oauth") { + throw new Error(`${t("providers.not_oauth_flow_prefix")} ${resolved}.`); + } + + const auth = unwrap( + await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex }), + ); + return { methodIndex: oauthIndex, authorization: auth }; + } catch (error) { + const message = describeProviderError(error, t("providers.connect_failed")); + setStateField("providerAuthError", message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function refreshProviders(optionsArg?: { dispose?: boolean }) { + const c = options.client(); + if (!c) return null; + + if (optionsArg?.dispose) { + try { + unwrap(await c.instance.dispose()); + } catch { + // ignore dispose failures and try reading current state anyway + } + + try { + await waitForHealthy(options.client() ?? c, { timeoutMs: 8000, pollMs: 250 }); + } catch { + // ignore health wait failures and still attempt provider reads + } + } + + const activeClient = options.client() ?? c; + let disabledProviders = options.disabledProviders() ?? []; + try { + const config = unwrap(await activeClient.config.get()); + disabledProviders = Array.isArray(config.disabled_providers) + ? config.disabled_providers + : []; + options.setDisabledProviders(disabledProviders); + refreshSnapshot(); + emitChange(); + } catch { + // ignore config read failures and continue with current store state + } + + try { + const updated = filterProviderList( + unwrap(await activeClient.provider.list()), + disabledProviders, + ); + applyProviderListState(updated); + return updated; + } catch { + try { + const fallback = unwrap(await activeClient.config.providers()); + const mapped = mapConfigProvidersToList(fallback.providers); + const next = filterProviderList( + { + all: mapped, + connected: options + .providerConnectedIds() + .filter((id) => mapped.some((provider) => provider.id === id)), + default: fallback.default, + }, + disabledProviders, + ); + applyProviderListState(next); + return next; + } catch { + return null; + } + } + } + + async function completeProviderAuthOAuth( + providerId: string, + methodIndex: number, + code?: string, + ) { + setStateField("providerAuthError", null); + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + + const resolved = providerId?.trim(); + if (!resolved) { + throw new Error(t("providers.provider_id_required")); + } + + if (!Number.isInteger(methodIndex) || methodIndex < 0) { + throw new Error(t("providers.oauth_method_required")); + } + + const waitForProviderConnection = async (timeoutMs = 15000, pollMs = 2000) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + const updated = await refreshProviders({ dispose: true }); + if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { + return true; + } + } catch { + // ignore and retry + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + return false; + }; + + const isPendingOauthError = (error: unknown) => { + const text = error instanceof Error ? error.message : String(error ?? ""); + return /request timed out/i.test(text) || /ProviderAuthOauthMissing/i.test(text); + }; + + try { + const trimmedCode = code?.trim(); + const result = await c.provider.oauth.callback({ + providerID: resolved, + method: methodIndex, + code: trimmedCode || undefined, + }); + assertNoClientError(result); + const updated = await refreshProviders({ dispose: true }); + const connectedNow = Array.isArray(updated?.connected) && updated.connected.includes(resolved); + if (connectedNow) { + return { connected: true, message: `${t("status.connected")} ${resolved}` }; + } + const connected = await waitForProviderConnection(); + if (connected) { + return { connected: true, message: `${t("status.connected")} ${resolved}` }; + } + return { connected: false, pending: true }; + } catch (error) { + if (isPendingOauthError(error)) { + const updated = await refreshProviders({ dispose: true }); + if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { + return { connected: true, message: `${t("status.connected")} ${resolved}` }; + } + const connected = await waitForProviderConnection(); + if (connected) { + return { connected: true, message: `${t("status.connected")} ${resolved}` }; + } + return { connected: false, pending: true }; + } + const message = describeProviderError(error, t("providers.oauth_failed")); + setStateField("providerAuthError", message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function submitProviderApiKey(providerId: string, apiKey: string) { + setStateField("providerAuthError", null); + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + + const trimmed = apiKey.trim(); + if (!trimmed) { + throw new Error(t("providers.api_key_required")); + } + + try { + await c.auth.set({ providerID: providerId, auth: { type: "api", key: trimmed } }); + await refreshProviders({ dispose: true }); + return `${t("status.connected")} ${providerId}`; + } catch (error) { + const message = describeProviderError(error, t("providers.save_api_key_failed")); + setStateField("providerAuthError", message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function connectCloudProviderInternal( + cloudProviderId: string, + optionsArg?: { silent?: boolean }, + ) { + if (!optionsArg?.silent) { + setStateField("providerAuthError", null); + } + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + const orgId = settings.activeOrgId?.trim() ?? ""; + if (!token || !orgId) { + throw new Error("Sign in to OpenWork Cloud and choose an organization first."); + } + + try { + const den = createDenClient({ + baseUrl: settings.baseUrl, + apiBaseUrl: settings.apiBaseUrl, + token, + }); + const provider = await den.getOrgLlmProviderConnection(orgId, cloudProviderId); + const existingImported = state.importedCloudProviders[cloudProviderId] ?? null; + const localProviderId = getCloudManagedProviderId(provider); + const apiKey = provider.apiKey?.trim() ?? ""; + const env = getCloudProviderEnv(provider.providerConfig); + if (!apiKey && env.length > 0) { + throw new Error(`${provider.name} does not have a stored organization credential yet.`); + } + + await assertCloudProviderImportSafe(provider); + + if (apiKey) { + await c.auth.set({ + providerID: localProviderId, + auth: { type: "api", key: apiKey }, + }); + } + if (existingImported?.providerId && existingImported.providerId !== localProviderId) { + try { + await removeProviderAuthCredentials(existingImported.providerId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ""); + if (!/not found|unknown auth|404/i.test(message.toLowerCase())) { + throw error; + } + } + } + const updatedConfig = await updateProjectConfigFile((raw) => + formatConfigWithCloudProvider(raw, provider, localProviderId, existingImported?.providerId ?? null), + ); + if (!updatedConfig) { + throw new Error("Could not update opencode.jsonc for this workspace."); + } + + const nextImportedProviders = { + ...state.importedCloudProviders, + [provider.id]: { + cloudProviderId: provider.id, + providerId: localProviderId, + // Track the provider id as shipped by the server at import time + // so we can detect local/remote drift later (see dev #1510 "key + // cloud providers by cloud id"). On first import both match. + sourceProviderId: provider.providerId, + name: provider.name, + source: provider.source, + updatedAt: provider.updatedAt ?? null, + modelIds: getProviderModelIds(provider), + importedAt: Date.now(), + }, + }; + await persistImportedCloudProviders(nextImportedProviders); + + const nextDisabledProviders = options + .disabledProviders() + .filter((id) => id !== localProviderId && id !== existingImported?.providerId); + options.setDisabledProviders(nextDisabledProviders); + options.markOpencodeConfigReloadRequired(); + refreshSnapshot(); + emitChange(); + return `${t("status.connected")} ${provider.name}`; + } catch (error) { + const message = describeProviderError(error, "Failed to connect organization provider."); + if (!optionsArg?.silent) { + setStateField("providerAuthError", message); + } + throw error instanceof Error ? error : new Error(message); + } + } + + async function connectCloudProvider(cloudProviderId: string) { + return await connectCloudProviderInternal(cloudProviderId); + } + + async function removeCloudProviderInternal( + cloudProviderId: string, + optionsArg?: { silent?: boolean }, + ) { + if (!optionsArg?.silent) { + setStateField("providerAuthError", null); + } + const imported = state.importedCloudProviders[cloudProviderId]; + if (!imported) { + throw new Error("This cloud provider has not been imported into the workspace."); + } + + try { + try { + await removeProviderAuthCredentials(imported.providerId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ""); + if (!/not found|unknown auth|404/i.test(message.toLowerCase())) { + throw error; + } + } + const updatedConfig = await updateProjectConfigFile((raw) => + formatConfigWithoutCloudProvider(raw, imported.providerId), + ); + if (!updatedConfig) { + throw new Error("Could not update opencode.jsonc for this workspace."); + } + + const nextImportedProviders = { ...state.importedCloudProviders }; + delete nextImportedProviders[cloudProviderId]; + await persistImportedCloudProviders(nextImportedProviders); + + options.setDisabledProviders( + options.disabledProviders().filter((id) => id !== imported.providerId), + ); + options.markOpencodeConfigReloadRequired(); + refreshSnapshot(); + emitChange(); + return `${t("providers.disconnected_prefix")} ${imported.name}`; + } catch (error) { + const message = describeProviderError(error, t("providers.disconnect_failed")); + if (!optionsArg?.silent) { + setStateField("providerAuthError", message); + } + throw error instanceof Error ? error : new Error(message); + } + } + + async function removeCloudProvider(cloudProviderId: string) { + return await removeCloudProviderInternal(cloudProviderId); + } + + const logCloudProviderSyncError = (reason: CloudProviderSyncReason, error: unknown) => { + const message = describeProviderError(error, "Cloud provider sync failed."); + console.warn(`[cloud-provider-sync:${reason}] ${message}`); + return message; + }; + + const getCloudProviderSyncContextKey = () => { + const settings = readDenSettings(); + return [ + settings.baseUrl, + settings.apiBaseUrl ?? "", + settings.activeOrgId?.trim() ?? "", + settings.authToken?.trim() ?? "", + options.selectedWorkspaceDisplay().workspaceType, + options.selectedWorkspaceRoot().trim(), + options.runtimeWorkspaceId() ?? "", + options.client() ? "connected" : "disconnected", + ].join("::"); + }; + + const hasCloudProviderSyncPrerequisites = () => { + const settings = readDenSettings(); + const workspaceTarget = + options.selectedWorkspaceRoot().trim() || options.runtimeWorkspaceId() || ""; + return Boolean( + options.client() && + settings.authToken?.trim() && + settings.activeOrgId?.trim() && + workspaceTarget, + ); + }; + + const isCloudProviderOutOfSync = ( + provider: DenOrgLlmProvider, + importedProvider: CloudImportedProvider, + ) => + importedProvider.providerId !== getCloudManagedProviderId(provider) || + 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))); + + async function performCloudProviderSync(reason: CloudProviderSyncReason) { + if (!hasCloudProviderSyncPrerequisites()) { + return; + } + + const importedProviders = await refreshImportedCloudProviders(); + const liveProviders = await refreshCloudOrgProviders({ force: true }); + const liveProviderMap = new Map(liveProviders.map((provider) => [provider.id, provider])); + const failures: string[] = []; + const processedLiveProviderIds = new Set(); + let configChanged = false; + + for (const importedProvider of Object.values(importedProviders)) { + const liveProvider = liveProviderMap.get(importedProvider.cloudProviderId); + if (!liveProvider) { + try { + await removeCloudProviderInternal(importedProvider.cloudProviderId, { silent: true }); + configChanged = true; + } catch (error) { + failures.push(logCloudProviderSyncError(reason, error)); + } + continue; + } + + processedLiveProviderIds.add(liveProvider.id); + + if (!isCloudProviderOutOfSync(liveProvider, importedProvider)) { + continue; + } + + try { + await removeCloudProviderInternal(importedProvider.cloudProviderId, { silent: true }); + await connectCloudProviderInternal(liveProvider.id, { silent: true }); + configChanged = true; + } catch (error) { + failures.push(logCloudProviderSyncError(reason, error)); + } + } + + const nextImportedProviders = state.importedCloudProviders; + for (const liveProvider of liveProviders) { + if (processedLiveProviderIds.has(liveProvider.id)) { + continue; + } + if (nextImportedProviders[liveProvider.id]) { + continue; + } + + try { + await connectCloudProviderInternal(liveProvider.id, { silent: true }); + configChanged = true; + } catch (error) { + failures.push(logCloudProviderSyncError(reason, error)); + } + } + + if (configChanged) { + await refreshProviders({ dispose: true }).catch(() => null); + } + + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } + } + + async function runCloudProviderSync(reason: CloudProviderSyncReason) { + if (cloudProviderSyncInFlight) { + cloudProviderSyncQueuedReason = reason; + return cloudProviderSyncInFlight; + } + + const request = performCloudProviderSync(reason) + .catch((error) => { + const message = logCloudProviderSyncError(reason, error); + if (reason === "settings_cloud_opened") { + setStateField("providerAuthError", message); + } + }) + .finally(() => { + cloudProviderSyncInFlight = null; + const queuedReason = cloudProviderSyncQueuedReason; + cloudProviderSyncQueuedReason = null; + if (queuedReason) { + void runCloudProviderSync(queuedReason); + } + }); + + cloudProviderSyncInFlight = request; + return request; + } + + async function disconnectProvider(providerId: string) { + setStateField("providerAuthError", null); + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + + const resolved = providerId.trim(); + if (!resolved) { + throw new Error(t("providers.provider_id_required")); + } + + const trackedImport = Object.values(state.importedCloudProviders).find( + (entry) => entry.providerId === resolved, + ); + if (trackedImport) { + return await removeCloudProvider(trackedImport.cloudProviderId); + } + + const provider = options.providers().find((entry) => entry.id === resolved) as + | (ProviderListItem & { source?: string }) + | undefined; + const canDisableProvider = provider?.source === "config" || provider?.source === "custom"; + + const disableProvider = async () => { + const config = unwrap(await c.config.get()); + const disabledProviders = Array.isArray(config.disabled_providers) + ? config.disabled_providers + : []; + if (disabledProviders.includes(resolved)) { + return false; + } + + const next = [...disabledProviders, resolved]; + options.setDisabledProviders(next); + try { + const result = await c.config.update({ + config: { ...config, disabled_providers: next }, + }); + assertNoClientError(result); + options.markOpencodeConfigReloadRequired(); + } catch (error) { + options.setDisabledProviders(disabledProviders); + throw error; + } + refreshSnapshot(); + emitChange(); + return true; + }; + + try { + await removeProviderAuthCredentials(resolved); + let updated = await refreshProviders({ dispose: true }); + if (canDisableProvider && Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { + const disabled = await disableProvider(); + if (disabled && updated) { + updated = filterProviderList(updated, options.disabledProviders() ?? []); + applyProviderListState(updated); + } + if (!Array.isArray(updated?.connected) || !updated.connected.includes(resolved)) { + return disabled + ? `${t("providers.disconnected_prefix")} ${resolved} ${t("providers.disabled_in_config_suffix")}` + : `${t("providers.disconnected_prefix")} ${resolved}.`; + } + } + + if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { + return `Removed stored credentials for ${resolved}${t("providers.still_connected_suffix")}`; + } + removeProviderFromState(resolved); + return `${t("providers.disconnected_prefix")} ${resolved}`; + } catch (error) { + const message = describeProviderError(error, t("providers.disconnect_failed")); + setStateField("providerAuthError", message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function openProviderAuthModal(optionsArg?: { + returnFocusTarget?: ProviderReturnFocusTarget; + preferredProviderId?: string; + }) { + mutateState((current) => ({ + ...current, + providerAuthReturnFocusTarget: optionsArg?.returnFocusTarget ?? "none", + providerAuthPreferredProviderId: optionsArg?.preferredProviderId?.trim() || null, + providerAuthBusy: true, + providerAuthError: null, + })); + + try { + const methods = await loadProviderAuthMethods(getProviderAuthWorkerType()); + mutateState((current) => ({ + ...current, + providerAuthMethods: methods, + providerAuthModalOpen: true, + })); + } catch (error) { + const message = describeProviderError(error, t("providers.load_failed")); + mutateState((current) => ({ + ...current, + providerAuthPreferredProviderId: null, + providerAuthReturnFocusTarget: "none", + providerAuthError: message, + })); + throw error; + } finally { + setStateField("providerAuthBusy", false); + } + } + + function closeProviderAuthModal(optionsArg?: { restorePromptFocus?: boolean }) { + const shouldFocusPrompt = + optionsArg?.restorePromptFocus ?? state.providerAuthReturnFocusTarget === "composer"; + mutateState((current) => ({ + ...current, + providerAuthModalOpen: false, + providerAuthError: null, + providerAuthPreferredProviderId: null, + providerAuthReturnFocusTarget: "none", + })); + if (shouldFocusPrompt) { + options.focusPromptSoon?.(); + } + } + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + const currentWorkspaceKey = () => + `${options.selectedWorkspaceRoot().trim()}::${options.runtimeWorkspaceId() ?? ""}`; + + const syncFromOptions = () => { + const workspaceKey = currentWorkspaceKey(); + const workspaceChanged = workspaceKey !== lastWorkspaceKey; + lastWorkspaceKey = workspaceKey; + refreshSnapshot(); + emitChange(); + if (workspaceChanged) { + void refreshImportedCloudProviders(); + } + if (!hasCloudProviderSyncPrerequisites()) { + cloudProviderSyncContextKey = ""; + return; + } + + const nextSyncContextKey = getCloudProviderSyncContextKey(); + if (nextSyncContextKey === cloudProviderSyncContextKey) { + return; + } + + cloudProviderSyncContextKey = nextSyncContextKey; + void runCloudProviderSync("app_launch"); + }; + + const start = () => { + if (started) return; + // StrictMode double-mount re-arms after dispose. + disposed = false; + started = true; + lastWorkspaceKey = currentWorkspaceKey(); + if (typeof window !== "undefined") { + const handleDenSessionUpdate = (event: Event) => { + cloudOrgProvidersLoadKey = ""; + cloudOrgProvidersInFlightKey = ""; + cloudOrgProvidersInFlight = null; + mutateState((current) => ({ + ...current, + cloudOrgProviders: [], + providerAuthMethods: {}, + })); + const detail = (event as CustomEvent).detail; + if (detail?.status === "success") { + void runCloudProviderSync("sign_in"); + } + }; + window.addEventListener( + denSessionUpdatedEvent, + handleDenSessionUpdate as EventListener, + ); + denSessionCleanup = () => { + window.removeEventListener( + denSessionUpdatedEvent, + handleDenSessionUpdate as EventListener, + ); + }; + } + void refreshImportedCloudProviders(); + refreshSnapshot(); + emitChange(); + }; + + const dispose = () => { + if (disposed) return; + disposed = true; + started = false; + denSessionCleanup?.(); + denSessionCleanup = null; + listeners.clear(); + }; + + refreshSnapshot(); + + return { + subscribe, + getSnapshot: () => snapshot, + start, + dispose, + syncFromOptions, + refreshCloudOrgProviders, + runCloudProviderSync, + startProviderAuth, + refreshProviders, + completeProviderAuthOAuth, + submitProviderApiKey, + connectCloudProvider, + removeCloudProvider, + disconnectProvider, + openProviderAuthModal, + closeProviderAuthModal, + }; +} + +export function useProviderAuthStoreSnapshot(store: ProviderAuthStore) { + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} diff --git a/apps/app/src/react-app/domains/connections/provider.tsx b/apps/app/src/react-app/domains/connections/provider.tsx new file mode 100644 index 0000000000..6473da2272 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider.tsx @@ -0,0 +1,33 @@ +/** @jsxImportSource react */ +import { + createContext, + useContext, + useSyncExternalStore, + type ReactNode, +} from "react"; + +import type { ConnectionsStore } from "./store"; + +const ConnectionsContext = createContext(null); + +export function ConnectionsProvider(props: { + store: ConnectionsStore; + children: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useConnections() { + const store = useContext(ConnectionsContext); + if (!store) { + throw new Error("useConnections must be used within a ConnectionsProvider"); + } + + useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + + return store; +} diff --git a/apps/app/src/react-app/domains/connections/store.ts b/apps/app/src/react-app/domains/connections/store.ts new file mode 100644 index 0000000000..0629be39d7 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/store.ts @@ -0,0 +1,912 @@ +import { useSyncExternalStore } from "react"; + +import { parse } from "jsonc-parser"; + +import { currentLocale, t } from "../../../i18n"; +import { + CHROME_DEVTOOLS_MCP_ID, + MCP_QUICK_CONNECT, + type McpDirectoryInfo, +} from "../../../app/constants"; +import { createClient, unwrap } from "../../../app/lib/opencode"; +import { finishPerf, perfNow, recordPerfLog } from "../../../app/lib/perf-log"; +import { + getDesktopHomeDir, + readOpencodeConfig, + writeOpencodeConfig, + type OpencodeConfigFile, +} from "../../../app/lib/desktop"; +import { toSessionTransportDirectory } from "../../../app/lib/session-scope"; +import { + parseMcpServersFromContent, + removeMcpFromConfig, + usesChromeDevtoolsAutoConnect, + validateMcpServerName, +} from "../../../app/mcp"; +import { buildOpenworkWorkspaceBaseUrl } from "../../../app/lib/openwork-server"; +import type { + Client, + McpServerEntry, + McpStatusMap, + ReloadReason, + ReloadTrigger, +} from "../../../app/types"; +import { isDesktopRuntime, normalizeDirectoryPath, safeStringify } from "../../../app/utils"; + +import type { OpenworkServerStore } from "./openwork-server-store"; + +type SetStateAction = T | ((current: T) => T); + +export type ConnectionsStoreSnapshot = { + mcpServers: McpServerEntry[]; + mcpStatus: string | null; + mcpLastUpdatedAt: number | null; + mcpStatuses: McpStatusMap; + mcpConnectingName: string | null; + selectedMcp: string | null; + mcpAuthModalOpen: boolean; + mcpAuthEntry: McpDirectoryInfo | null; + mcpAuthNeedsReload: boolean; +}; + +type MutableState = ConnectionsStoreSnapshot; + +export type ConnectionsStore = ReturnType; + +export function createConnectionsStore(options: { + client: () => Client | null; + setClient: (value: Client | null) => void; + projectDir: () => string; + selectedWorkspaceId: () => string; + selectedWorkspaceRoot: () => string; + workspaceType: () => "local" | "remote"; + openworkServer: OpenworkServerStore; + runtimeWorkspaceId: () => string | null; + ensureRuntimeWorkspaceId?: () => Promise; + setProjectDir?: (value: string) => void; + developerMode: () => boolean; + markReloadRequired?: (reason: ReloadReason, trigger?: ReloadTrigger) => void; +}) { + const listeners = new Set<() => void>(); + const translate = (key: string) => t(key, currentLocale()); + + let started = false; + let disposed = false; + let lastWorkspaceContextKey = ""; + let lastProjectDir = ""; + let snapshot: ConnectionsStoreSnapshot; + + let state: MutableState = { + mcpServers: [], + mcpStatus: null, + mcpLastUpdatedAt: null, + mcpStatuses: {}, + mcpConnectingName: null, + selectedMcp: null, + mcpAuthModalOpen: false, + mcpAuthEntry: null, + mcpAuthNeedsReload: false, + }; + + const emitChange = () => { + for (const listener of listeners) listener(); + }; + + const refreshSnapshot = () => { + snapshot = { + mcpServers: state.mcpServers, + mcpStatus: state.mcpStatus, + mcpLastUpdatedAt: state.mcpLastUpdatedAt, + mcpStatuses: state.mcpStatuses, + mcpConnectingName: state.mcpConnectingName, + selectedMcp: state.selectedMcp, + mcpAuthModalOpen: state.mcpAuthModalOpen, + mcpAuthEntry: state.mcpAuthEntry, + mcpAuthNeedsReload: state.mcpAuthNeedsReload, + }; + }; + + const mutateState = (updater: (current: MutableState) => MutableState) => { + state = updater(state); + refreshSnapshot(); + emitChange(); + }; + + const setStateField = (key: K, value: MutableState[K]) => { + if (Object.is(state[key], value)) return; + mutateState((current) => ({ ...current, [key]: value })); + }; + + const applyStateAction = (current: T, next: SetStateAction) => + typeof next === "function" ? (next as (value: T) => T)(current) : next; + + const getWorkspaceContextKey = () => { + const workspaceId = options.selectedWorkspaceId().trim(); + const root = normalizeDirectoryPath(options.selectedWorkspaceRoot().trim()); + const runtimeWorkspaceId = (options.runtimeWorkspaceId() ?? "").trim(); + const workspaceType = options.workspaceType(); + return `${workspaceType}:${workspaceId}:${root}:${runtimeWorkspaceId}`; + }; + + const getOpenworkSnapshot = () => options.openworkServer.getSnapshot(); + + const filterConfiguredStatuses = (status: McpStatusMap, entries: McpServerEntry[]) => { + const configured = new Set(entries.map((entry) => entry.name)); + return Object.fromEntries( + Object.entries(status).filter(([name]) => configured.has(name)), + ) as McpStatusMap; + }; + + const readMcpConfigFile = async (scope: "project" | "global"): Promise => { + const projectDir = options.projectDir().trim(); + const openworkSnapshot = getOpenworkSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkSnapshot.openworkServerCapabilities?.config?.read; + + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + return openworkClient.readOpencodeConfigFile(openworkWorkspaceId, scope); + } + + if (!isDesktopRuntime()) { + return null; + } + + return readOpencodeConfig(scope, projectDir); + }; + + const ensureActiveClient = async () => { + let activeClient = options.client(); + if (activeClient) { + return activeClient; + } + + const openworkSnapshot = getOpenworkSnapshot(); + const openworkBaseUrl = openworkSnapshot.openworkServerBaseUrl.trim(); + const token = openworkSnapshot.openworkServerAuth.token?.trim(); + if (!openworkBaseUrl || !token) { + return null; + } + + const mountedBaseUrl = + buildOpenworkWorkspaceBaseUrl(openworkBaseUrl, options.runtimeWorkspaceId()) ?? openworkBaseUrl; + activeClient = createClient(`${mountedBaseUrl.replace(/\/+$/, "")}/opencode`, undefined, { + token, + mode: "openwork", + }); + options.setClient(activeClient); + return activeClient; + }; + + const resolveWritableOpenworkTarget = async () => { + const openworkSnapshot = getOpenworkSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + let openworkWorkspaceId = options.runtimeWorkspaceId(); + const openworkCapabilities = openworkSnapshot.openworkServerCapabilities; + if (!openworkWorkspaceId && openworkClient && openworkSnapshot.openworkServerStatus === "connected") { + openworkWorkspaceId = (await options.ensureRuntimeWorkspaceId?.()) ?? null; + } + + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.mcp?.write; + + return { + openworkClient, + openworkWorkspaceId, + canUseOpenworkServer: Boolean(canUseOpenworkServer), + }; + }; + + const resolveProjectDir = async (activeClient: Client | null, currentProjectDir: string) => { + let resolvedProjectDir = currentProjectDir; + if (!resolvedProjectDir && activeClient) { + try { + const pathInfo = unwrap(await activeClient.path.get()); + const discoveredRaw = toSessionTransportDirectory(pathInfo.directory ?? ""); + const discovered = discoveredRaw.replace(/^\/private\/tmp(?=\/|$)/, "/tmp"); + if (discovered) { + resolvedProjectDir = discovered; + options.setProjectDir?.(discovered); + } + } catch { + // ignore + } + } + + return resolvedProjectDir; + }; + + async function refreshMcpServers() { + if (disposed) return; + + const projectDir = options.projectDir().trim(); + const isRemoteWorkspace = options.workspaceType() === "remote"; + const isLocalWorkspace = !isRemoteWorkspace; + const openworkSnapshot = getOpenworkSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkSnapshot.openworkServerCapabilities?.mcp?.read; + + if (isRemoteWorkspace) { + if (!canUseOpenworkServer) { + mutateState((current) => ({ + ...current, + mcpStatus: "OpenWork server unavailable. MCP config is read-only.", + mcpServers: [], + mcpStatuses: {}, + })); + return; + } + + try { + setStateField("mcpStatus", null); + const response = await openworkClient.listMcp(openworkWorkspaceId); + const next = response.items.map((entry) => ({ + name: entry.name, + config: entry.config as McpServerEntry["config"], + source: entry.source, + })); + + let nextStatuses: McpStatusMap = {}; + const activeClient = options.client(); + if (activeClient && projectDir) { + try { + const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); + nextStatuses = filterConfiguredStatuses(status as McpStatusMap, next); + } catch { + nextStatuses = {}; + } + } + + mutateState((current) => ({ + ...current, + mcpServers: next, + mcpLastUpdatedAt: Date.now(), + mcpStatuses: nextStatuses, + mcpStatus: next.length ? null : "No MCP servers configured yet.", + })); + } catch (error) { + mutateState((current) => ({ + ...current, + mcpServers: [], + mcpStatuses: {}, + mcpStatus: + error instanceof Error ? error.message : "Failed to load MCP servers", + })); + } + return; + } + + if (isLocalWorkspace && canUseOpenworkServer) { + try { + setStateField("mcpStatus", null); + const response = await openworkClient.listMcp(openworkWorkspaceId); + const next = response.items.map((entry) => ({ + name: entry.name, + config: entry.config as McpServerEntry["config"], + source: entry.source, + })); + + let nextStatuses: McpStatusMap = {}; + const activeClient = options.client(); + if (activeClient && projectDir) { + try { + const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); + nextStatuses = filterConfiguredStatuses(status as McpStatusMap, next); + } catch { + nextStatuses = {}; + } + } + + mutateState((current) => ({ + ...current, + mcpServers: next, + mcpLastUpdatedAt: Date.now(), + mcpStatuses: nextStatuses, + mcpStatus: next.length ? null : "No MCP servers configured yet.", + })); + } catch (error) { + mutateState((current) => ({ + ...current, + mcpServers: [], + mcpStatuses: {}, + mcpStatus: + error instanceof Error ? error.message : "Failed to load MCP servers", + })); + } + return; + } + + if (!isDesktopRuntime()) { + mutateState((current) => ({ + ...current, + mcpStatus: "MCP configuration is only available for local workspaces.", + mcpServers: [], + mcpStatuses: {}, + })); + return; + } + + if (!projectDir) { + mutateState((current) => ({ + ...current, + mcpStatus: "Pick a workspace folder to load MCP servers.", + mcpServers: [], + mcpStatuses: {}, + })); + return; + } + + try { + setStateField("mcpStatus", null); + const config = await readOpencodeConfig("project", projectDir); + if (!config.exists || !config.content) { + mutateState((current) => ({ + ...current, + mcpServers: [], + mcpStatuses: {}, + mcpStatus: "No opencode.json found yet. Create one by connecting an MCP.", + })); + return; + } + + const next = parseMcpServersFromContent(config.content); + let nextStatuses = state.mcpStatuses; + const activeClient = options.client(); + if (activeClient) { + try { + const status = unwrap(await activeClient.mcp.status({ directory: projectDir })); + nextStatuses = filterConfiguredStatuses(status as McpStatusMap, next); + } catch { + nextStatuses = {}; + } + } + + mutateState((current) => ({ + ...current, + mcpServers: next, + mcpLastUpdatedAt: Date.now(), + mcpStatuses: nextStatuses, + mcpStatus: next.length ? null : "No MCP servers configured yet.", + })); + } catch (error) { + mutateState((current) => ({ + ...current, + mcpServers: [], + mcpStatuses: {}, + mcpStatus: error instanceof Error ? error.message : "Failed to load MCP servers", + })); + } + } + + async function connectMcp(entry: McpDirectoryInfo) { + const startedAt = perfNow(); + const openworkSnapshot = getOpenworkSnapshot(); + const isRemoteWorkspace = + options.workspaceType() === "remote" || + (!isDesktopRuntime() && openworkSnapshot.openworkServerStatus === "connected"); + const projectDir = options.projectDir().trim(); + const entryType = entry.type ?? "remote"; + + recordPerfLog(options.developerMode(), "mcp.connect", "start", { + name: entry.name, + type: entryType, + workspaceType: isRemoteWorkspace ? "remote" : "local", + projectDir: projectDir || null, + }); + + const { openworkClient, openworkWorkspaceId, canUseOpenworkServer } = + await resolveWritableOpenworkTarget(); + + if (isRemoteWorkspace && !canUseOpenworkServer) { + setStateField("mcpStatus", "OpenWork server unavailable. MCP config is read-only."); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "openwork-server-unavailable", + }); + return; + } + + if (!canUseOpenworkServer && !isDesktopRuntime()) { + setStateField("mcpStatus", translate("mcp.desktop_required")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "desktop-required", + }); + return; + } + + if (!isRemoteWorkspace && !projectDir) { + setStateField("mcpStatus", translate("mcp.pick_workspace_first")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "missing-workspace", + }); + return; + } + + const activeClient = await ensureActiveClient(); + if (!activeClient) { + setStateField("mcpStatus", translate("mcp.connect_server_first")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "no-active-client", + }); + return; + } + + const resolvedProjectDir = await resolveProjectDir(activeClient, projectDir); + if (!resolvedProjectDir) { + setStateField("mcpStatus", translate("mcp.pick_workspace_first")); + finishPerf(options.developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "missing-workspace-after-discovery", + }); + return; + } + + const slug = entry.id ?? entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + const action = snapshot.mcpServers.some((server) => server.name === slug) ? "updated" : "added"; + + try { + mutateState((current) => ({ ...current, mcpStatus: null, mcpConnectingName: entry.name })); + + let mcpEnvironment: Record | undefined; + + const mcpEntryConfig: Record = { + type: entryType, + enabled: true, + }; + + if (entryType === "remote") { + if (!entry.url) { + throw new Error("Missing MCP URL."); + } + mcpEntryConfig["url"] = entry.url; + if (entry.oauth) { + mcpEntryConfig["oauth"] = {}; + } + } + + if (entryType === "local") { + if (!entry.command?.length) { + throw new Error("Missing MCP command."); + } + mcpEntryConfig["command"] = entry.command; + + if ( + slug === CHROME_DEVTOOLS_MCP_ID && + usesChromeDevtoolsAutoConnect(entry.command) && + isDesktopRuntime() + ) { + try { + const hostHome = (await getDesktopHomeDir()).replace(/[\\/]+$/, ""); + if (hostHome) { + mcpEnvironment = { HOME: hostHome }; + mcpEntryConfig["environment"] = mcpEnvironment; + } + } catch { + // ignore and let the MCP use the default worker environment + } + } + } + + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + await openworkClient.addMcp(openworkWorkspaceId, { + name: slug, + config: mcpEntryConfig, + }); + } else { + const configFile = await readOpencodeConfig("project", resolvedProjectDir); + + let existingConfig: Record = {}; + if (configFile.exists && configFile.content?.trim()) { + try { + existingConfig = parse(configFile.content) ?? {}; + } catch (parseErr) { + recordPerfLog(options.developerMode(), "mcp.connect", "config-parse-failed", { + error: parseErr instanceof Error ? parseErr.message : String(parseErr), + }); + existingConfig = {}; + } + } + + if (!existingConfig["$schema"]) { + existingConfig["$schema"] = "https://opencode.ai/config.json"; + } + + const mcpSection = (existingConfig["mcp"] as Record) ?? {}; + existingConfig["mcp"] = mcpSection; + mcpSection[slug] = mcpEntryConfig; + + const writeResult = await writeOpencodeConfig( + "project", + resolvedProjectDir, + `${JSON.stringify(existingConfig, null, 2)}\n`, + ); + if (!writeResult.ok) { + throw new Error(writeResult.stderr || writeResult.stdout || "Failed to write opencode.json"); + } + } + + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + // The OpenWork server is the source of truth for workspace-scoped MCP + // config in the React port. Avoid also calling the OpenCode SDK's MCP + // hot-add endpoint here: when the SDK client is rooted at the aggregate + // `/opencode` route it can resolve to an internal `local_*` workspace + // id that the OpenWork server does not expose, producing a confusing + // `workspace_not_found` after the config write already succeeded. + setStateField("mcpStatuses", filterConfiguredStatuses(snapshot.mcpStatuses, snapshot.mcpServers)); + } else { + const mcpAddConfig = + entryType === "remote" + ? { + type: "remote" as const, + url: entry.url!, + enabled: true, + ...(entry.oauth ? { oauth: {} } : {}), + } + : { + type: "local" as const, + command: entry.command!, + enabled: true, + ...(mcpEnvironment ? { environment: mcpEnvironment } : {}), + }; + + const status = unwrap( + await activeClient.mcp.add({ + directory: resolvedProjectDir, + name: slug, + config: mcpAddConfig, + }), + ); + + setStateField("mcpStatuses", status as McpStatusMap); + } + options.markReloadRequired?.("mcp", { type: "mcp", name: slug, action }); + await refreshMcpServers(); + + if (entry.oauth) { + mutateState((current) => ({ + ...current, + mcpAuthEntry: entry, + mcpAuthNeedsReload: true, + mcpAuthModalOpen: true, + })); + } else { + setStateField("mcpStatus", translate("mcp.connected")); + } + + await refreshMcpServers(); + finishPerf(options.developerMode(), "mcp.connect", "done", startedAt, { + name: entry.name, + type: entryType, + slug, + }); + } catch (error) { + setStateField( + "mcpStatus", + error instanceof Error ? error.message : translate("mcp.connect_failed"), + ); + finishPerf(options.developerMode(), "mcp.connect", "error", startedAt, { + name: entry.name, + type: entryType, + error: error instanceof Error ? error.message : safeStringify(error), + }); + } finally { + setStateField("mcpConnectingName", null); + } + } + + function authorizeMcp(entry: McpServerEntry) { + if (entry.config.type !== "remote" || entry.config.oauth === false) { + setStateField("mcpStatus", translate("mcp.login_unavailable")); + return; + } + + const matchingQuickConnect = MCP_QUICK_CONNECT.find((candidate) => { + const candidateSlug = candidate.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return candidateSlug === entry.name || candidate.name === entry.name; + }); + + mutateState((current) => ({ + ...current, + mcpAuthEntry: + matchingQuickConnect ?? { + name: entry.name, + description: "", + type: "remote", + url: entry.config.url, + oauth: true, + }, + mcpAuthNeedsReload: false, + mcpAuthModalOpen: true, + })); + } + + async function logoutMcpAuth(name: string) { + const openworkSnapshot = getOpenworkSnapshot(); + const isRemoteWorkspace = + options.workspaceType() === "remote" || + (!isDesktopRuntime() && openworkSnapshot.openworkServerStatus === "connected"); + const projectDir = options.projectDir().trim(); + + const { openworkClient, openworkWorkspaceId, canUseOpenworkServer } = + await resolveWritableOpenworkTarget(); + + if (isRemoteWorkspace && !canUseOpenworkServer) { + setStateField("mcpStatus", "OpenWork server unavailable. MCP auth is read-only."); + return; + } + + if (!canUseOpenworkServer && !isDesktopRuntime()) { + setStateField("mcpStatus", translate("mcp.desktop_required")); + return; + } + + const activeClient = await ensureActiveClient(); + if (!activeClient) { + setStateField("mcpStatus", translate("mcp.connect_server_first")); + return; + } + + const resolvedProjectDir = await resolveProjectDir(activeClient, projectDir); + if (!resolvedProjectDir) { + setStateField("mcpStatus", translate("mcp.pick_workspace_first")); + return; + } + + const safeName = validateMcpServerName(name); + setStateField("mcpStatus", null); + + try { + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + await openworkClient.logoutMcpAuth(openworkWorkspaceId, safeName); + } else { + try { + await activeClient.mcp.disconnect({ directory: resolvedProjectDir, name: safeName }); + } catch { + // ignore + } + await activeClient.mcp.auth.remove({ directory: resolvedProjectDir, name: safeName }); + } + + try { + const status = unwrap(await activeClient.mcp.status({ directory: resolvedProjectDir })); + setStateField("mcpStatuses", status as McpStatusMap); + } catch { + // ignore + } + + await refreshMcpServers(); + setStateField("mcpStatus", translate("mcp.logout_success").replace("{server}", safeName)); + } catch (error) { + setStateField( + "mcpStatus", + error instanceof Error ? error.message : translate("mcp.logout_failed"), + ); + } + } + + async function removeMcp(name: string) { + try { + setStateField("mcpStatus", null); + + const openworkSnapshot = getOpenworkSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkSnapshot.openworkServerCapabilities?.mcp?.write; + + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { + await openworkClient.removeMcp(openworkWorkspaceId, name); + } else { + const projectDir = options.projectDir().trim(); + if (!projectDir) { + setStateField("mcpStatus", translate("mcp.pick_workspace_first")); + return; + } + await removeMcpFromConfig(projectDir, name); + } + + options.markReloadRequired?.("mcp", { type: "mcp", name, action: "removed" }); + await refreshMcpServers(); + if (snapshot.selectedMcp === name) { + setStateField("selectedMcp", null); + } + setStateField("mcpStatus", null); + } catch (error) { + setStateField( + "mcpStatus", + error instanceof Error ? error.message : translate("mcp.remove_failed"), + ); + } + } + + function notifyMcpReloading() { + setStateField("mcpStatus", translate("mcp.reloading_status")); + } + + // OpenCode reconnects MCP servers asynchronously after /instance/dispose, + // so an immediate mcp.status query returns stale "disconnected". Poll on + // a backoff until every enabled MCP reaches a terminal status, with the + // banner up the whole time so users see continuous feedback. + async function pollMcpServersAfterReload(): Promise { + if (disposed) return; + notifyMcpReloading(); + await refreshMcpServers(); + + const settled = (statuses: McpStatusMap, servers: McpServerEntry[]) => { + const expected = servers.filter((s) => s.config.enabled !== false); + if (expected.length === 0) return true; + return expected.every((server) => { + const status = statuses[server.name]?.status; + return status === "connected" || status === "needs_auth" || status === "failed"; + }); + }; + + const delays = [400, 800, 1500, 2500, 4000]; + for (const delay of delays) { + if (disposed) return; + if (settled(snapshot.mcpStatuses, snapshot.mcpServers)) break; + await new Promise((resolve) => setTimeout(resolve, delay)); + await refreshMcpServers(); + } + + if (disposed) return; + // Only clear the reloading banner if it's still ours. refreshMcpServers + // may have already replaced it with a real message (e.g. "No MCP servers"). + if (snapshot.mcpStatus === translate("mcp.reloading_status")) { + setStateField("mcpStatus", null); + } + } + + // Server-only path. Local fallback would rewrite opencode.jsonc whole and + // clobber inline comments — settings-route.tsx already gates the prop so + // this never gets called when the server is unavailable. Reload UX comes + // from the existing reload-required popup; no extra banner here. + async function setMcpEnabled(name: string, enabled: boolean) { + try { + const openworkSnapshot = getOpenworkSnapshot(); + const openworkClient = openworkSnapshot.openworkServerClient; + const openworkWorkspaceId = options.runtimeWorkspaceId(); + const canUseOpenworkServer = + openworkSnapshot.openworkServerStatus === "connected" && + openworkClient && + openworkWorkspaceId && + openworkSnapshot.openworkServerCapabilities?.mcp?.write; + + if (!canUseOpenworkServer || !openworkClient || !openworkWorkspaceId) { + setStateField("mcpStatus", translate("mcp.toggle_requires_server")); + return; + } + + await openworkClient.setMcpEnabled(openworkWorkspaceId, name, enabled); + options.markReloadRequired?.("mcp", { type: "mcp", name, action: "updated" }); + await refreshMcpServers(); + } catch (error) { + setStateField( + "mcpStatus", + error instanceof Error ? error.message : translate("mcp.toggle_failed"), + ); + } + } + + function closeMcpAuthModal() { + mutateState((current) => ({ + ...current, + mcpAuthModalOpen: false, + mcpAuthEntry: null, + mcpAuthNeedsReload: false, + })); + } + + async function completeMcpAuthModal() { + closeMcpAuthModal(); + await refreshMcpServers(); + } + + const syncFromOptions = () => { + const workspaceContextKey = getWorkspaceContextKey(); + const projectDir = options.projectDir().trim(); + const changed = + workspaceContextKey !== lastWorkspaceContextKey || projectDir !== lastProjectDir; + + lastWorkspaceContextKey = workspaceContextKey; + lastProjectDir = projectDir; + + if (!started || disposed || !isDesktopRuntime() || !changed) { + return; + } + + void refreshMcpServers(); + }; + + const start = () => { + if (started) return; + // StrictMode double-mount re-arms after dispose. + disposed = false; + started = true; + syncFromOptions(); + }; + + const dispose = () => { + disposed = true; + started = false; + }; + + refreshSnapshot(); + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + const getSnapshot = () => snapshot; + + return { + subscribe, + getSnapshot, + start, + dispose, + syncFromOptions, + get mcpServers() { + return snapshot.mcpServers; + }, + get mcpStatus() { + return snapshot.mcpStatus; + }, + get mcpLastUpdatedAt() { + return snapshot.mcpLastUpdatedAt; + }, + get mcpStatuses() { + return snapshot.mcpStatuses; + }, + get mcpConnectingName() { + return snapshot.mcpConnectingName; + }, + get selectedMcp() { + return snapshot.selectedMcp; + }, + setSelectedMcp(value: SetStateAction) { + const resolved = applyStateAction(state.selectedMcp, value); + setStateField("selectedMcp", resolved); + }, + quickConnect: MCP_QUICK_CONNECT, + readMcpConfigFile, + refreshMcpServers, + connectMcp, + authorizeMcp, + logoutMcpAuth, + removeMcp, + setMcpEnabled, + notifyMcpReloading, + pollMcpServersAfterReload, + get mcpAuthModalOpen() { + return snapshot.mcpAuthModalOpen; + }, + get mcpAuthEntry() { + return snapshot.mcpAuthEntry; + }, + get mcpAuthNeedsReload() { + return snapshot.mcpAuthNeedsReload; + }, + closeMcpAuthModal, + completeMcpAuthModal, + }; +} + +export function useConnectionsStoreSnapshot(store: ConnectionsStore) { + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} diff --git a/apps/app/src/react-app/domains/onboarding/welcome-page.tsx b/apps/app/src/react-app/domains/onboarding/welcome-page.tsx new file mode 100644 index 0000000000..f84818012e --- /dev/null +++ b/apps/app/src/react-app/domains/onboarding/welcome-page.tsx @@ -0,0 +1,93 @@ +/** @jsxImportSource react */ +import { FileSpreadsheet, Globe, FolderOpen, Bot, FileText, Plug } from "lucide-react"; +import { t } from "../../../i18n"; + +type CapabilityCardProps = { + icon: React.ReactNode; + title: string; + description: string; +}; + +function CapabilityCard({ icon, title, description }: CapabilityCardProps) { + return ( +
+
+ {icon} +
+
{title}
+
+ {description} +
+
+ ); +} + +type WelcomePageProps = { + onGetStarted: () => void; +}; + +export function WelcomePage({ onGetStarted }: WelcomePageProps) { + return ( +
+
+ {/* Header */} +
+
+ +
+

+ {t("welcome.title")} +

+

+ {t("welcome.subtitle")} +

+
+ + {/* Capability grid */} +
+ } + title={t("welcome.capability_spreadsheets")} + description={t("welcome.capability_spreadsheets_desc")} + /> + } + title={t("welcome.capability_browser")} + description={t("welcome.capability_browser_desc")} + /> + } + title={t("welcome.capability_files")} + description={t("welcome.capability_files_desc")} + /> + } + title={t("welcome.capability_automate")} + description={t("welcome.capability_automate_desc")} + /> + } + title={t("welcome.capability_content")} + description={t("welcome.capability_content_desc")} + /> + } + title={t("welcome.capability_apis")} + description={t("welcome.capability_apis_desc")} + /> +
+ + {/* CTA */} +
+ +
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx new file mode 100644 index 0000000000..ec0020f29a --- /dev/null +++ b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx @@ -0,0 +1,364 @@ +/** @jsxImportSource react */ +import { Check, ChevronRight, Clock3, HardDrive, RefreshCcw, ShieldCheck, XCircle } from "lucide-react"; +import { useEffect, useMemo, useRef, type KeyboardEvent } from "react"; + +import { t } from "../../../../i18n"; +import type { PendingPermission } from "../../../../app/types"; +import { Button } from "../../../design-system/button"; + +type PermissionPresentation = { + title: string; + message: string; + permissionLabel: string; + scopeLabel: string; + scopeValue: string; + isDoomLoop: boolean; + note: string | null; +}; + +type PermissionDetail = { + label: string; + value: string; + multiline?: boolean; +}; + +type PermissionApprovalModalProps = { + permission: PendingPermission; + busy?: boolean; + respondPermission?: (requestID: string, reply: "once" | "always" | "reject") => void; + safeStringify?: (value: unknown) => string; +}; + +const metadataDetailKeys: Array<{ key: string; labelKey: string; multiline?: boolean }> = [ + { key: "command", labelKey: "session.permission_detail_command", multiline: true }, + { key: "description", labelKey: "session.permission_detail_description" }, + { key: "cwd", labelKey: "session.permission_detail_cwd" }, + { key: "filepath", labelKey: "session.permission_detail_file" }, + { key: "filePath", labelKey: "session.permission_detail_file" }, + { key: "path", labelKey: "session.permission_detail_path" }, + { key: "target", labelKey: "session.permission_detail_target" }, + { key: "parentDir", labelKey: "session.permission_detail_parent_directory" }, + { key: "url", labelKey: "session.permission_detail_url" }, + { key: "query", labelKey: "session.permission_detail_query", multiline: true }, + { key: "subagent_type", labelKey: "session.permission_detail_agent" }, + { key: "tool", labelKey: "session.permission_detail_tool" }, + { key: "files", labelKey: "session.permission_detail_files", multiline: true }, + { key: "diff", labelKey: "session.permission_detail_diff", multiline: true }, +]; + +function readablePermissionLabel(permission: string): string { + if (permission === "bash") return "Bash"; + if (permission === "edit") return t("session.permission_kind_edit"); + if (permission === "read") return t("session.permission_kind_read"); + if (permission === "external_directory") return t("session.permission_kind_external_directory"); + if (permission === "task") return t("session.permission_kind_task"); + if (permission === "todowrite") return t("session.permission_kind_todowrite"); + if (permission === "question") return t("session.permission_kind_question"); + if (permission === "skill") return t("session.permission_kind_skill"); + return permission; +} + +function permissionCopy(permission: string): Pick { + if (permission === "bash") { + return { + title: t("session.permission_title_bash"), + message: t("session.permission_message_bash"), + }; + } + if (permission === "edit") { + return { + title: t("session.permission_title_edit"), + message: t("session.permission_message_edit"), + }; + } + if (permission === "read") { + return { + title: t("session.permission_title_read"), + message: t("session.permission_message_read"), + }; + } + if (permission === "external_directory") { + return { + title: t("session.permission_title_external_directory"), + message: t("session.permission_message_external_directory"), + }; + } + if (permission === "task") { + return { + title: t("session.permission_title_task"), + message: t("session.permission_message_task"), + }; + } + return { + title: t("session.permission_title_generic", undefined, { permission: readablePermissionLabel(permission) }), + message: t("session.permission_message"), + }; +} + +function fileChangeLine(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const path = + (typeof record.relativePath === "string" && record.relativePath.trim()) || + (typeof record.filePath === "string" && record.filePath.trim()) || + (typeof record.path === "string" && record.path.trim()) || + null; + if (!path) return null; + const type = typeof record.type === "string" && record.type.trim() ? record.type.trim() : "change"; + return `${type}: ${path}`; +} + +function metadataValue(key: string, value: unknown): string | null { + if (typeof value === "string") return value.trim() || null; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (key === "files" && Array.isArray(value)) { + const lines = value.map(fileChangeLine).filter(Boolean); + return lines.length ? lines.join("\n") : null; + } + return null; +} + +export function permissionDetailRows(metadata: Record): PermissionDetail[] { + const seen = new Set(); + const rows: PermissionDetail[] = []; + for (const item of metadataDetailKeys) { + if (seen.has(item.labelKey)) continue; + const value = metadataValue(item.key, metadata[item.key]); + if (!value) continue; + seen.add(item.labelKey); + rows.push({ + label: t(item.labelKey), + value, + multiline: item.multiline, + }); + } + return rows; +} + +function stringifyMetadata(metadata: Record, safeStringify?: (value: unknown) => string) { + try { + return safeStringify ? safeStringify(metadata) : JSON.stringify(metadata, null, 2); + } catch { + return t("session.permission_metadata_unavailable"); + } +} + +function isFocusableElement(element: HTMLElement) { + if (element.hasAttribute("disabled")) return false; + if (element.getAttribute("aria-hidden") === "true") return false; + const style = window.getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden"; +} + +function describePermissionRequest(permission: PendingPermission): PermissionPresentation { + const patterns = permission.patterns.filter((pattern) => pattern.trim().length > 0); + if (permission.permission === "doom_loop") { + const tool = + permission.metadata && typeof permission.metadata === "object" && typeof permission.metadata.tool === "string" + ? permission.metadata.tool + : null; + + return { + title: t("session.doom_loop_title"), + message: t("session.doom_loop_message"), + permissionLabel: t("session.doom_loop_label"), + scopeLabel: tool ? t("session.doom_loop_tool_label") : t("session.doom_loop_repeated_call_label"), + scopeValue: tool ?? (patterns.length ? patterns.join(", ") : t("session.doom_loop_repeated_tool_call")), + isDoomLoop: true, + note: t("session.doom_loop_note"), + }; + } + + const copy = permissionCopy(permission.permission); + return { + title: copy.title, + message: copy.message, + permissionLabel: readablePermissionLabel(permission.permission), + scopeLabel: t("session.scope_label"), + scopeValue: patterns.join(", ") || t("session.permission_scope_empty"), + isDoomLoop: false, + note: null, + }; +} + +export function PermissionApprovalModal(props: PermissionApprovalModalProps) { + const dialogRef = useRef(null); + const previousActiveElementRef = useRef(null); + const presentation = useMemo(() => describePermissionRequest(props.permission), [props.permission]); + const metadata = + props.permission.metadata && typeof props.permission.metadata === "object" + ? props.permission.metadata + : {}; + const hasMetadata = Object.keys(metadata).length > 0; + const detailRows = permissionDetailRows(metadata); + const Icon = presentation.isDoomLoop ? RefreshCcw : ShieldCheck; + const iconClass = presentation.isDoomLoop + ? "bg-amber-3/30 text-amber-11" + : "bg-[rgba(var(--dls-accent-rgb),0.1)] text-dls-accent"; + + useEffect(() => { + previousActiveElementRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + dialogRef.current?.focus({ preventScroll: true }); + return () => { + previousActiveElementRef.current?.focus({ preventScroll: true }); + }; + }, [props.permission.id]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + props.respondPermission?.(props.permission.id, "reject"); + return; + } + if (event.key !== "Tab") return; + const dialog = dialogRef.current; + if (!dialog) return; + const focusable = Array.from( + dialog.querySelectorAll( + 'button, [href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])', + ), + ).filter(isFocusableElement); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + const active = document.activeElement; + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + return ( +
+
+
+
+
+ +
+
+

+ {presentation.title} +

+

+ {presentation.message} +

+
+
+
+ +
+
+
+ {t("session.permission_label")} +
+
+ {presentation.permissionLabel} +
+ {presentation.note ? ( +

+ {presentation.note} +

+ ) : null} +
+ +
+
+ + {presentation.scopeLabel} +
+
+ {presentation.scopeValue} +
+
+ + {detailRows.length > 0 ? ( +
+
+ {t("session.permission_review_label")} +
+
+ {detailRows.map((row) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+
+ ) : null} + + {hasMetadata ? ( +
+ + {t("session.details_label")} + + +
+                {stringifyMetadata(metadata, props.safeStringify)}
+              
+
+ ) : null} +
+ +
+

+ {t("session.permission_decision_hint")} +

+
+ + + +
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx new file mode 100644 index 0000000000..c9336b0b34 --- /dev/null +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -0,0 +1,590 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useState } from "react"; +import { Check, Loader2, Minimize2, Redo2, Undo2, Zap } from "lucide-react"; + +import { t } from "../../../../i18n"; +import { buildOpenworkWorkspaceBaseUrl, type OpenworkServerClient, type OpenworkServerStatus } from "../../../../app/lib/openwork-server"; +import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; +import type { BootPhase } from "../../../../app/lib/startup-boot"; +import type { WorkspaceInfo } from "../../../../app/lib/desktop"; +import type { + PendingPermission, + PendingQuestion, + ProviderListItem, + TodoItem, + WorkspaceConnectionState, + WorkspaceSessionGroup, +} from "../../../../app/types"; +import type { ShareWorkspaceModalProps } from "../../workspace/types"; +import { Button } from "../../../design-system/button"; +import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; +import ProviderAuthModal, { type ProviderAuthModalProps } from "../../connections/provider-auth/provider-auth-modal"; +import { PermissionApprovalModal } from "./permission-approval-modal"; +import { QuestionModal } from "../modals/question-modal"; +import { RenameSessionModal } from "../modals/rename-session-modal"; +import { WorkspaceSessionList } from "../sidebar/workspace-session-list"; +import { SessionSurface, type SessionSurfaceProps } from "../surface/session-surface"; +import { ShareWorkspaceModal } from "../../workspace/share-workspace-modal"; +import { StatusBar, type StatusBarProps } from "./status-bar"; +import { + DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, + useWorkspaceShellLayout, +} from "../../../shell/workspace-shell-layout"; +import { OwDotTicker } from "../../../shell/dot-ticker"; +import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; + +type StatusBarOverrides = Pick< + StatusBarProps, + | "statusLabel" + | "statusDetail" + | "statusDotClass" + | "statusPingClass" + | "statusPulse" + | "showSettingsButton" + | "settingsOpen" +>; + +export type SessionPageHistoryControls = { + canUndo: boolean; + canRedo: boolean; + busyAction: "undo" | "redo" | null; + onUndo: () => void | Promise; + onRedo: () => void | Promise; +}; + +export type SessionPageSidebarProps = { + workspaceSessionGroups: WorkspaceSessionGroup[]; + selectedWorkspaceId: string; + selectedSessionId: string | null; + developerMode: boolean; + sessionStatusById: Record; + connectingWorkspaceId: string | null; + workspaceConnectionStateById: Record; + newTaskDisabled: boolean; + sidebarHydratedFromCache: boolean; + startupPhase: BootPhase; + onSelectWorkspace: (workspaceId: string) => Promise | boolean | void; + onOpenSession: (workspaceId: string, sessionId: string) => void; + onPrefetchSession?: (workspaceId: string, sessionId: string) => void; + onCreateTaskInWorkspace: (workspaceId: string) => 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; +}; + +export type SessionPageSurfaceProps = Omit< + SessionSurfaceProps, + "client" | "workspaceId" | "sessionId" | "opencodeBaseUrl" | "openworkToken" +>; + +export type SessionPageProps = { + selectedSessionId: string | null; + selectedWorkspaceId: string; + selectedWorkspaceDisplay: { + id?: string; + name?: string; + displayName?: string; + workspaceType?: WorkspaceInfo["workspaceType"]; + }; + selectedWorkspaceRoot: string; + runtimeWorkspaceId: string | null; + workspaces: WorkspaceInfo[]; + clientConnected: boolean; + openworkServerStatus: OpenworkServerStatus; + openworkServerClient: OpenworkServerClient | null; + openworkServerToken?: string | null; + developerMode: boolean; + headerStatus: string; + busyHint: string | null; + startupPhase: BootPhase; + providerConnectedIds: string[]; + providers?: ProviderListItem[]; + mcpConnectedCount: number; + onSendFeedback: () => void; + onOpenSettings: () => void; + sidebar: SessionPageSidebarProps; + surface?: SessionPageSurfaceProps | null; + history?: SessionPageHistoryControls | null; + todos: TodoItem[]; + sessionLoadingById: (sessionId: string | null) => boolean; + shareWorkspaceModal?: ShareWorkspaceModalProps | null; + providerAuthModal?: ProviderAuthModalProps | null; + activePermission?: PendingPermission | null; + permissionReplyBusy?: boolean; + respondPermission?: (requestID: string, reply: "once" | "always" | "reject") => void; + safeStringify?: (value: unknown) => string; + activeQuestion?: PendingQuestion | null; + questionReplyBusy?: boolean; + respondQuestion?: (requestID: string, answers: string[][]) => void; + statusBar?: Partial; + onRenameSession?: (sessionId: string, title: string) => Promise | void; + onDeleteSession?: (sessionId: string) => Promise | void; +}; + +function getSidebarInitialLoading(props: SessionPageSidebarProps) { + if (props.workspaceSessionGroups.some((group) => group.sessions.length > 0)) { + return false; + } + if (props.sidebarHydratedFromCache) return false; + if ( + props.startupPhase !== "sessionIndexReady" && + props.startupPhase !== "firstSessionReady" && + props.startupPhase !== "ready" + ) { + return true; + } + return props.workspaceSessionGroups.some( + (group) => group.status === "loading" || group.status === "idle", + ); +} + +function sessionTitleForId(groups: WorkspaceSessionGroup[], id: string | null | undefined) { + if (!id) return ""; + for (const group of groups) { + const match = group.sessions.find((session) => session.id === id); + if (match) return getDisplaySessionTitle(match.title); + } + return ""; +} + +export function SessionPage(props: SessionPageProps) { + const { leftSidebarWidth, startLeftSidebarResize } = useWorkspaceShellLayout({ + defaultLeftWidth: DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, + expandedRightWidth: 280, + }); + useReactRenderWatchdog("SessionPage", { + selectedSessionId: props.selectedSessionId, + selectedWorkspaceId: props.selectedWorkspaceId, + clientConnected: props.clientConnected, + startupPhase: props.startupPhase, + hasSurface: Boolean(props.surface), + workspaceCount: props.workspaces.length, + }); + + const [renameOpen, setRenameOpen] = useState(false); + const [renameTitle, setRenameTitle] = useState(""); + const [renameBusy, setRenameBusy] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + const [todoExpanded, setTodoExpanded] = useState(true); + const [showDelayedSessionLoadingState, setShowDelayedSessionLoadingState] = useState(false); + + const selectedSessionTitle = useMemo( + () => sessionTitleForId(props.sidebar.workspaceSessionGroups, props.selectedSessionId), + [props.selectedSessionId, props.sidebar.workspaceSessionGroups], + ); + const workspaceName = + props.selectedWorkspaceDisplay.displayName?.trim() || + props.selectedWorkspaceDisplay.name?.trim() || + t("session.workspace_fallback"); + const providerCount = props.providerConnectedIds.length; + const messageCountVisible = props.selectedSessionId ? 1 : 0; + const showWorkspaceSetupEmptyState = props.workspaces.length === 0 && !props.selectedSessionId; + const showStartupSkeleton = + !props.selectedSessionId && + !props.clientConnected && + props.startupPhase !== "sessionIndexReady" && + props.startupPhase !== "firstSessionReady" && + props.startupPhase !== "ready"; + const showSessionLoadingState = + Boolean(props.selectedSessionId) && props.sessionLoadingById(props.selectedSessionId) && !showWorkspaceSetupEmptyState; + const todos = useMemo(() => props.todos.filter((todo) => todo.content.trim()), [props.todos]); + const completedTodos = useMemo( + () => todos.filter((todo) => todo.status === "completed").length, + [todos], + ); + const sidebarInitialLoading = useMemo(() => getSidebarInitialLoading(props.sidebar), [props.sidebar]); + + const reactSessionBaseUrl = useMemo(() => { + const workspaceId = props.runtimeWorkspaceId?.trim() ?? ""; + const baseUrl = props.openworkServerClient?.baseUrl?.trim() ?? ""; + if (!workspaceId || !baseUrl) return ""; + const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl; + return `${mounted.replace(/\/+$/, "")}/opencode`; + }, [props.openworkServerClient?.baseUrl, props.runtimeWorkspaceId]); + + const reactSessionToken = props.openworkServerClient?.token?.trim() || props.openworkServerToken?.trim() || ""; + const canRenderReactSurface = Boolean( + props.selectedSessionId && + props.runtimeWorkspaceId && + props.openworkServerClient && + reactSessionBaseUrl && + reactSessionToken && + props.surface, + ); + + useEffect(() => { + if (!showSessionLoadingState) { + setShowDelayedSessionLoadingState(false); + return; + } + const id = window.setTimeout(() => { + setShowDelayedSessionLoadingState(true); + }, 1000); + return () => window.clearTimeout(id); + }, [showSessionLoadingState]); + + useEffect(() => { + setRenameOpen(false); + setDeleteOpen(false); + setRenameBusy(false); + setDeleteBusy(false); + }, [props.selectedSessionId]); + + const openRenameModal = () => { + if (!props.selectedSessionId || !props.onRenameSession) return; + setRenameTitle(selectedSessionTitle); + setRenameOpen(true); + }; + + const submitRename = async () => { + const sessionId = props.selectedSessionId; + const nextTitle = renameTitle.trim(); + if (!sessionId || !props.onRenameSession || !nextTitle || nextTitle === selectedSessionTitle.trim()) return; + setRenameBusy(true); + try { + await props.onRenameSession(sessionId, nextTitle); + setRenameOpen(false); + } finally { + setRenameBusy(false); + } + }; + + const confirmDelete = async () => { + const sessionId = props.selectedSessionId; + if (!sessionId || !props.onDeleteSession) return; + setDeleteBusy(true); + try { + await props.onDeleteSession(sessionId); + setDeleteOpen(false); + } finally { + setDeleteBusy(false); + } + }; + + const todoLabel = + completedTodos > 0 + ? t("session.todo_progress_label", undefined, { completed: completedTodos, total: todos.length }) + : t("session.todo_label", undefined, { count: todos.length }); + + return ( +
+
+ + +
+
+
+

+ {showWorkspaceSetupEmptyState + ? t("session.create_or_connect_workspace") + : selectedSessionTitle || t("session.default_title")} +

+ + {workspaceName} + + {props.developerMode ? ( + + {props.headerStatus} + + ) : null} + {props.busyHint ? ( + + {props.busyHint} + + ) : null} +
+ +
+ {props.history ? ( + <> + + + + ) : null} +
+
+ +
+
+ {showStartupSkeleton ? ( +
+
+
+
+
+
+
+ {[0, 1, 2].map((idx) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+ ) : null} + + {showDelayedSessionLoadingState ? ( +
+
+ +
+ {t("session.loading_detail")} +
+
+
+ ) : null} + + {!showDelayedSessionLoadingState && canRenderReactSurface ? ( + + ) : null} + + {!showDelayedSessionLoadingState && !canRenderReactSurface && !showStartupSkeleton ? ( +
+ {showWorkspaceSetupEmptyState ? ( +
+
+ +
+
+

{t("session.create_or_connect_workspace")}

+

+ {t("workspace.empty_state_body")} +

+
+
+ +
+
+ ) : ( +
+ {props.selectedSessionId + ? t("session.loading_detail") + : t("session.select_or_create_session")} +
+ )} +
+ ) : null} +
+
+ + {todos.length > 0 ? ( +
+
+ + {todoExpanded ? ( +
+ {todos.map((todo, index) => { + const done = todo.status === "completed"; + const cancelled = todo.status === "cancelled"; + const active = todo.status === "in_progress"; + return ( +
+
+
+ {done ? : active ? : null} +
+
+
+ {index + 1}. + {todo.content} +
+
+ ); + })} +
+ ) : null} +
+
+ ) : null} + + +
+
+ + {props.providerAuthModal ? : null} + + {props.onRenameSession ? ( + 0 && renameTitle.trim() !== selectedSessionTitle.trim()} + onClose={() => { + if (!renameBusy) setRenameOpen(false); + }} + onSave={() => void submitRename()} + onTitleChange={setRenameTitle} + /> + ) : null} + + {props.onDeleteSession ? ( + void confirmDelete()} + onCancel={() => { + if (!deleteBusy) setDeleteOpen(false); + }} + /> + ) : null} + + {props.shareWorkspaceModal ? : null} + + {props.activePermission ? ( + + ) : null} + + { + if (props.activeQuestion) { + props.respondQuestion?.(props.activeQuestion.id, answers); + } + }} + /> +
+ ); +} diff --git a/apps/app/src/react-app/domains/session/chat/status-bar.tsx b/apps/app/src/react-app/domains/session/chat/status-bar.tsx new file mode 100644 index 0000000000..ce97a3ceb2 --- /dev/null +++ b/apps/app/src/react-app/domains/session/chat/status-bar.tsx @@ -0,0 +1,186 @@ +/** @jsxImportSource react */ +import { useEffect, useState } from "react"; +import { BookOpen, MessageCircle, Settings } from "lucide-react"; + +import { t } from "../../../../i18n"; +import { usePlatform } from "../../../kernel/platform"; +import type { OpenworkServerStatus } from "../../../../app/lib/openwork-server"; + +const DOCS_URL = "https://openworklabs.com/docs"; +const STATUS_BAR_BOOT_STARTED_AT = Date.now(); +const STATUS_BAR_INITIALIZING_MS = 15_000; + +export type StatusBarProps = { + clientConnected: boolean; + openworkServerStatus: OpenworkServerStatus; + developerMode: boolean; + settingsOpen: boolean; + onSendFeedback: () => void; + onOpenSettings: () => void; + providerConnectedIds: string[]; + mcpConnectedCount: number; + statusLabel?: string; + statusDetail?: string; + statusDotClass?: string; + statusPingClass?: string; + statusPulse?: boolean; + showSettingsButton?: boolean; + initializing?: boolean; +}; + +type StatusCopy = { + label: string; + detail: string; + dotClass: string; + pingClass: string; + pulse: boolean; +}; + +function deriveStatusCopy(props: StatusBarProps): StatusCopy { + 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 mcp = props.mcpConnectedCount; + + if (!props.clientConnected && props.openworkServerStatus === "disconnected" && props.initializing) { + return { + label: "Preparing workspace", + detail: t("session.loading_detail"), + dotClass: "bg-amber-9", + pingClass: "bg-amber-9/35 animate-ping", + pulse: true, + }; + } + + if (props.clientConnected) { + const detailBits: string[] = []; + 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, + }; +} + +export function StatusBar(props: StatusBarProps) { + const platform = usePlatform(); + const [initializing, setInitializing] = useState( + () => Date.now() - STATUS_BAR_BOOT_STARTED_AT < STATUS_BAR_INITIALIZING_MS, + ); + + useEffect(() => { + if (!initializing) return; + const remaining = Math.max( + 0, + STATUS_BAR_INITIALIZING_MS - (Date.now() - STATUS_BAR_BOOT_STARTED_AT), + ); + const timeout = window.setTimeout(() => setInitializing(false), remaining); + return () => window.clearTimeout(timeout); + }, [initializing]); + + const statusCopy = deriveStatusCopy({ ...props, initializing }); + + return ( +
+
+
+ + {statusCopy.pulse ? ( + + ) : null} + + + + {statusCopy.label} + + + {statusCopy.detail} + +
+ +
+ + + {props.showSettingsButton !== false ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx new file mode 100644 index 0000000000..2ab49e2b26 --- /dev/null +++ b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx @@ -0,0 +1,590 @@ +/** @jsxImportSource react */ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { CheckCircle2, Circle, Search, X } from "lucide-react"; + +import { t } from "../../../../i18n"; +import { modelEquals } from "../../../../app/utils"; +import type { ModelOption, ModelRef } from "../../../../app/types"; + +// Minimal inline provider icon placeholder. The full ProviderIcon gets ported +// from src/app/components/provider-icon.tsx in a later step of the plan. +function ProviderIcon({ + providerId, + size = 16, + className, +}: { + providerId: string; + size?: number; + className?: string; +}) { + const initial = providerId.trim().charAt(0).toUpperCase() || "?"; + return ( + + {initial} + + ); +} + +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; +}; + +type RenderedItem = + | { kind: "model"; opt: ModelOption } + | { + kind: "provider"; + providerID: string; + title: string; + matchCount: number; + }; + +export function ModelPickerModal(props: ModelPickerModalProps) { + const searchInputRef = useRef(null); + const optionRefs = useRef([]); + const [activeIndex, setActiveIndex] = useState(0); + + const translate = useCallback( + (key: string, params?: Record) => + t(key, undefined, params), + [], + ); + + const otherProviderLinks = useMemo(() => { + 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, + })); + }, [props.filteredOptions]); + + const renderedItems = useMemo(() => { + 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, + })), + ]; + }, [otherProviderLinks, props.filteredOptions]); + + const activeModelIndex = useMemo( + () => + renderedItems.findIndex( + (item) => + item.kind === "model" && + modelEquals(props.current, { + providerID: item.opt.providerID, + modelID: item.opt.modelID, + }), + ), + [props.current, renderedItems], + ); + + const recommendedOptions = useMemo( + () => + renderedItems.flatMap((item, index) => + item.kind === "model" && item.opt.isRecommended + ? [{ opt: item.opt, index }] + : [], + ), + [renderedItems], + ); + + const otherEnabledOptions = useMemo( + () => + renderedItems.flatMap((item, index) => + item.kind === "model" && !item.opt.isRecommended + ? [{ opt: item.opt, index }] + : [], + ), + [renderedItems], + ); + + const otherOptions = useMemo( + () => + renderedItems.flatMap((item, index) => + item.kind === "provider" + ? [ + { + providerID: item.providerID, + title: item.title, + matchCount: item.matchCount, + index, + }, + ] + : [], + ), + [renderedItems], + ); + + const clampIndex = useCallback( + (next: number) => { + const last = renderedItems.length - 1; + if (last < 0) return 0; + return Math.max(0, Math.min(next, last)); + }, + [renderedItems.length], + ); + + const scrollActiveIntoView = useCallback((idx: number) => { + const el = optionRefs.current[idx]; + if (!el) return; + el.scrollIntoView({ block: "nearest" }); + }, []); + + const selectRenderedItem = useCallback( + (item: RenderedItem | undefined) => { + if (!item) return; + if (item.kind === "provider") { + props.onClose({ restorePromptFocus: false }); + props.onOpenSettings(); + return; + } + props.onSelect({ + providerID: item.opt.providerID, + modelID: item.opt.modelID, + }); + }, + [props], + ); + + // Focus the search input whenever the modal opens. + useEffect(() => { + if (!props.open) return; + const frame = requestAnimationFrame(() => { + searchInputRef.current?.focus(); + if (searchInputRef.current?.value) { + searchInputRef.current.select(); + } + }); + return () => cancelAnimationFrame(frame); + }, [props.open]); + + // Keep the active option in sync with the current model on open / list change. + useEffect(() => { + if (!props.open) return; + const next = activeModelIndex >= 0 ? activeModelIndex : 0; + const clamped = clampIndex(next); + setActiveIndex(clamped); + const frame = requestAnimationFrame(() => scrollActiveIntoView(clamped)); + return () => cancelAnimationFrame(frame); + }, [activeModelIndex, clampIndex, props.open, scrollActiveIntoView]); + + // Window-level key handling. + useEffect(() => { + if (!props.open) return; + const onKeyDown = (event: KeyboardEvent) => { + 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 item = renderedItems[activeIndex]; + if (!item) return; + event.preventDefault(); + event.stopPropagation(); + selectRenderedItem(item); + } + }; + + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [activeIndex, clampIndex, renderedItems, scrollActiveIntoView, selectRenderedItem]); + + if (!props.open) return null; + + const registerOptionRef = (index: number) => (el: HTMLDivElement | null) => { + if (!el) return; + optionRefs.current[index] = el; + }; + + const renderOption = (opt: ModelOption, index: number) => { + const active = modelEquals(props.current, { + providerID: opt.providerID, + modelID: opt.modelID, + }); + const isKeyboardActive = index === activeIndex; + + return ( +
setActiveIndex(index)} + onClick={() => + props.onSelect({ + providerID: opt.providerID, + modelID: opt.modelID, + }) + } + onKeyDown={(event: ReactKeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (event.nativeEvent.isComposing) return; + event.preventDefault(); + props.onSelect({ + providerID: opt.providerID, + modelID: opt.modelID, + }); + }} + > +
+ +
+
+ {opt.title} +
+
+ + {opt.description ?? opt.providerID} + + + {opt.providerID}/{opt.modelID} + +
+ {opt.footer ? ( +
+ {opt.footer} +
+ ) : null} + {active && (opt.behaviorOptions?.length ?? 0) > 0 ? ( +
event.stopPropagation()} + > + + {opt.behaviorTitle}: + +
event.stopPropagation()} + > + {(opt.behaviorOptions ?? []).map((option) => ( + + ))} +
+
+ ) : null} +
+
+
+ ); + }; + + const renderProviderLink = (provider: { + providerID: string; + title: string; + matchCount: number; + index: number; + }) => { + const isKeyboardActive = provider.index === activeIndex; + return ( +
setActiveIndex(provider.index)} + onClick={() => { + props.onClose({ restorePromptFocus: false }); + props.onOpenSettings(); + }} + onKeyDown={(event: ReactKeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (event.nativeEvent.isComposing) 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", + )} +

+
+ +
+ +
+
+ + props.setQuery(event.currentTarget.value)} + placeholder={translate("settings.search_models")} + className="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" + /> +
+ {props.query.trim() ? ( +
+ {translate("settings.showing_models", { + count: props.filteredOptions.length, + total: props.options.length, + })} +
+ ) : null} +
+ +
+ {recommendedOptions.length > 0 ? ( +
+
+ {translate("model_picker.recommended")} +
+ {recommendedOptions.map(({ opt, index }) => + renderOption(opt, index), + )} +
+ ) : null} + + {otherEnabledOptions.length > 0 ? ( +
+
+ {translate("model_picker.other_connected_models")} +
+ {otherEnabledOptions.map(({ opt, index }) => + renderOption(opt, index), + )} +
+ ) : null} + + {otherOptions.length > 0 ? ( +
+
+ {translate("model_picker.more_providers")} +
+ {otherOptions.map(renderProviderLink)} +
+ ) : null} + + {renderedItems.length === 0 ? ( +
+ {translate("model_picker.no_results")} +
+ ) : null} +
+ +
+ +
+
+
+
+ ); +} + +// Small helper so downstream callers can keep the check/circle icons +// colocated with the picker (future use for selected-state ornaments). +export function ModelPickerSelectedIcon({ active }: { active: boolean }) { + return active ? : ; +} diff --git a/apps/app/src/react-app/domains/session/modals/question-modal.tsx b/apps/app/src/react-app/domains/session/modals/question-modal.tsx new file mode 100644 index 0000000000..c6a06bf752 --- /dev/null +++ b/apps/app/src/react-app/domains/session/modals/question-modal.tsx @@ -0,0 +1,235 @@ +/** @jsxImportSource react */ +import { useEffect, useState } from "react"; +import type { QuestionInfo } from "@opencode-ai/sdk/v2/client"; +import { Check, ChevronRight, HelpCircle } from "lucide-react"; + +import { t } from "../../../../i18n"; +import { Button } from "../../../design-system/button"; + +export type QuestionModalProps = { + open: boolean; + questions: QuestionInfo[]; + busy: boolean; + onReply: (answers: string[][]) => void; +}; + +export function QuestionModal(props: QuestionModalProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [answers, setAnswers] = useState([]); + const [currentSelection, setCurrentSelection] = useState([]); + const [customInput, setCustomInput] = useState(""); + const [focusedOptionIndex, setFocusedOptionIndex] = useState(0); + + useEffect(() => { + if (!props.open) return; + setCurrentIndex(0); + setAnswers(new Array(props.questions.length).fill([])); + setCurrentSelection([]); + setCustomInput(""); + setFocusedOptionIndex(0); + }, [props.open, props.questions.length]); + + const currentQuestion = props.questions[currentIndex]; + const isLastQuestion = currentIndex === props.questions.length - 1; + const canProceed = (() => { + if (!currentQuestion) return false; + if (currentQuestion.custom && customInput.trim().length > 0) return true; + return currentSelection.length > 0; + })(); + + const handleNext = () => { + if (!canProceed || !currentQuestion) return; + const nextAnswer = [...currentSelection]; + if (currentQuestion.custom && customInput.trim()) { + nextAnswer.push(customInput.trim()); + } + const newAnswers = [...answers]; + newAnswers[currentIndex] = nextAnswer; + setAnswers(newAnswers); + if (isLastQuestion) { + props.onReply(newAnswers); + } else { + setCurrentIndex((i) => i + 1); + setCurrentSelection([]); + setCustomInput(""); + setFocusedOptionIndex(0); + } + }; + + const toggleOption = (option: string) => { + if (!currentQuestion) return; + if (currentQuestion.multiple) { + setCurrentSelection((prev) => + prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option], + ); + return; + } + setCurrentSelection([option]); + if (!currentQuestion.custom) { + setTimeout(() => { + setAnswers((prevAnswers) => { + const newAnswers = [...prevAnswers]; + newAnswers[currentIndex] = [option]; + if (isLastQuestion) { + props.onReply(newAnswers); + } else { + setCurrentIndex((i) => i + 1); + setCurrentSelection([]); + setCustomInput(""); + setFocusedOptionIndex(0); + } + return newAnswers; + }); + }, 150); + } + }; + + useEffect(() => { + if (!props.open) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (!currentQuestion) return; + const optionsCount = currentQuestion.options.length; + + if (event.key === "ArrowDown") { + event.preventDefault(); + setFocusedOptionIndex((prev) => (prev + 1) % optionsCount); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setFocusedOptionIndex( + (prev) => (prev - 1 + optionsCount) % optionsCount, + ); + } else if (event.key === "Enter") { + if (event.isComposing || event.keyCode === 229) return; + event.preventDefault(); + if ( + currentQuestion.custom && + document.activeElement?.tagName === "INPUT" + ) { + handleNext(); + return; + } + const option = currentQuestion.options[focusedOptionIndex]?.description; + if (option) toggleOption(option); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.open, currentQuestion, focusedOptionIndex]); + + if (!props.open || !currentQuestion) return null; + + return ( +
+
+
+
+
+ +
+
+

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

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

+ {currentQuestion.question} +

+
+ +
+
+ {currentQuestion.options.map((opt, idx) => { + const isSelected = currentSelection.includes(opt.description); + const isFocused = focusedOptionIndex === idx; + return ( + + ); + })} +
+ + {currentQuestion.custom ? ( +
+ + setCustomInput(event.currentTarget.value)} + className="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={(event) => { + if (event.key === "Enter") { + if (event.nativeEvent.isComposing || event.keyCode === 229) + return; + event.stopPropagation(); + handleNext(); + } + }} + /> +
+ ) : null} +
+ +
+
+ + ↑↓ + + {t("common.navigate")} + + ↵ + + {t("common.select")} +
+ +
+ {currentQuestion.multiple || currentQuestion.custom ? ( + + ) : null} +
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/modals/rename-session-modal.tsx b/apps/app/src/react-app/domains/session/modals/rename-session-modal.tsx new file mode 100644 index 0000000000..6b1a779c53 --- /dev/null +++ b/apps/app/src/react-app/domains/session/modals/rename-session-modal.tsx @@ -0,0 +1,106 @@ +/** @jsxImportSource react */ +import { useEffect, useRef } from "react"; +import { X } from "lucide-react"; + +import { currentLocale, t } from "../../../../i18n"; +import { + inputClass, + pillGhostClass, + pillPrimaryClass, + pillSecondaryClass, +} from "../../workspace/modal-styles"; + +export type RenameSessionModalProps = { + open: boolean; + title: string; + busy: boolean; + canSave: boolean; + onClose: () => void; + onSave: () => void; + onTitleChange: (value: string) => void; +}; + +export function RenameSessionModal(props: RenameSessionModalProps) { + const inputRef = useRef(null); + const translate = (key: string) => t(key, currentLocale()); + + useEffect(() => { + if (!props.open) return; + const frame = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(frame); + }, [props.open]); + + if (!props.open) return null; + + return ( +
+
+
+
+
+

+ {translate("session.rename_title")} +

+

+ {translate("session.rename_description")} +

+
+ +
+ +
+ + props.onTitleChange(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.key !== "Enter" || + event.nativeEvent.isComposing || + event.keyCode === 229 + ) + return; + event.preventDefault(); + if (props.canSave) props.onSave(); + }} + placeholder={translate("session.rename_placeholder")} + className={`${inputClass} bg-gray-3`} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/sidebar/workspace-session-list.tsx b/apps/app/src/react-app/domains/session/sidebar/workspace-session-list.tsx new file mode 100644 index 0000000000..4d527610ef --- /dev/null +++ b/apps/app/src/react-app/domains/session/sidebar/workspace-session-list.tsx @@ -0,0 +1,774 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, ChevronRight, Loader2, MoreHorizontal, Plus } from "lucide-react"; + +import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; +import type { WorkspaceInfo } from "../../../../app/lib/desktop"; +import type { + WorkspaceConnectionState, + WorkspaceSessionGroup, +} from "../../../../app/types"; +import { + getWorkspaceTaskLoadErrorDisplay, + isSandboxWorkspace, + isWindowsPlatform, +} from "../../../../app/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 function WorkspaceSessionList(props: Props) { + const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useState>( + () => new Set(), + ); + const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] = useState>({}); + const [workspaceMenuId, setWorkspaceMenuId] = useState(null); + const [sessionMenuOpen, setSessionMenuOpen] = useState(false); + const [expandedSessionIds, setExpandedSessionIds] = useState>( + () => new Set(), + ); + const workspaceMenuRef = useRef(null); + const sessionMenuRef = useRef(null); + + const revealLabel = isWindowsPlatform() + ? t("workspace_list.reveal_explorer") + : t("workspace_list.reveal_finder"); + + const expandWorkspace = (workspaceId: string) => { + const id = workspaceId.trim(); + if (!id) return; + setExpandedWorkspaceIds((previous) => { + if (previous.has(id)) return previous; + const next = new Set(previous); + next.add(id); + return next; + }); + }; + + const toggleWorkspaceExpanded = (workspaceId: string) => { + const id = workspaceId.trim(); + if (!id) return; + setExpandedWorkspaceIds((previous) => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + useEffect(() => { + const id = props.selectedWorkspaceId.trim(); + if (!id) return; + // Keep the selected workspace visible without collapsing other workspaces. + // Collapsing the previous workspace on every cross-workspace session click + // makes the sidebar feel jumpy and hides the context the user just left. + expandWorkspace(id); + }, [props.selectedWorkspaceId]); + + const previewCount = (workspaceId: string) => + previewCountByWorkspaceId[workspaceId] ?? MAX_SESSIONS_PREVIEW; + + 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"); + }; + + const toggleSessionExpanded = (sessionId: string) => { + const id = sessionId.trim(); + if (!id) return; + setExpandedSessionIds((previous) => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + useEffect(() => { + if (!workspaceMenuId) return; + const closeMenu = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && workspaceMenuRef.current?.contains(target)) return; + setWorkspaceMenuId(null); + }; + window.addEventListener("pointerdown", closeMenu); + return () => { + window.removeEventListener("pointerdown", closeMenu); + }; + }, [workspaceMenuId]); + + useEffect(() => { + setSessionMenuOpen(false); + }, [props.selectedSessionId]); + + useEffect(() => { + 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); + }); + }, [ + props.onPrefetchSession, + props.selectedSessionId, + props.selectedWorkspaceId, + props.workspaceSessionGroups, + ]); + + useEffect(() => { + if (!sessionMenuOpen) return; + const closeMenu = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && sessionMenuRef.current?.contains(target)) return; + setSessionMenuOpen(false); + }; + window.addEventListener("pointerdown", closeMenu); + return () => { + window.removeEventListener("pointerdown", closeMenu); + }; + }, [sessionMenuOpen]); + + const renderSessionRow = ( + workspaceId: string, + row: FlattenedSessionRow, + tree: SessionTreeState, + forcedExpandedSessionIds: Set, + ) => { + const session = row.session; + const isSelected = props.selectedSessionId === session.id; + const displayTitle = getDisplaySessionTitle(session.title); + const hasChildren = (tree.descendantCountBySessionId.get(session.id) ?? 0) > 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.nativeEvent.isComposing || event.keyCode === 229) return; + event.preventDefault(); + openSession(); + }} + > +
+ {hasChildren ? ( + + ) : row.depth > 0 ? ( + + ) : null} + + {isSessionActive ? : null} + + {displayTitle} + +
+ +
+ {canManageSession ? ( + + ) : null} +
+
+ + {canManageSession && sessionMenuOpen ? ( +
event.stopPropagation()} + > + {props.onOpenRenameSession ? ( + + ) : null} + + {props.onOpenDeleteSession ? ( + + ) : null} +
+ ) : null} +
+ ); + }; + + return ( +
+
+
+ {props.workspaceSessionGroups.map((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" as const, + 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 = group.status === "error" + ? taskLoadError.tone === "offline" + ? "text-amber-11" + : "text-red-11" + : "text-gray-9"; + const rootSessions = getRootSessions(group.sessions); + const sessionRows = flattenSessionRows( + group.sessions, + previewCount(workspace.id), + tree, + expandedSessionIds, + forcedExpandedSessionIds, + ); + + return ( +
+
+
{ + expandWorkspace(workspace.id); + void Promise.resolve(props.onSelectWorkspace(workspace.id)); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + event.preventDefault(); + expandWorkspace(workspace.id); + void Promise.resolve(props.onSelectWorkspace(workspace.id)); + }} + > +
+
+
+
+ {workspaceLabel(workspace)} +
+ {statusLabel ? ( +
{statusLabel}
+ ) : null} +
+
+ +
+ {group.status === "loading" || isConnecting ? ( + + ) : null} + +
+ + + +
+ + +
+
+ + {isMenuOpen ? ( +
event.stopPropagation()} + > + + + {workspace.workspaceType === "local" ? ( + + ) : null} + {workspace.workspaceType === "remote" ? ( + <> + {canRecover ? ( + + ) : null} + + + + ) : null} + +
+ ) : null} +
+ + {expandedWorkspaceIds.has(workspace.id) ? ( +
+
+ {props.showInitialLoading ? ( +
+ {[0, 1, 2].map((idx) => ( +
+
+
+ ))} +
+ ) : group.status === "loading" && group.sessions.length === 0 ? ( +
+ {t("workspace.loading_tasks")} +
+ ) : group.sessions.length > 0 ? ( + <> + {sessionRows.map((row) => renderSessionRow(workspace.id, row, tree, forcedExpandedSessionIds))} + + {group.sessions.length === 0 && group.status === "ready" ? ( + + ) : null} + + {rootSessions.length > previewCount(workspace.id) ? ( + + ) : null} + + ) : group.status === "error" ? ( +
+ {taskLoadError.message} +
+ ) : ( + + )} +
+
+ ) : null} +
+ ); + })} +
+
+ +
+ +
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx new file mode 100644 index 0000000000..0ddcbee614 --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -0,0 +1,1429 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Agent } from "@opencode-ai/sdk/v2/client"; +import { ArrowUp, Check, ChevronDown, ChevronRight, FileText, Paperclip, Plug, Settings, Square, Terminal, X, Zap } from "lucide-react"; +import fuzzysort from "fuzzysort"; +import type { CloudImportedPlugin, CloudImportedPluginFile } from "../../../../../app/cloud/import-state"; +import type { ComposerAttachment, McpServerEntry, McpStatusMap, SkillCard, SlashCommandOption } from "../../../../../app/types"; +import { currentLocale, t, type Language } from "../../../../../i18n"; +import { LexicalPromptEditor } from "./editor"; +import { + ReactComposerNotice, + type ReactComposerNotice as ReactComposerNoticeData, +} from "./notice"; + +type MentionItem = { + id: string; + kind: "agent" | "file"; + value: string; + label: string; +}; + +type PastedTextChip = { + id: string; + label: string; + text: string; + lines: number; +}; + +type ToolMenuSettingsSection = "commands" | "skills" | "mcps" | "plugins"; +type ToolMenuSection = "commands" | "skills" | "mcps" | `plugin:${string}`; + +type ComposerProps = { + draft: string; + mentions: Record; + onDraftChange: (value: string) => void; + onSend: () => void | Promise; + onStop: () => void | Promise; + busy: boolean; + disabled: boolean; + statusLabel: string; + modelLabel: string; + onModelClick: () => void; + attachments: ComposerAttachment[]; + onAttachFiles: (files: File[]) => void; + onRemoveAttachment: (id: string) => void; + attachmentsEnabled: boolean; + attachmentsDisabledReason: string | null; + modelVariantLabel: string; + modelVariant: string | null; + modelBehaviorOptions?: { value: string | null; label: string }[]; + onModelVariantChange: (value: string | null) => void; + agentLabel: string; + selectedAgent: string | null; + listAgents: () => Promise; + onSelectAgent: (agent: string | null) => void; + listCommands: () => Promise; + listSkills?: () => Promise; + skills?: SkillCard[]; + listMcp?: () => Promise<{ servers: McpServerEntry[]; statuses: McpStatusMap; status: string | null }>; + mcpServers?: McpServerEntry[]; + mcpStatus?: string | null; + mcpStatuses?: McpStatusMap; + listImportedPlugins?: () => Promise; + importedPlugins?: CloudImportedPlugin[]; + onOpenSettingsSection?: (section: ToolMenuSettingsSection) => void; + recentFiles: string[]; + searchFiles: (query: string) => Promise; + onInsertMention: (kind: "agent" | "file", value: string) => void; + notice: ReactComposerNoticeData | null; + onNotice: (notice: ReactComposerNoticeData) => void; + onPasteText: (text: string) => void; + onUnsupportedFileLinks: (links: string[]) => void; + pastedText: PastedTextChip[]; + onRevealPastedText: (id: string) => void; + onRemovePastedText: (id: string) => void; + isRemoteWorkspace: boolean; + isSandboxWorkspace: boolean; + onUploadInboxFiles?: ((files: File[]) => void | Promise) | null; + draftScopeKey?: string; + compactTopSpacing?: boolean; +}; + +const FLUSH_PROMPT_EVENT = "openwork:flushPromptDraft"; +const FOCUS_PROMPT_EVENT = "openwork:focusPrompt"; +const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; +const IMAGE_COMPRESS_MAX_PX = 2048; +const IMAGE_COMPRESS_QUALITY = 0.82; +const IMAGE_COMPRESS_TARGET_BYTES = 1_500_000; +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]; +const FILE_URL_RE = /^file:\/\//i; +const HTTP_URL_RE = /^https?:\/\//i; + +/** + * Extract external file/URL drops from a clipboard. Only used when the user + * drag-drops a file reference from another app (Finder / browser), which sets + * the text/uri-list MIME type explicitly. Plain text pastes — even ones that + * contain absolute paths like "/Users/..." — are NEVER treated as links here + * because that intercepted real text pastes and made composer paste feel + * broken. Plain text goes straight into the editor via Lexical's default. + */ +function parseClipboardUriList(clipboard: DataTransfer) { + const raw = clipboard.getData("text/uri-list") ?? ""; + if (!raw.trim()) return []; + const links: string[] = []; + const seen = new Set(); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (!FILE_URL_RE.test(trimmed) && !HTTP_URL_RE.test(trimmed)) continue; + const normalized = encodeURI(trimmed); + if (seen.has(normalized)) continue; + seen.add(normalized); + links.push(normalized); + } + return links; +} + +function formatBytes(size: number) { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function isImageAttachment(attachment: ComposerAttachment) { + return attachment.kind === "image" || attachment.mimeType.startsWith("image/"); +} + +const isSupportedAttachmentType = (mime: string) => ACCEPTED_FILE_TYPES.includes(mime); + +async function compressImageFile(file: File): Promise { + if (file.type === "image/gif" || file.size <= IMAGE_COMPRESS_TARGET_BYTES) { + return file; + } + + const bitmap = await createImageBitmap(file); + const { width, height } = bitmap; + const maxDim = Math.max(width, height); + const scale = maxDim > IMAGE_COMPRESS_MAX_PX ? IMAGE_COMPRESS_MAX_PX / maxDim : 1; + const targetW = Math.round(width * scale); + const targetH = Math.round(height * scale); + + let blob: Blob | null = null; + + if (typeof OffscreenCanvas !== "undefined") { + const offscreen = new OffscreenCanvas(targetW, targetH); + const ctx = offscreen.getContext("2d"); + if (ctx) { + ctx.drawImage(bitmap, 0, 0, targetW, targetH); + blob = await offscreen.convertToBlob({ + type: "image/jpeg", + quality: IMAGE_COMPRESS_QUALITY, + }); + } + } + + if (!blob) { + const canvas = document.createElement("canvas"); + canvas.width = targetW; + canvas.height = targetH; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(bitmap, 0, 0, targetW, targetH); + blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/jpeg", IMAGE_COMPRESS_QUALITY), + ); + } + } + + bitmap.close(); + + if (!blob || blob.size >= file.size) { + return file; + } + + const stem = file.name.replace(/\.[^.]+$/, "") || "image"; + return new File([blob], `${stem}.jpg`, { type: "image/jpeg" }); +} + +function formatMcpStatusLabel(status: McpServerStatus | undefined, locale: Language) { + switch (status) { + case "connected": + return t("mcp.friendly_status_ready", locale); + case "needs_auth": + case "needs_client_registration": + return t("mcp.friendly_status_needs_signin", locale); + case "disabled": + return t("mcp.friendly_status_paused", locale); + case "disconnected": + return t("mcp.friendly_status_offline", locale); + case "failed": + default: + return t("mcp.friendly_status_issue", locale); + } +} + +type McpServerStatus = "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected"; + +function toReactMcpStatus(name: string, entry: McpServerEntry, statuses: McpStatusMap): McpServerStatus { + const configured = statuses[name]; + if (configured?.status === "connected") return "connected"; + if (configured?.status === "needs_auth") return "needs_auth"; + if (configured?.status === "needs_client_registration") return "needs_client_registration"; + if (configured?.status === "failed") return "failed"; + if (configured?.status === "disabled" || entry.config.enabled === false || entry.config.enabled === undefined && entry.config.type === "local" && entry.config.command?.length === 0) { + return entry.config.enabled === false ? "disabled" : configured?.status === "disabled" ? "disabled" : "disconnected"; + } + return "disconnected"; +} + +function mcpStatusBadgeClass(status: McpServerStatus) { + switch (status) { + case "connected": + return "bg-green-3 text-green-11"; + case "needs_auth": + case "needs_client_registration": + return "bg-amber-3 text-amber-11"; + case "disabled": + case "disconnected": + return "bg-gray-3 text-gray-11"; + default: + return "bg-red-3 text-red-11"; + } +} + +function formatPluginObjectType(type: string) { + const normalized = type.trim().toLowerCase(); + if (!normalized) return "File"; + if (normalized === "mcp") return "MCP"; + return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`; +} + +function pluginSlashCommandName(file: CloudImportedPluginFile) { + const path = file.path.trim(); + if (file.objectType === "command") { + const command = path.match(/^\.opencode\/(?:command|commands)\/(.+)\.md$/i)?.[1]; + return command?.trim() || null; + } + if (file.objectType === "skill") { + const skill = path.match(/^\.opencode\/(?:skill|skills)\/(?:[^/]+\/)?([^/]+)\/SKILL\.md$/i)?.[1]; + return skill?.trim() || null; + } + return null; +} + +export function ReactSessionComposer(props: ComposerProps) { + let fileInput: HTMLInputElement | undefined; + const [agents, setAgents] = useState([]); + const [agentMenuOpen, setAgentMenuOpen] = useState(false); + const [variantMenuOpen, setVariantMenuOpen] = useState(false); + const [commands, setCommands] = useState([]); + const [commandsLoading, setCommandsLoading] = useState(false); + const [skillsLoading, setSkillsLoading] = useState(false); + const [skills, setSkills] = useState(props.skills ?? []); + const [mcpLoading, setMcpLoading] = useState(false); + const [mcpServers, setMcpServers] = useState(props.mcpServers ?? []); + const [mcpStatus, setMcpStatus] = useState(props.mcpStatus ?? null); + const [mcpStatuses, setMcpStatuses] = useState(props.mcpStatuses ?? {}); + const [importedPlugins, setImportedPlugins] = useState(props.importedPlugins ?? []); + const [pluginsLoading, setPluginsLoading] = useState(false); + const [slashOpen, setSlashOpen] = useState(false); + const [toolMenuOpen, setToolMenuOpen] = useState(false); + const [toolMenuSection, setToolMenuSection] = useState("commands"); + const [mentionItems, setMentionItems] = useState([]); + const [mentionOpen, setMentionOpen] = useState(false); + const [menuIndex, setMenuIndex] = useState(0); + const menuItemRefs = useRef>([]); + const commandsCacheRef = useRef(null); + const commandsRequestRef = useRef | null>(null); + const commandsLoadVersionRef = useRef(0); + const [agentMenuIndex, setAgentMenuIndex] = useState(0); + const agentItemRefs = useRef>([]); + const [dropzoneActive, setDropzoneActive] = useState(false); + const toolMenuRef = useRef(null); + const variantMenuRef = useRef(null); + const agentMenuRef = useRef(null); + // IME composition guard: while an IME composition is active, we must not + // treat Enter as a submit. Three signals keep this reliable across WebKit, + // Chrome, and Safari: event.isComposing, event.keyCode === 229, and the + // compositionstart/compositionend events below. + const imeComposingRef = useRef(false); + const rootRef = useRef(null); + const locale = currentLocale(); + const draftRef = useRef(props.draft); + useEffect(() => { + draftRef.current = props.draft; + }, [props.draft]); + + const slashMatch = props.draft.match(/^\/(\S*)$/); + const slashOpenNext = Boolean(slashMatch); + const slashQuery = slashMatch?.[1] ?? ""; + const mentionMatch = props.draft.match(/@([^\s@]*)$/); + const mentionOpenNext = Boolean(mentionMatch); + const mentionQuery = mentionMatch?.[1] ?? ""; + + useEffect(() => { + setSlashOpen(slashOpenNext); + setMenuIndex(0); + }, [slashOpenNext, slashQuery]); + + useEffect(() => { + setMentionOpen(mentionOpenNext); + setMenuIndex(0); + }, [mentionOpenNext, mentionQuery]); + + useEffect(() => { + if (!agentMenuOpen) return; + void props.listAgents().then(setAgents).catch(() => setAgents([])); + }, [agentMenuOpen, props.listAgents]); + + useEffect(() => { + setSkills(props.skills ?? []); + }, [props.skills]); + + useEffect(() => { + setMcpServers(props.mcpServers ?? []); + setMcpStatus(props.mcpStatus ?? null); + setMcpStatuses(props.mcpStatuses ?? {}); + }, [props.mcpServers, props.mcpStatus, props.mcpStatuses]); + + useEffect(() => { + setImportedPlugins(props.importedPlugins ?? []); + }, [props.importedPlugins]); + + useEffect(() => { + setAgentMenuIndex(0); + }, [agentMenuOpen]); + + useEffect(() => { + const target = agentItemRefs.current[agentMenuIndex]; + target?.scrollIntoView({ block: "nearest" }); + }, [agentMenuIndex, agentMenuOpen]); + + useEffect(() => { + commandsLoadVersionRef.current += 1; + commandsCacheRef.current = null; + commandsRequestRef.current = null; + }, [props.listCommands]); + + const loadCommands = useCallback(() => { + if (commandsCacheRef.current !== null) { + return Promise.resolve(commandsCacheRef.current); + } + if (commandsRequestRef.current) { + return commandsRequestRef.current; + } + const version = commandsLoadVersionRef.current; + const request = props.listCommands().then((next) => { + if (commandsLoadVersionRef.current === version) { + commandsCacheRef.current = next; + } + return next; + }).finally(() => { + if (commandsLoadVersionRef.current === version) { + commandsRequestRef.current = null; + } + }); + commandsRequestRef.current = request; + return request; + }, [props.listCommands]); + + useEffect(() => { + if (!slashOpen && !toolMenuOpen) return; + let cancelled = false; + const cached = commandsCacheRef.current; + if (cached !== null) { + setCommands(cached); + setCommandsLoading(false); + return () => { + cancelled = true; + }; + } + setCommandsLoading(true); + void loadCommands() + .then((next) => { + if (!cancelled) setCommands(next); + }) + .catch(() => { + if (!cancelled) setCommands([]); + }) + .finally(() => { + if (!cancelled) setCommandsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [slashOpen, toolMenuOpen, loadCommands]); + + useEffect(() => { + if (!mentionOpen) return; + let cancelled = false; + void Promise.all([props.listAgents(), props.searchFiles(mentionQuery)]).then(([agentList, files]) => { + if (cancelled) return; + const recent = props.recentFiles.slice(0, 8); + const next: MentionItem[] = [ + ...agentList.map((agent) => ({ id: `agent:${agent.name}`, kind: "agent" as const, value: agent.name, label: agent.name })), + ...recent.map((file) => ({ id: `file:${file}`, kind: "file" as const, value: file, label: file })), + ...files.filter((file) => !recent.includes(file)).map((file) => ({ id: `file:${file}`, kind: "file" as const, value: file, label: file })), + ]; + setMentionItems(next); + }).catch(() => { + if (!cancelled) setMentionItems([]); + }); + return () => { + cancelled = true; + }; + }, [mentionOpen, mentionQuery, props.listAgents, props.recentFiles, props.searchFiles]); + + useEffect(() => { + if (!toolMenuOpen) return; + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (toolMenuRef.current?.contains(target)) return; + setToolMenuOpen(false); + }; + window.addEventListener("mousedown", handlePointerDown); + return () => { + window.removeEventListener("mousedown", handlePointerDown); + }; + }, [toolMenuOpen]); + + useEffect(() => { + if (!variantMenuOpen) return; + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (variantMenuRef.current?.contains(target)) return; + setVariantMenuOpen(false); + }; + window.addEventListener("mousedown", handlePointerDown); + return () => { + window.removeEventListener("mousedown", handlePointerDown); + }; + }, [variantMenuOpen]); + + useEffect(() => { + if (!agentMenuOpen) return; + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (agentMenuRef.current?.contains(target)) return; + setAgentMenuOpen(false); + }; + window.addEventListener("mousedown", handlePointerDown); + return () => { + window.removeEventListener("mousedown", handlePointerDown); + }; + }, [agentMenuOpen]); + + useEffect(() => { + if (!toolMenuOpen) return; + if (props.listImportedPlugins) { + let cancelled = false; + setPluginsLoading(true); + void props.listImportedPlugins() + .then((next) => { + if (!cancelled) setImportedPlugins(next); + }) + .catch(() => { + if (!cancelled) setImportedPlugins([]); + }) + .finally(() => { + if (!cancelled) setPluginsLoading(false); + }); + return () => { + cancelled = true; + }; + } + return undefined; + }, [toolMenuOpen, props.listImportedPlugins]); + + useEffect(() => { + if (!toolMenuOpen) return; + if (toolMenuSection === "skills" && props.listSkills) { + let cancelled = false; + setSkillsLoading(true); + void props.listSkills() + .then((next) => { + if (!cancelled) setSkills(next); + }) + .catch(() => { + if (!cancelled) setSkills([]); + }) + .finally(() => { + if (!cancelled) setSkillsLoading(false); + }); + return () => { + cancelled = true; + }; + } + if (toolMenuSection === "mcps" && props.listMcp) { + let cancelled = false; + setMcpLoading(true); + void props.listMcp() + .then((next) => { + if (cancelled) return; + setMcpServers(next.servers); + setMcpStatuses(next.statuses); + setMcpStatus(next.status); + }) + .catch(() => { + if (cancelled) return; + setMcpServers([]); + setMcpStatuses({}); + }) + .finally(() => { + if (!cancelled) setMcpLoading(false); + }); + return () => { + cancelled = true; + }; + } + return undefined; + }, [toolMenuOpen, toolMenuSection, props.listSkills, props.listMcp]); + + const slashFiltered = useMemo(() => { + if (!slashOpen) return []; + if (!slashQuery) return commands.slice(0, 8); + return fuzzysort.go(slashQuery, commands, { keys: ["name", "description"], limit: 8 }).map((entry) => entry.obj); + }, [commands, slashOpen, slashQuery]); + const mentionFiltered = useMemo(() => { + if (!mentionOpen) return []; + if (!mentionQuery) return mentionItems.slice(0, 8); + return fuzzysort.go(mentionQuery, mentionItems, { keys: ["label"], limit: 8 }).map((entry) => entry.obj); + }, [mentionItems, mentionOpen, mentionQuery]); + const pastedTextTokens = useMemo( + () => props.pastedText.map((item) => ({ label: item.label, lines: item.lines })), + [props.pastedText], + ); + + const activeMenu = slashOpen ? "slash" : mentionOpen ? "mention" : null; + const activeItems = activeMenu === "slash" ? slashFiltered : activeMenu === "mention" ? mentionFiltered : []; + const toolCommandItems = commands.filter((command) => !command.source || command.source === "command"); + const toolSkillItems = commands.filter((command) => command.source === "skill"); + const toolMcpItems = commands.filter((command) => command.source === "mcp"); + void toolMcpItems; + const pluginSections = importedPlugins + .filter((plugin) => plugin.files.length > 0) + .map((plugin) => ({ section: `plugin:${plugin.pluginId}` as const, plugin })); + const activePlugin = toolMenuSection.startsWith("plugin:") + ? pluginSections.find((entry) => entry.section === toolMenuSection)?.plugin ?? null + : null; + const canSend = props.draft.trim().length > 0 || props.attachments.length > 0; + + useEffect(() => { + if (!toolMenuSection.startsWith("plugin:")) return; + if (activePlugin) return; + setToolMenuSection("commands"); + }, [activePlugin, toolMenuSection]); + + useEffect(() => { + if (!activeItems.length) { + setMenuIndex(0); + return; + } + setMenuIndex((current) => Math.max(0, Math.min(current, activeItems.length - 1))); + }, [activeItems.length]); + + useEffect(() => { + menuItemRefs.current.length = activeItems.length; + const target = menuItemRefs.current[menuIndex]; + target?.scrollIntoView({ block: "nearest" }); + }, [menuIndex, activeItems.length]); + + const applyCommandSelection = (command: SlashCommandOption) => { + props.onDraftChange(`/${command.name} `); + setSlashOpen(false); + setToolMenuOpen(false); + }; + + const applyPluginFileSelection = (file: CloudImportedPluginFile) => { + const commandName = pluginSlashCommandName(file); + if (commandName) { + applyCommandSelection({ + id: `plugin:${file.configObjectId}`, + name: commandName, + source: file.objectType === "skill" ? "skill" : "command", + }); + return; + } + props.onInsertMention("file", file.path); + setToolMenuOpen(false); + }; + + const openToolMenuSettings = () => { + const section: ToolMenuSettingsSection = toolMenuSection === "commands" || toolMenuSection === "skills" || toolMenuSection === "mcps" + ? toolMenuSection + : "plugins"; + props.onOpenSettingsSection?.(section); + }; + + const acceptActiveItem = () => { + if (!activeItems.length) return false; + if (activeMenu === "slash") { + const command = slashFiltered[menuIndex]; + if (!command) return false; + applyCommandSelection(command); + return true; + } + if (activeMenu === "mention") { + const item = mentionFiltered[menuIndex]; + if (!item) return false; + props.onInsertMention(item.kind, item.value); + setMentionOpen(false); + return true; + } + return false; + }; + + // Listen for cross-app focus + draft flush events. The Solid shell uses + // these from deep-link handlers, the command palette, and the browser + // pagehide/beforeunload cycle so no in-flight draft is lost. + useEffect(() => { + const handleFocus = () => { + const root = rootRef.current; + if (!root) return; + const editable = root.querySelector("[contenteditable='true']"); + editable?.focus(); + }; + const handleFlush = () => { + // onDraftChange always runs synchronously on every keystroke, so this + // listener is effectively a hook for the shell to signal "we're about + // to unmount, commit any debounced state". Re-fire with the current + // draft so downstream stores can checkpoint it. + props.onDraftChange(draftRef.current); + }; + window.addEventListener(FOCUS_PROMPT_EVENT, handleFocus); + window.addEventListener(FLUSH_PROMPT_EVENT, handleFlush); + window.addEventListener("beforeunload", handleFlush); + window.addEventListener("pagehide", handleFlush); + return () => { + window.removeEventListener(FOCUS_PROMPT_EVENT, handleFocus); + window.removeEventListener(FLUSH_PROMPT_EVENT, handleFlush); + window.removeEventListener("beforeunload", handleFlush); + window.removeEventListener("pagehide", handleFlush); + }; + }, [props.onDraftChange]); + + const handleKeyDownCapture: React.KeyboardEventHandler = (event) => { + // IME composition guard — block Enter while IME is mid-character. + const imeActive = + imeComposingRef.current || + (event.nativeEvent as KeyboardEvent).isComposing === true || + event.keyCode === 229; + if (event.key === "Enter" && imeActive) { + return; + } + if (agentMenuOpen) { + const total = agents.length + 1; + if (event.key === "ArrowDown") { + event.preventDefault(); + setAgentMenuIndex((current) => (current + 1) % total); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setAgentMenuIndex((current) => (current - 1 + total) % total); + return; + } + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + const selected = agentMenuIndex === 0 ? null : agents[agentMenuIndex - 1]?.name ?? null; + props.onSelectAgent(selected); + setAgentMenuOpen(false); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setAgentMenuOpen(false); + setVariantMenuOpen(false); + return; + } + } + + if (toolMenuOpen && event.key === "Escape") { + event.preventDefault(); + setToolMenuOpen(false); + return; + } + + if (!activeMenu || !activeItems.length) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setMenuIndex((current) => (current + 1) % activeItems.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setMenuIndex((current) => (current - 1 + activeItems.length) % activeItems.length); + return; + } + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + event.stopPropagation(); + void acceptActiveItem(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setSlashOpen(false); + setMentionOpen(false); + } + }; + + const addAttachments = async (inputFiles: File[]) => { + if (!inputFiles.length) return; + if (!props.attachmentsEnabled) { + props.onNotice({ + title: props.attachmentsDisabledReason ?? t("composer.attachments_unavailable", locale), + tone: "warning", + }); + return; + } + + const accepted: File[] = []; + const unsupported: string[] = []; + const oversize: string[] = []; + + for (const original of inputFiles) { + if (!isSupportedAttachmentType(original.type)) { + unsupported.push(original.name || t("composer.file_kind", locale)); + continue; + } + const processed = original.type.startsWith("image/") ? await compressImageFile(original) : original; + if (processed.size > MAX_ATTACHMENT_BYTES) { + oversize.push(processed.name || original.name); + continue; + } + accepted.push(processed); + } + + if (accepted.length) { + props.onAttachFiles(accepted); + props.onNotice({ + title: + accepted.length === 1 + ? t("composer.uploaded_single_file", locale, { name: accepted[0]?.name ?? t("composer.file_kind", locale) }) + : t("composer.uploaded_multiple_files", locale, { count: accepted.length }), + tone: "success", + }); + } + + if (oversize.length) { + props.onNotice({ + title: + oversize.length === 1 + ? t("composer.file_exceeds_limit", locale, { name: oversize[0] }) + : `${oversize.length} files exceed the 8MB limit.`, + tone: "warning", + }); + } + + if (unsupported.length) { + props.onNotice({ + title: + unsupported.length === 1 + ? `${unsupported[0]} · ${t("composer.unsupported_attachment_type", locale)}` + : `${unsupported.length} ${t("composer.unsupported_attachment_type", locale).toLowerCase()}`, + tone: "warning", + }); + } + }; + + const activeMcpItems = mcpServers.map((entry) => ({ + entry, + status: toReactMcpStatus(entry.name, entry, mcpStatuses), + })); + + const panelRoundedClass = + mentionOpen || slashOpen + ? "rounded-t-[18px] border-t-transparent" + : "shadow-[var(--dls-shell-shadow)]"; + + const renderSlashMenu = () => { + if (!slashOpen) return null; + return ( +
+
+
event.preventDefault()} + > + {slashFiltered.length > 0 ? ( +
+ {slashFiltered.map((command, index) => ( + + ))} +
+ ) : ( +
+ {commandsLoading ? t("composer.loading_commands", locale) : t("composer.no_commands", locale)} +
+ )} +
+
+
+ ); + }; + + const renderMentionMenu = () => { + if (!mentionOpen || mentionFiltered.length === 0) return null; + return ( +
+
+
event.preventDefault()} + > +
+ {mentionFiltered.map((item, index) => ( + + ))} +
+
+
+
+ ); + }; + + return ( +
{ + imeComposingRef.current = true; + }} + onCompositionEnd={() => { + imeComposingRef.current = false; + }} + > +
+ {/* Main composer panel */} +
+ + + {renderMentionMenu()} + {renderSlashMenu()} + + {props.attachments.length > 0 ? ( +
+ {props.attachments.map((attachment) => ( +
+ {isImageAttachment(attachment) && attachment.previewUrl ? ( +
+ {attachment.name} +
+ ) : ( + + )} +
+
{attachment.name}
+
+ {isImageAttachment(attachment) ? t("composer.image_kind", locale) : t("composer.file_kind", locale)} + · + {formatBytes(attachment.size)} +
+
+ +
+ ))} +
+ ) : null} + + {/* + The pasted-text chip used to render twice — once inline inside + the Lexical editor (via ComposerPastedTextNode) and again as a + separate rail here above the composer. Keep only the inline + chip; its pill already shows label + line count, and the user + removes it with backspace like any other inline token. + */} + + {dropzoneActive ? ( +
+
+
{t("composer.attach_files", locale)}
+
Images and PDFs are supported.
+
+
+ ) : null} + +
+ {/* Editor */} + { + // Paste policy: + // 1. Actual files on the clipboard -> attach them. + // 2. Explicit text/uri-list (drag from Finder / browser) -> insert links. + // 3. Plain text -> DO NOTHING. Let Lexical's PlainTextPlugin + // handle the paste natively so newlines render correctly + // and no content is silently dropped. Previous behavior + // hijacked pastes that merely contained absolute paths + // like "/Users/..." or pastes longer than 10 lines, which + // was the root cause of "paste into composer is broken". + const files = Array.from(event.clipboardData?.files ?? []); + if (files.length) { + event.preventDefault(); + void addAttachments(files); + return; + } + + const uriList = event.clipboardData + ? parseClipboardUriList(event.clipboardData) + : []; + if (uriList.length) { + event.preventDefault(); + props.onUnsupportedFileLinks(uriList); + props.onNotice({ + title: t("composer.inserted_links_unsupported", locale), + tone: "info", + }); + return; + } + + const text = event.clipboardData?.getData("text/plain") ?? ""; + + // Collapse long pastes into an inline chip. The threshold + // is 3+ lines or 200+ characters — short pastes still go + // straight into the editor as plain text. + const PASTE_CHIP_LINE_THRESHOLD = 3; + const PASTE_CHIP_CHAR_THRESHOLD = 200; + const lineCount = text.split(/\r?\n/).length; + if (text.trim() && (lineCount >= PASTE_CHIP_LINE_THRESHOLD || text.length >= PASTE_CHIP_CHAR_THRESHOLD)) { + event.preventDefault(); + props.onPasteText(text); + return; + } + + if ( + text.trim() && + (props.isRemoteWorkspace || props.isSandboxWorkspace) && + /file:\/\/|(^|\s)\/(Users|home|var|etc|opt|tmp|private|Volumes|Applications)\//.test(text) + ) { + const attachedFiles = props.attachments.map((attachment) => attachment.file); + props.onNotice({ + title: t("composer.remote_worker_paste_warning", locale), + tone: "warning", + actionLabel: + props.onUploadInboxFiles && attachedFiles.length > 0 + ? t("composer.upload_to_shared_folder", locale) + : undefined, + onAction: + props.onUploadInboxFiles && attachedFiles.length > 0 + ? () => void props.onUploadInboxFiles?.(attachedFiles) + : undefined, + }); + // Intentionally no preventDefault — the notice is advisory, + // the paste still goes through the editor. + } + }} + onDragOver={(event) => { + if (event.dataTransfer?.files?.length) { + event.preventDefault(); + if (!dropzoneActive) setDropzoneActive(true); + } + }} + onDragLeave={(event) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setDropzoneActive(false); + }} + onDrop={(event) => { + const files = Array.from(event.dataTransfer?.files ?? []); + setDropzoneActive(false); + if (!files.length) return; + event.preventDefault(); + void addAttachments(files); + }} + /> + + {/* Action row — attach/inbox/tools on the left, send on the right */} +
+
+ { + fileInput = element ?? undefined; + }} + type="file" + multiple + className="hidden" + onChange={(event) => { + const files = Array.from(event.currentTarget.files ?? []); + if (files.length) void addAttachments(files); + event.currentTarget.value = ""; + }} + /> + +
+ + {toolMenuOpen ? ( +
+
+
+ {([ + ["commands", t("dashboard.commands", locale)], + ["skills", t("dashboard.skills", locale)], + ["mcps", t("composer.mcps_label", locale)], + ] as const).map(([section, label]) => ( + + ))} + {pluginSections.length > 0 ?
: null} + {pluginSections.map(({ section, plugin }) => ( + + ))} +
+
+
+ +
+ {toolMenuSection === "commands" ? ( + toolCommandItems.length > 0 ? ( +
+ {toolCommandItems.map((command) => ( + + ))} +
+ ) : ( +
+ {commandsLoading ? t("composer.loading_commands", locale) : t("composer.no_commands", locale)} +
+ ) + ) : null} + {toolMenuSection === "skills" ? ( + (skills.length > 0 || toolSkillItems.length > 0) ? ( +
+ {[...toolSkillItems, ...skills.filter((skill) => !toolSkillItems.some((command) => command.name === skill.name)).map((skill) => ({ id: `skill:${skill.name}`, name: skill.name, description: skill.description, source: "skill" as const }))].map((command) => ( + + ))} +
+ ) : ( +
+ {skillsLoading || commandsLoading ? t("composer.loading_commands", locale) : t("context_panel.no_skills", locale)} +
+ ) + ) : null} + {toolMenuSection === "mcps" ? ( + activeMcpItems.length > 0 ? ( +
+ {activeMcpItems.map(({ entry, status }) => ( +
+ +
+
+
{entry.name}
+ + {formatMcpStatusLabel(status, locale)} + +
+
{entry.config.type === "remote" ? entry.config.url ?? entry.config.command?.join(" ") ?? "Remote MCP" : entry.config.command?.join(" ") ?? "Local MCP"}
+
+
+ ))} +
+ ) : ( +
+ {mcpLoading ? t("composer.loading_commands", locale) : (mcpStatus ?? t("context_panel.no_mcp", locale))} +
+ ) + ) : null} + {activePlugin ? ( + activePlugin.files.length > 0 ? ( +
+ {activePlugin.files.map((file) => ( + + ))} +
+ ) : ( +
No plugin files imported yet.
+ ) + ) : toolMenuSection.startsWith("plugin:") ? ( +
+ {pluginsLoading ? t("composer.loading_commands", locale) : "Plugin files are unavailable."} +
+ ) : null} +
+
+
+ ) : null} +
+
+ + {/* + Single action button that toggles between Stop and Run task. + When busy with no draft: Stop (cancels current run). + When busy with a draft: Run task (queues a follow-up). + When idle: Run task. + */} +
+ {props.busy && !canSend ? ( + + ) : ( + + )} +
+
+
+
+ + {/* Below-panel control strip: agent + model + behavior variant */} +
+
+
+ + {agentMenuOpen ? ( +
+
+ {t("composer.agent_label", locale)} +
+
event.preventDefault()} + > + + {agents.map((agent, index) => { + const active = props.selectedAgent === agent.name; + return ( + + ); + })} +
+
+ ) : null} +
+ + + + {props.modelBehaviorOptions?.length ? ( +
+ + {variantMenuOpen ? ( +
+
+ {t("composer.behavior_label", locale)} +
+
+ {props.modelBehaviorOptions.map((option) => { + // Highlight the row whose label matches the pill. When + // modelVariant is null but the provider-default is + // e.g. "medium", the "medium" row should render as + // selected — user sees the actual active mode. + const isActive = + props.modelVariant === option.value || + (props.modelVariant == null && option.label === props.modelVariantLabel); + return ( + + ); + })} +
+
+ ) : null} +
+ ) : null} +
+ + {/* Status label removed — redundant with the footer bar */} +
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/surface/composer/editor.tsx b/apps/app/src/react-app/domains/session/surface/composer/editor.tsx new file mode 100644 index 0000000000..2ed8060918 --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/composer/editor.tsx @@ -0,0 +1,692 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer.js"; +import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin.js"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable.js"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary.js"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin.js"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin.js"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext.js"; +import { + $applyNodeReplacement, + $createRangeSelection, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $setSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_HIGH, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_ENTER_COMMAND, + PASTE_COMMAND, + type SerializedTextNode, + type Spread, + TextNode, + type EditorConfig, + type NodeKey, +} from "lexical"; +import type { InitialConfigType } from "@lexical/react/LexicalComposer.js"; + +type EditorProps = { + value: string; + mentions: Record; + pastedText?: Array<{ label: string; lines: number }>; + disabled: boolean; + placeholder: string; + onChange: (value: string) => void; + onSubmit: () => void | Promise; + onPaste?: React.ClipboardEventHandler; + onPasteText?: (text: string) => void; + onDrop?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragLeave?: React.DragEventHandler; +}; + +type SerializedComposerMentionNode = Spread< + { + mentionValue: string; + mentionKind: "agent" | "file"; + type: "composer-mention"; + version: 1; + }, + SerializedTextNode +>; + +type SerializedComposerSlashCommandNode = Spread< + { + commandName: string; + type: "composer-slash-command"; + version: 1; + }, + SerializedTextNode +>; + +class ComposerMentionNode extends TextNode { + __value: string; + __kind: "agent" | "file"; + + static override getType() { + return "composer-mention"; + } + + static override clone(node: ComposerMentionNode) { + return new ComposerMentionNode(node.__value, node.__kind, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerMentionNode) { + return $createComposerMentionNode(serializedNode.mentionValue, serializedNode.mentionKind); + } + + constructor(value = "", kind: "agent" | "file" = "file", key?: NodeKey) { + super(`@${value}`, key); + this.__value = value; + this.__kind = kind; + } + + override exportJSON(): SerializedComposerMentionNode { + return { + ...super.exportJSON(), + mentionValue: this.__value, + mentionKind: this.__kind, + type: "composer-mention", + version: 1, + }; + } + + override createDOM(_config: EditorConfig) { + const dom = document.createElement("span"); + const isFile = this.__kind === "file"; + dom.className = isFile + ? "inline-flex items-center rounded-full border border-gray-6 bg-gray-3 px-2.5 py-1 text-xs font-medium text-gray-11" + : "inline-flex items-center rounded-full border border-sky-6/35 bg-sky-3/20 px-2.5 py-1 text-xs font-medium text-sky-11"; + dom.textContent = `@${isFile ? this.__value.split(/[\\/]/).pop() || this.__value : this.__value}`; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + dom.title = `@${this.__value}`; + return dom; + } + + override updateDOM(prevNode: ComposerMentionNode, dom: HTMLElement) { + if (prevNode.__value !== this.__value || prevNode.__kind !== this.__kind) { + const isFile = this.__kind === "file"; + dom.className = isFile + ? "inline-flex items-center rounded-full border border-gray-6 bg-gray-3 px-2.5 py-1 text-xs font-medium text-gray-11" + : "inline-flex items-center rounded-full border border-sky-6/35 bg-sky-3/20 px-2.5 py-1 text-xs font-medium text-sky-11"; + dom.textContent = `@${isFile ? this.__value.split(/[\\/]/).pop() || this.__value : this.__value}`; + dom.title = `@${this.__value}`; + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerMentionNode(value: string, kind: "agent" | "file") { + return $applyNodeReplacement(new ComposerMentionNode(value, kind)); +} + +class ComposerSlashCommandNode extends TextNode { + __commandName: string; + + static override getType() { + return "composer-slash-command"; + } + + static override clone(node: ComposerSlashCommandNode) { + return new ComposerSlashCommandNode(node.__commandName, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerSlashCommandNode) { + return $createComposerSlashCommandNode(serializedNode.commandName); + } + + constructor(commandName = "", key?: NodeKey) { + super(`/${commandName}`, key); + this.__commandName = commandName; + } + + override exportJSON(): SerializedComposerSlashCommandNode { + return { + ...super.exportJSON(), + commandName: this.__commandName, + type: "composer-slash-command", + version: 1, + }; + } + + override createDOM(_config: EditorConfig) { + const dom = document.createElement("span"); + dom.className = "inline-flex items-center rounded-full border border-violet-6/35 bg-violet-3/20 px-2.5 py-1 text-xs font-medium text-violet-11"; + dom.textContent = `/${this.__commandName}`; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + dom.title = `/${this.__commandName}`; + return dom; + } + + override updateDOM(prevNode: ComposerSlashCommandNode, dom: HTMLElement) { + if (prevNode.__commandName !== this.__commandName) { + dom.textContent = `/${this.__commandName}`; + dom.title = `/${this.__commandName}`; + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerSlashCommandNode(commandName: string) { + return $applyNodeReplacement(new ComposerSlashCommandNode(commandName)); +} + +type SerializedComposerPastedTextNode = Spread< + { + pastedLabel: string; + pastedLines: number; + type: "composer-pasted-text"; + version: 1; + }, + SerializedTextNode +>; + +class ComposerPastedTextNode extends TextNode { + __pastedLabel: string; + __pastedLines: number; + + static override getType() { + return "composer-pasted-text"; + } + + static override clone(node: ComposerPastedTextNode) { + return new ComposerPastedTextNode(node.__pastedLabel, node.__pastedLines, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerPastedTextNode) { + return $createComposerPastedTextNode(serializedNode.pastedLabel, serializedNode.pastedLines); + } + + constructor(label = "", lines = 0, key?: NodeKey) { + super(`[pasted text ${label}]`, key); + this.__pastedLabel = label; + this.__pastedLines = lines; + } + + override exportJSON(): SerializedComposerPastedTextNode { + return { + ...super.exportJSON(), + pastedLabel: this.__pastedLabel, + pastedLines: this.__pastedLines, + type: "composer-pasted-text", + version: 1, + }; + } + + override createDOM(_config: EditorConfig) { + const dom = document.createElement("span"); + dom.className = "inline-flex items-center gap-1 rounded-full border border-amber-6/35 bg-amber-3/15 px-2.5 py-1 text-xs font-medium text-amber-11"; + dom.textContent = `Pasted · ${this.__pastedLines} line${this.__pastedLines === 1 ? "" : "s"}`; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + dom.title = `Pasted text · ${this.__pastedLabel}`; + return dom; + } + + override updateDOM(prevNode: ComposerPastedTextNode, dom: HTMLElement) { + if (prevNode.__pastedLabel !== this.__pastedLabel || prevNode.__pastedLines !== this.__pastedLines) { + dom.textContent = `Pasted · ${this.__pastedLines} line${this.__pastedLines === 1 ? "" : "s"}`; + dom.title = `Pasted text · ${this.__pastedLabel}`; + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerPastedTextNode(label: string, lines: number) { + return $applyNodeReplacement(new ComposerPastedTextNode(label, lines)); +} + +type ComposerInlineTokenNode = ComposerMentionNode | ComposerSlashCommandNode | ComposerPastedTextNode; + +function setSelectionAfterNode(node: ComposerInlineTokenNode) { + const parent = node.getParent(); + if (!parent || !$isElementNode(parent)) return; + const selection = $createRangeSelection(); + const offset = node.getIndexWithinParent() + 1; + selection.anchor.set(parent.getKey(), offset, "element"); + selection.focus.set(parent.getKey(), offset, "element"); + $setSelection(selection); +} + +function setSelectionBeforeNode(node: ComposerInlineTokenNode) { + const parent = node.getParent(); + if (!parent || !$isElementNode(parent)) return; + const selection = $createRangeSelection(); + const offset = node.getIndexWithinParent(); + selection.anchor.set(parent.getKey(), offset, "element"); + selection.focus.set(parent.getKey(), offset, "element"); + $setSelection(selection); +} + +function appendSegmentWithNewlines( + paragraph: ReturnType, + segment: string, +) { + // Preserve newlines in plain text segments. A single paragraph cannot + // render "\n" as a line break in contenteditable, so we split on "\n" + // and start a new paragraph per line. Return the paragraph the caller + // should keep appending to (i.e. the last one we produced). + if (!segment.includes("\n")) { + paragraph.append($createTextNode(segment)); + return paragraph; + } + const lines = segment.split("\n"); + let current = paragraph; + lines.forEach((line, index) => { + if (index > 0) { + const next = $createParagraphNode(); + current.insertAfter(next); + current = next; + } + if (line.length > 0) { + current.append($createTextNode(line)); + } + }); + return current; +} + +function setPrompt(value: string, mentions: Record, pastedText?: Array<{ label: string; lines: number }>) { + const root = $getRoot(); + root.clear(); + let paragraph = $createParagraphNode(); + root.append(paragraph); + + const slashMatch = value.match(/^\/(\S+)\s(.*)$/s); + if (slashMatch?.[1]) { + paragraph.append($createComposerSlashCommandNode(slashMatch[1])); + paragraph.append($createTextNode(" ")); + value = slashMatch[2] ?? ""; + } + + const segments = value.split(/(\[pasted text [^\]]+\]|@[^\s@]+)/); + for (const segment of segments) { + if (!segment) continue; + const pasteMatch = segment.match(/^\[pasted text (.+)\]$/); + if (pasteMatch?.[1]) { + const target = pastedText?.find((item) => item.label === pasteMatch[1]); + if (target) { + paragraph.append($createComposerPastedTextNode(target.label, target.lines)); + continue; + } + } + if (segment.startsWith("@")) { + const token = segment.slice(1); + const kind = mentions[token]; + if (kind) { + paragraph.append($createComposerMentionNode(token, kind)); + continue; + } + } + paragraph = appendSegmentWithNewlines(paragraph, segment); + } +} + +// Serialize the current editor state to the external draft string. Lexical's +// root.getTextContent() joins element children with "\n\n" (its "text content +// mode" for the root node), which causes single newlines typed/pasted by the +// user to round-trip as double newlines and quickly corrupts the draft. We +// walk root children ourselves and join with a single "\n" so every newline +// the user sees onscreen is preserved exactly in the stored draft. +function serializePromptFromRoot(): string { + const root = $getRoot(); + return root + .getChildren() + .map((child) => child.getTextContent()) + .join("\n"); +} + +function SyncPlugin(props: { value: string; mentions: Record; pastedText?: Array<{ label: string; lines: number }>; disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + const valueRef = useRef(props.value); + + useEffect(() => { + editor.setEditable(!props.disabled); + }, [editor, props.disabled]); + + useEffect(() => { + // When the external value is cleared (e.g. after sending a message), + // always force-rebuild the editor to remove any stale chip nodes. + // The valueRef check can false-positive when both refs converge to "" + // through different paths (SyncPlugin vs OnChange). + // + // NOTE: serializePromptFromRoot() calls $getRoot() which requires an + // active editor state. Outside of editor.update()/editor.read() we + // must wrap it in editor.getEditorState().read(). + const currentText = editor.getEditorState().read(() => serializePromptFromRoot()); + const forceRebuild = !props.value.trim() && currentText.trim() !== ""; + if (!forceRebuild && valueRef.current === props.value) return; + valueRef.current = props.value; + editor.update(() => { + if (!forceRebuild && serializePromptFromRoot() === props.value) return; + setPrompt(props.value, props.mentions, props.pastedText); + $getRoot().selectEnd(); + }); + }, [editor, props.mentions, props.pastedText, props.value]); + + return null; +} + +function SubmitPlugin(props: { onSubmit: () => void | Promise; disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + const onSubmitRef = useRef(props.onSubmit); + + useEffect(() => { + onSubmitRef.current = props.onSubmit; + }, [props.onSubmit]); + + useEffect(() => { + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent | null) => { + if (props.disabled) return false; + // IME composition guard: three signals keep this reliable across + // Chrome, Safari, and WebKit. While IME is mid-character, Enter + // must always fall through to the editor so the composition can + // commit. + if (event?.isComposing === true || event?.keyCode === 229) return false; + // Shift+Enter inserts a newline — let the editor handle it. + if (event?.shiftKey) return false; + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + // Plain Enter submits. Cmd/Ctrl+Enter also submits for muscle + // memory compatibility. + event?.preventDefault(); + void onSubmitRef.current(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, props.disabled]); + + return null; +} + +const PASTE_CHIP_LINE_THRESHOLD = 3; +const PASTE_CHIP_CHAR_THRESHOLD = 200; + +function PasteChipPlugin(props: { onPasteText?: (text: string) => void }) { + const [editor] = useLexicalComposerContext(); + const onPasteTextRef = useRef(props.onPasteText); + + useEffect(() => { + onPasteTextRef.current = props.onPasteText; + }, [props.onPasteText]); + + useEffect(() => { + return editor.registerCommand( + PASTE_COMMAND, + (event: ClipboardEvent) => { + if (!onPasteTextRef.current) return false; + // Only handle plain-text pastes; files are handled in the React onPaste. + const files = event.clipboardData?.files; + if (files && files.length > 0) return false; + const text = event.clipboardData?.getData("text/plain") ?? ""; + if (!text.trim()) return false; + const lineCount = text.split(/\r?\n/).length; + if (lineCount < PASTE_CHIP_LINE_THRESHOLD && text.length < PASTE_CHIP_CHAR_THRESHOLD) { + return false; + } + // Collapse into a paste chip. + event.preventDefault(); + onPasteTextRef.current(text); + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); + + return null; +} + +function MentionChipNavigationPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const unregisterBackspace = editor.registerCommand( + KEY_BACKSPACE_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; + const anchorNode = selection.anchor.getNode(); + + // --- Slash command chip: atomic delete --- + // When cursor is in the text node right after a slash chip, + // remove the chip (and any trailing whitespace text) in one action. + if ($isTextNode(anchorNode)) { + const previous = anchorNode.getPreviousSibling(); + if (previous instanceof ComposerSlashCommandNode) { + // At offset 0: cursor is right after the chip -> remove chip + // At offset > 0 but text is only whitespace: also remove chip + const textBefore = anchorNode.getTextContent().slice(0, selection.anchor.offset); + if (selection.anchor.offset === 0 || textBefore.trim() === "") { + previous.remove(); + // Also remove the whitespace-only prefix + if (selection.anchor.offset > 0) { + const remaining = anchorNode.getTextContent().slice(selection.anchor.offset); + if (remaining) { + anchorNode.setTextContent(remaining); + const sel = $createRangeSelection(); + sel.anchor.set(anchorNode.getKey(), 0, "text"); + sel.focus.set(anchorNode.getKey(), 0, "text"); + $setSelection(sel); + } else { + anchorNode.remove(); + } + } + return true; + } + } + } + + // --- Mention / pasted-text chips: atomic delete (same as before) --- + if ($isTextNode(anchorNode) && selection.anchor.offset === 0) { + const previous = anchorNode.getPreviousSibling(); + if (previous instanceof ComposerMentionNode || previous instanceof ComposerPastedTextNode) { + previous.remove(); + return true; + } + } + + if ($isElementNode(anchorNode)) { + const previous = anchorNode.getChildAtIndex(selection.anchor.offset - 1); + if (previous instanceof ComposerSlashCommandNode || previous instanceof ComposerMentionNode || previous instanceof ComposerPastedTextNode) { + previous.remove(); + return true; + } + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + + const unregisterLeft = editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; + const anchorNode = selection.anchor.getNode(); + + if ($isTextNode(anchorNode) && selection.anchor.offset === 0) { + const previous = anchorNode.getPreviousSibling(); + if (previous instanceof ComposerMentionNode || previous instanceof ComposerSlashCommandNode || previous instanceof ComposerPastedTextNode) { + setSelectionBeforeNode(previous); + return true; + } + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + + const unregisterRight = editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; + const anchorNode = selection.anchor.getNode(); + + if (anchorNode instanceof ComposerMentionNode || anchorNode instanceof ComposerSlashCommandNode || anchorNode instanceof ComposerPastedTextNode) { + setSelectionAfterNode(anchorNode); + return true; + } + + if ($isElementNode(anchorNode)) { + const current = anchorNode.getChildAtIndex(selection.anchor.offset); + if (current instanceof ComposerMentionNode || current instanceof ComposerSlashCommandNode || current instanceof ComposerPastedTextNode) { + setSelectionAfterNode(current); + return true; + } + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + + return () => { + unregisterBackspace(); + unregisterLeft(); + unregisterRight(); + }; + }, [editor]); + + return null; +} + +export function LexicalPromptEditor(props: EditorProps) { + const valueRef = useRef(props.value); + const onChangeRef = useRef(props.onChange); + + useEffect(() => { + valueRef.current = props.value; + }, [props.value]); + + useEffect(() => { + onChangeRef.current = props.onChange; + }, [props.onChange]); + + const initialConfig = useMemo( + () => ({ + namespace: "openwork-react-session-composer", + onError(error: Error) { + throw error; + }, + editable: !props.disabled, + nodes: [ComposerMentionNode, ComposerSlashCommandNode, ComposerPastedTextNode], + editorState: () => { + setPrompt(props.value, props.mentions, props.pastedText); + }, + }), + [], + ); + + const handleChange = useCallback( + (state: Parameters["onChange"]>>[0]) => { + state.read(() => { + const next = serializePromptFromRoot(); + if (next === valueRef.current) return; + valueRef.current = next; + onChangeRef.current(next); + }); + }, + [], + ); + + return ( + + {/* + Tight start, bounded growth: + - min-h holds the editor to a single-line look until the user starts typing. + - max-h caps the composer — long pastes / multi-paragraph drafts scroll + inside the editor instead of pushing the transcript out of view. + */} +
+ } + onPaste={props.onPaste} + onDrop={props.onDrop} + onDragOver={props.onDragOver} + onDragLeave={props.onDragLeave} + /> + } + placeholder={ +
+ {props.placeholder} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + +
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/surface/composer/notice.tsx b/apps/app/src/react-app/domains/session/surface/composer/notice.tsx new file mode 100644 index 0000000000..77f93452a8 --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/composer/notice.tsx @@ -0,0 +1,48 @@ +/** @jsxImportSource react */ + +export type ReactComposerNotice = { + title: string; + description?: string | null; + tone?: "info" | "success" | "warning" | "error"; + actionLabel?: string; + onAction?: () => void; +}; + +export function ReactComposerNotice(props: { notice: ReactComposerNotice | null }) { + const tone = props.notice?.tone ?? "info"; + if (!props.notice) return null; + + const toneClass = + tone === "success" + ? "border-emerald-6/40 bg-emerald-4/80 text-emerald-11" + : tone === "warning" + ? "border-amber-6/40 bg-amber-4/80 text-amber-11" + : tone === "error" + ? "border-red-6/40 bg-red-4/80 text-red-11" + : "border-sky-6/40 bg-sky-4/80 text-sky-11"; + + return ( +
+
+
+ {tone === "success" ? "✓" : tone === "warning" ? "!" : tone === "error" ? "×" : "i"} +
+
+
{props.notice.title}
+ {props.notice.description?.trim() ? ( +

{props.notice.description}

+ ) : null} + {props.notice.actionLabel && props.notice.onAction ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/surface/debug-panel.tsx b/apps/app/src/react-app/domains/session/surface/debug-panel.tsx new file mode 100644 index 0000000000..ba39cbb31f --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/debug-panel.tsx @@ -0,0 +1,23 @@ +/** @jsxImportSource react */ +import type { OpenworkSessionSnapshot } from "../../../../app/lib/openwork-server"; +import type { SessionRenderModel } from "../sync/transition-controller"; + +export function SessionDebugPanel(props: { + model: SessionRenderModel; + snapshot: OpenworkSessionSnapshot | null; +}) { + return ( +
+
React Session Debug
+
+
intendedSessionId: {props.model.intendedSessionId || "-"}
+
renderedSessionId: {props.model.renderedSessionId || "-"}
+
transitionState: {props.model.transitionState}
+
renderSource: {props.model.renderSource}
+
status: {props.snapshot?.status.type ?? "-"}
+
messages: {props.snapshot?.messages.length ?? 0}
+
todos: {props.snapshot?.todos.length ?? 0}
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/surface/markdown.tsx b/apps/app/src/react-app/domains/session/surface/markdown.tsx new file mode 100644 index 0000000000..0119d0a1bd --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/markdown.tsx @@ -0,0 +1,141 @@ +/** @jsxImportSource react */ +import { memo, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import type { Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Streamdown } from "streamdown"; + +import { applyTextHighlights } from "./text-highlights"; + +function MarkdownCodeBlock(props: { className?: string; children: React.ReactNode }) { + const text = Array.isArray(props.children) ? props.children.join("") : String(props.children ?? ""); + const [copied, setCopied] = useState(false); + + return ( +
+
+ +
+
+        {props.children}
+      
+
+ ); +} + +const markdownComponents: Components = { + a({ href, children }) { + return ( + + {children} + + ); + }, + pre({ children }) { + return ( +
+        {children}
+      
+ ); + }, + code({ className, children }) { + const isBlock = Boolean(className?.includes("language-")); + if (isBlock) { + return {children}; + } + return ( + + {children} + + ); + }, + blockquote({ children }) { + return
{children}
; + }, + table({ children }) { + return {children}
; + }, + th({ children }) { + return {children}; + }, + td({ children }) { + return {children}; + }, + hr() { + return
; + }, +}; + +const markdownClassName = `markdown-content max-w-none text-gray-12 + [&_strong]:font-semibold + [&_em]:italic + [&_h1]:my-5 [&_h1]:text-xl [&_h1]:font-semibold + [&_h2]:my-4 [&_h2]:text-lg [&_h2]:font-semibold + [&_h3]:my-3 [&_h3]:text-base [&_h3]:font-semibold + [&_p]:my-3 [&_p]:leading-relaxed + [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-6 + [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-6 + [&_li]:my-1 +`.trim(); + +function MarkdownBlockInner(props: { + text: string; + streaming?: boolean; + highlightQuery?: string; +}) { + const rootRef = useRef(null); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + queueMicrotask(() => { + if (!rootRef.current || rootRef.current !== root) return; + applyTextHighlights(root, props.highlightQuery ?? ""); + }); + }, [props.highlightQuery, props.streaming, props.text]); + + if (!props.text.trim()) return null; + + if (props.streaming) { + return ( +
+ + {props.text} + +
+ ); + } + + return ( +
+ + {props.text} + +
+ ); +} + +/** + * Memoize so a message block that has already been rendered — the usual + * case for every assistant bubble above the currently-streaming one — + * doesn't re-parse its markdown on every token. Only re-renders when its + * own text / streaming / highlightQuery props change. + */ +export const MarkdownBlock = memo(MarkdownBlockInner); +MarkdownBlock.displayName = "MarkdownBlock"; diff --git a/apps/app/src/react-app/domains/session/surface/message-list.tsx b/apps/app/src/react-app/domains/session/surface/message-list.tsx new file mode 100644 index 0000000000..49c518c4cd --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/message-list.tsx @@ -0,0 +1,1167 @@ +/** @jsxImportSource react */ +import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { isToolUIPart, type DynamicToolUIPart, type UIMessage } from "ai"; +import type { Part } from "@opencode-ai/sdk/v2/client"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Check, ChevronDown, CircleAlert, Copy, File as FileIcon } from "lucide-react"; + +import { openDesktopPath, revealDesktopItemInDir } from "../../../../app/lib/desktop"; +import { + SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, + type MessageGroup, + type StepGroupMode, +} from "../../../../app/types"; +import { groupMessageParts, isDesktopRuntime, summarizeStep } from "../../../../app/utils"; +import { MarkdownBlock } from "./markdown"; +import { applyTextHighlights } from "./text-highlights"; + +type TranscriptPart = Part; + +type TranscriptMessage = { + id: string; + role: UIMessage["role"]; + source: UIMessage; + parts: TranscriptPart[]; +}; + +type StepTimelineGroup = { + id: string; + parts: TranscriptPart[]; + mode: StepGroupMode; +}; + +type StepClusterBlock = { + kind: "steps-cluster"; + id: string; + stepGroups: StepTimelineGroup[]; + messageIds: string[]; + isUser: boolean; +}; + +type MessageBlock = { + kind: "message"; + message: UIMessage; + renderableParts: TranscriptPart[]; + attachments: Array<{ + url: string; + filename: string; + mime: string; + }>; + groups: MessageGroup[]; + isUser: boolean; + messageId: string; +}; + +type MessageBlockItem = MessageBlock | StepClusterBlock; + +/** + * Stable-key used to match a block across renders. For message blocks the + * messageId is stable. For step clusters we reuse the cluster id (which is + * derived from its first step group) as the identity anchor. + */ +function blockIdentityKey(block: MessageBlockItem): string { + if (block.kind === "steps-cluster") return `cluster:${block.id}`; + return `msg:${block.messageId}`; +} + +/** + * Returns true when a newly-computed block is content-equivalent to the + * previous block we rendered under the same identity key. We compare the + * underlying UIMessage reference (`message.source`) for message blocks and + * the messageIds array + stepGroups identity for step clusters. If equal, + * the caller reuses the previous block reference so React.memo'd children + * downstream can skip work. + * + * This is the structural-sharing trick from T3Tools' MessagesTimeline: on + * every streaming token, `props.messages` is a fresh array, but only the + * *currently-streaming* message has a new `source` reference — everything + * else is still pointer-equal to last tick. Rebuilding blocks from the new + * array gives fresh block objects for every message, so downstream memo + * checks all fail by default. Reusing the previous block reference when + * its content hasn't actually changed gives every non-streaming row a free + * bailout during a streaming burst. + */ +function blocksAreEquivalent( + previous: MessageBlockItem | undefined, + next: MessageBlockItem, +): boolean { + if (!previous) return false; + if (previous.kind !== next.kind) return false; + if (previous.isUser !== next.isUser) return false; + + if (previous.kind === "steps-cluster" && next.kind === "steps-cluster") { + if (previous.id !== next.id) return false; + if (previous.messageIds.length !== next.messageIds.length) return false; + for (let i = 0; i < previous.messageIds.length; i += 1) { + if (previous.messageIds[i] !== next.messageIds[i]) return false; + } + if (previous.stepGroups.length !== next.stepGroups.length) return false; + for (let i = 0; i < previous.stepGroups.length; i += 1) { + const prevGroup = previous.stepGroups[i]; + const nextGroup = next.stepGroups[i]; + if (!prevGroup || !nextGroup) return false; + if (prevGroup.id !== nextGroup.id) return false; + if (prevGroup.mode !== nextGroup.mode) return false; + if (prevGroup.parts.length !== nextGroup.parts.length) return false; + for (let p = 0; p < prevGroup.parts.length; p += 1) { + if (prevGroup.parts[p] !== nextGroup.parts[p]) return false; + } + } + return true; + } + + if (previous.kind === "message" && next.kind === "message") { + if (previous.messageId !== next.messageId) return false; + // The single most important check. The session sync layer keeps + // UIMessage references stable for every non-streaming message across + // rerenders; only the actively-streaming message gets a fresh + // `source` reference per token. If the source is pointer-equal, the + // block hasn't changed and we can reuse the previous object. + if (previous.message !== next.message) return false; + if (previous.attachments.length !== next.attachments.length) return false; + if (previous.renderableParts.length !== next.renderableParts.length) return false; + if (previous.groups.length !== next.groups.length) return false; + return true; + } + + return false; +} + +type SessionTranscriptProps = { + messages: UIMessage[]; + isStreaming: boolean; + developerMode: boolean; + showThinking?: boolean; + expandedStepIds?: Set; + onExpandedStepIdsChange?: (updater: (current: Set) => Set) => void; + searchMatchMessageIds?: ReadonlySet; + activeSearchMessageId?: string | null; + searchHighlightQuery?: string; + scrollElement?: () => HTMLElement | null | undefined; + setScrollToMessageById?: ( + handler: ((messageId: string, behavior?: ScrollBehavior) => boolean) | null, + ) => void; + footer?: ReactNode; + variant?: "default" | "nested"; +}; + +// 500 was too high for real-world OpenWork sessions: a handful of giant +// messages (emails, legal docs, pasted transcripts) can still produce a +// massive DOM even when the block count is low. Lowering the threshold means +// we switch to react-virtual much earlier and keep the main thread lighter +// during workspace/session switches. +// Virtualize aggressively. A session with 20+ message blocks already pays +// more to render eagerly than to run the virtualizer, so there's no reason +// to defer. The only reason the threshold exists at all is to avoid the +// virtualizer's baseline overhead for tiny sessions. +const VIRTUALIZATION_THRESHOLD = 20; +const VIRTUAL_OVERSCAN = 4; + +function partIdFromUiPart(part: UIMessage["parts"][number], fallbackId: string) { + const metadata = (part as { providerMetadata?: { opencode?: { partId?: unknown } } }) + .providerMetadata?.opencode; + if (typeof metadata?.partId === "string" && metadata.partId.trim()) { + return metadata.partId; + } + return fallbackId; +} + +function toDynamicToolPart(part: UIMessage["parts"][number]) { + if (part.type === "dynamic-tool") { + return part; + } + if (!isToolUIPart(part)) return null; + return { + ...part, + toolName: part.type.replace(/^tool-/, ""), + type: "dynamic-tool", + } as DynamicToolUIPart; +} + +function toLegacyPart( + part: UIMessage["parts"][number], + fallbackId: string, +): TranscriptPart | null { + const id = partIdFromUiPart(part, fallbackId); + + if (part.type === "text") { + return { id, type: "text", text: part.text } as TranscriptPart; + } + + if (part.type === "reasoning") { + return { id, type: "reasoning", text: part.text } as TranscriptPart; + } + + if (part.type === "file") { + return { + id, + type: "file", + url: part.url, + filename: part.filename, + mime: part.mediaType, + } as TranscriptPart; + } + + if (part.type === "step-start") { + return { id, type: "step-start" } as TranscriptPart; + } + + const toolPart = toDynamicToolPart(part); + if (toolPart) { + const state: Record = { + input: toolPart.input, + }; + + if (toolPart.state === "output-available") { + state.output = toolPart.output; + } + + if (toolPart.state === "output-error") { + state.error = toolPart.errorText; + } + + return { + id: toolPart.toolCallId || id, + type: "tool", + tool: toolPart.toolName, + state, + } as TranscriptPart; + } + + return null; +} + +function isAttachmentPart(part: TranscriptPart) { + if (part.type !== "file") return false; + const url = (part as { url?: string }).url; + return typeof url === "string" && !url.startsWith("file://"); +} + +function attachmentsForParts(parts: TranscriptPart[]) { + return 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) => Boolean(attachment.url)); +} + +function partToText(part: TranscriptPart) { + if (part.type === "text") { + return String((part as { text?: string }).text ?? ""); + } + if (part.type === "reasoning") { + 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; + url?: string; + }; + const label = record.label ?? record.path ?? record.filename ?? record.url ?? ""; + return label ? `@${label}` : "@file"; + } + if (part.type === "tool") { + return summarizeStep(part).title; + } + return ""; +} + +function messageToText(message: UIMessage) { + return message.parts + .flatMap((part) => { + if (part.type === "text") return [part.text]; + if (part.type === "reasoning") return [part.text]; + if (part.type === "file") return [part.filename ?? part.url]; + const toolPart = toDynamicToolPart(part); + if (toolPart) { + if (toolPart.state === "output-error") { + return [`[tool:${toolPart.toolName}] ${toolPart.errorText}`]; + } + if (toolPart.state === "output-available") { + return [`[tool:${toolPart.toolName}] ${JSON.stringify(toolPart.output)}`]; + } + return [`[tool:${toolPart.toolName}] ${JSON.stringify(toolPart.input)}`]; + } + return []; + }) + .join("\n\n") + .trim(); +} + +function isImageAttachment(mime: string) { + return mime.startsWith("image/"); +} + +function humanMediaType(raw: string) { + if (!raw || raw === "application/octet-stream") return null; + const short = raw.replace(/^application\//, "").replace(/^text\//, ""); + return short.toUpperCase(); +} + +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; +} + +async function openFileWithOS(path: string) { + try { + await openDesktopPath(path); + } catch { + // silently fail on web + } +} + +async function revealFileInFinder(path: string) { + try { + await revealDesktopItemInDir(path); + } catch { + // silently fail on web + } +} + +function CopyButton(props: { getText: () => string }) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +/** Expandable chip for collapsed pasted text in sent messages. */ +function PastedTextChip(props: { label: string; text: string }) { + const [expanded, setExpanded] = useState(false); + const lineCount = props.text.split(/\r?\n/).length; + + return ( + + + {expanded ? ( +
+
{props.text}
+
+ ) : null} +
+ ); +} + +const PASTE_TOKEN_RE = /(\[pasted text [^\]]+\])/; + +function HighlightedPlainText(props: { + text: string; + className: string; + highlightQuery?: string; + /** Map of paste label -> full text for expandable chips */ + pastedTextMap?: Map; +}) { + const rootRef = useRef(null); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + queueMicrotask(() => { + if (!rootRef.current || rootRef.current !== root) return; + applyTextHighlights(root, props.highlightQuery ?? ""); + }); + }, [props.highlightQuery, props.text]); + + // If no paste tokens present, render as plain text (fast path). + if (!props.pastedTextMap?.size || !PASTE_TOKEN_RE.test(props.text)) { + return ( +
+ {props.text} +
+ ); + } + + // Split on paste tokens and render chips inline. + const segments = props.text.split(PASTE_TOKEN_RE); + return ( +
+ {segments.map((segment, index) => { + const match = segment.match(/^\[pasted text (.+)\]$/); + if (match?.[1]) { + const pastedBody = props.pastedTextMap?.get(match[1]); + if (pastedBody) { + return ; + } + } + return {segment}; + })} +
+ ); +} + +function FileCard(props: { + part: { filename?: string; url: string; mediaType: string }; + tone: "assistant" | "user"; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const isDataUrl = props.part.url?.startsWith("data:"); + const title = props.part.filename || (isDataUrl ? "Attached file" : props.part.url) || "File"; + const ext = props.part.filename?.split(".").pop()?.toLowerCase(); + const badge = humanMediaType(props.part.mediaType) ?? (ext ? ext.toUpperCase() : null); + const isImage = isImageAttachment(props.part.mediaType ?? ""); + const isDesktop = isDesktopRuntime(); + const hasPath = !isDataUrl && props.part.url && !props.part.url.startsWith("http"); + + return ( +
+ {isImage && props.part.url ? ( +
+ {title} +
+ ) : ( +
+ +
+ )} +
+
{title}
+ {badge ? ( +
+ {badge} +
+ ) : null} +
+ + {isDesktop && hasPath ? ( +
+ + {menuOpen ? ( + <> +
setMenuOpen(false)} /> +
+ + + +
+ + ) : null} +
+ ) : null} +
+ ); +} + +function StepRow(props: { + id: string; + part: TranscriptPart; + expanded: boolean; + onToggle: () => void; +}) { + const summary = useMemo(() => summarizeStep(props.part), [props.part]); + const toolState = useMemo(() => { + if (props.part.type !== "tool") return {} as Record; + return (((props.part as { state?: unknown }).state ?? {}) as Record); + }, [props.part]); + const toolInput = toolState.input && typeof toolState.input === "object" + ? (toolState.input as Record) + : undefined; + const toolOutput = toolState.output; + const toolError = typeof toolState.error === "string" ? toolState.error : null; + const expandable = + props.part.type === "tool" && + (hasStructuredValue(toolInput) || hasStructuredValue(toolOutput) || Boolean(toolError)); + const headline = summary.title?.trim() || "Step updates progress"; + + if (props.part.type === "reasoning") { + const raw = typeof (props.part as { text?: unknown }).text === "string" + ? (props.part as { text: string }).text + : ""; + return ( +
+
{cleanReasoningPreview(raw) || headline}
+
+ ); + } + + return ( +
+ + {props.expanded ? ( +
+ {hasStructuredValue(toolInput) ? ( +
+
Request
+
+                {formatStructuredValue(toolInput)}
+              
+
+ ) : null} + {hasStructuredValue(toolOutput) ? ( +
+
Result
+
+                {formatStructuredValue(toolOutput)}
+              
+
+ ) : null} + {toolError ? ( +
+
Error
+
+                {toolError}
+              
+
+ ) : null} +
+ ) : null} +
+ ); +} + +function StepsContainer(props: { + stepGroups: StepTimelineGroup[]; + isUser: boolean; + isInline?: boolean; + isNestedVariant: boolean; + isStreaming: boolean; + expandedStepIds: Set; + onExpandedStepIdsChange: (updater: (current: Set) => Set) => void; +}) { + const toggleSteps = (id: string) => { + props.onExpandedStepIdsChange((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const useInnerTimelineScroll = !props.isStreaming; + + return ( +
+
+
+ {props.stepGroups.map((group) => ( +
+ {group.parts.map((part, index) => { + const rowId = `${group.id}:${index}`; + return ( + toggleSteps(rowId)} + /> + ); + })} +
+ ))} +
+
+
+ ); +} + +function SessionTranscriptInner(props: SessionTranscriptProps) { + const showThinking = props.showThinking ?? props.developerMode; + const isNestedVariant = props.variant === "nested"; + const [internalExpandedStepIds, setInternalExpandedStepIds] = useState>( + () => new Set(), + ); + const expandedStepIds = props.expandedStepIds ?? internalExpandedStepIds; + const onExpandedStepIdsChange = + props.onExpandedStepIdsChange ?? + ((updater: (current: Set) => Set) => { + setInternalExpandedStepIds((current) => updater(current)); + }); + + const transcriptMessages = useMemo(() => { + return props.messages.map((message) => ({ + id: message.id, + role: message.role, + source: message, + parts: message.parts + .map((part, index) => toLegacyPart(part, `${message.id}:${index}`)) + .filter((part): part is TranscriptPart => Boolean(part)), + })); + }, [props.messages]); + + // Cache of the previous messageBlocks array, indexed by identity key. + // Used by useStableBlocks below so structurally-equivalent blocks keep + // their previous object reference across renders. + const previousBlocksRef = useRef>(new Map()); + + const rawMessageBlocks = useMemo(() => { + const blocks: MessageBlockItem[] = []; + + transcriptMessages.forEach((message) => { + const renderableParts = message.parts.filter((part) => { + if (part.type === "reasoning") { + return showThinking; + } + + if (part.type === "step-start" || part.type === "step-finish") { + return false; + } + + return ( + part.type === "text" || + part.type === "tool" || + part.type === "agent" || + part.type === "file" || + props.developerMode + ); + }); + + if (!renderableParts.length) return; + + const isUser = message.role === "user"; + const attachments = attachmentsForParts(renderableParts); + const nonAttachmentParts = renderableParts.filter((part) => !isAttachmentPart(part)); + const groups = groupMessageParts(nonAttachmentParts, message.id); + const isStepsOnly = groups.length > 0 && groups.every((group) => group.kind === "steps"); + const stepGroups = isStepsOnly + ? (groups as Array<{ + kind: "steps"; + id: string; + parts: TranscriptPart[]; + segment: "execution"; + mode: StepGroupMode; + }>).map((group) => ({ + id: group.id, + parts: group.parts, + mode: group.mode, + })) + : []; + + if (isStepsOnly && stepGroups.length > 0) { + blocks.push({ + kind: "steps-cluster", + id: stepGroups[0].id, + stepGroups, + messageIds: [message.id], + isUser, + }); + return; + } + + blocks.push({ + kind: "message", + message: message.source, + renderableParts, + attachments, + groups, + isUser, + messageId: message.id, + }); + }); + + return blocks; + }, [props.developerMode, showThinking, transcriptMessages]); + + // Structural sharing: reuse the previous block object reference for any + // block whose content is equivalent. During streaming, only the active + // assistant message's block is actually new — every other block in the + // transcript keeps its previous reference, which means every + // React.memo'd descendant (MarkdownBlock, SessionTranscript itself, and + // any future per-row components) gets a pointer-equal prop and can bail + // out of rendering entirely. + const messageBlocks = useMemo(() => { + const prev = previousBlocksRef.current; + const next = new Map(); + const stable: MessageBlockItem[] = rawMessageBlocks.map((block) => { + const key = blockIdentityKey(block); + const prevBlock = prev.get(key); + const reused = blocksAreEquivalent(prevBlock, block) ? (prevBlock as MessageBlockItem) : block; + next.set(key, reused); + return reused; + }); + previousBlocksRef.current = next; + return stable; + }, [rawMessageBlocks]); + + const latestAssistantMessageId = useMemo(() => { + for (let index = props.messages.length - 1; index >= 0; index -= 1) { + const message = props.messages[index]; + if (message?.role === "assistant") { + return message.id; + } + } + return ""; + }, [props.messages]); + + const blockIndexByMessageId = useMemo(() => { + 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; + }, [messageBlocks]); + + // Decide to virtualize based only on block count. Do NOT gate on whether + // the scrollElement ref has already attached — that's false on the first + // render of a session, which used to make us render every message + // eagerly (freezing the UI on large sessions) for one tick before + // switching to virtualization. + const shouldVirtualize = messageBlocks.length >= VIRTUALIZATION_THRESHOLD; + + const virtualizer = useVirtualizer({ + count: messageBlocks.length, + getScrollElement: () => props.scrollElement?.() ?? null, + // Give react-virtual a shape-aware estimate so the initial scroll + // height is closer to reality. Small steps-cluster rows are much + // shorter than full assistant message blocks; a good estimate means + // fewer measurement-driven scroll corrections as rows come into view. + estimateSize: (index) => { + const block = messageBlocks[index]; + if (!block) return 180; + if (block.kind === "steps-cluster") return 80; + return block.isUser ? 96 : 320; + }, + 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}`; + }, + }); + + const virtualRows = shouldVirtualize ? virtualizer.getVirtualItems() : []; + + useEffect(() => { + const register = props.setScrollToMessageById; + if (!register) return; + + register((messageId, behavior = "smooth") => { + const index = blockIndexByMessageId.get(messageId); + if (index === undefined) return false; + + if (shouldVirtualize) { + 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; + }); + + return () => { + register(null); + }; + }, [blockIndexByMessageId, props.scrollElement, props.setScrollToMessageById, shouldVirtualize, virtualizer]); + + // NOTE: we intentionally do NOT call virtualizer.measure() on every + // messageBlocks change. react-virtual already invalidates and + // re-measures rows whose refs remount or whose content changes. Calling + // measure() explicitly on each streaming token forces a synchronous + // getBoundingClientRect() pass over every measured row, which made + // streaming into large sessions feel like the UI was frozen. + + // Apply content-visibility earlier too. Even when the transcript is below + // the virtualization threshold, hiding distant blocks from layout/paint + // work reduces the chance that one large session makes the UI feel frozen. + const shouldUseContentVisibility = !shouldVirtualize && messageBlocks.length > 24; + + const blockPerfStyle = (index: number): CSSProperties | undefined => { + if (!shouldUseContentVisibility) return undefined; + const total = messageBlocks.length; + if (index >= total - 12) return undefined; + return { + contentVisibility: "auto", + containIntrinsicSize: "180px", + }; + }; + + 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 ( +
+
+ +
+
+ ); + } + + 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 ( +
+
+
+ +
{messageText}
+
+
+
+ ); + } + + return ( +
+
+ {block.attachments.length > 0 ? ( +
+ {block.attachments.map((attachment) => ( + + ))} +
+ ) : null} + + {block.groups.map((group, index) => { + const highlightQuery = hasSearchMatch ? props.searchHighlightQuery : undefined; + const isStreamingLatestAssistant = + !block.isUser && props.isStreaming && block.messageId === latestAssistantMessageId; + + return ( +
+ {group.kind === "text" ? (() => { + if (group.part.type === "file") { + const filePart = group.part as { + filename?: string; + url?: string; + mime?: string; + }; + return ( + + ); + } + + const text = partToText(group.part); + if (block.isUser) { + return ( + + ); + } + + return ( + + ); + })() : null} + + {group.kind === "steps" ? ( + + ) : null} +
+ ); + })} + + {!isNestedVariant ? ( +
+ messageToText(block.message)} /> +
+ ) : null} +
+
+ ); + }; + + return ( +
+ {shouldVirtualize ? ( + // Always render the virtualized container once we've decided to + // virtualize — even if virtualRows is empty on the very first tick + // (e.g. scrollElement ref hasn't attached yet). A fallback to + // rendering every message would re-introduce the eager-render + // freeze on huge sessions. +
+ {virtualRows.map((virtualRow) => { + const block = messageBlocks[virtualRow.index]; + if (!block) return null; + return ( +
{ + if (element) { + virtualizer.measureElement(element); + } + }} + className="absolute left-0 top-0 w-full pb-4" + style={{ + transform: `translateY(${virtualRow.start}px)`, + }} + > + {renderBlock(block, virtualRow.index)} +
+ ); + })} +
+ ) : ( +
+ {messageBlocks.map((block, index) => renderBlock(block, index))} +
+ )} + + {!isNestedVariant && props.footer ? props.footer : null} +
+ ); +} + +/** + * Memoize at the transcript boundary so SessionSurface state churn (e.g. + * sending=true flipping while the assistant streams) doesn't force a full + * transcript re-render on every parent commit. Re-renders now happen only + * when the transcript's own props actually change (messages array + * identity, isStreaming, developerMode, etc.). + */ +export const SessionTranscript = memo(SessionTranscriptInner); +SessionTranscript.displayName = "SessionTranscript"; diff --git a/apps/app/src/react-app/domains/session/surface/scroll-controller.ts b/apps/app/src/react-app/domains/session/surface/scroll-controller.ts new file mode 100644 index 0000000000..03e2c245e4 --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/scroll-controller.ts @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useRef, useState, type RefObject, type UIEventHandler } from "react"; + +const FOLLOW_LATEST_BOTTOM_GAP_PX = 96; +// Widened from 250ms so a single wheel or trackpad flick isn't missed between +// two rapid programmatic scroll-to-bottom frames during streaming. +const SCROLL_GESTURE_WINDOW_MS = 600; +// Threshold (px) that counts as a meaningful "scroll upward" gesture. Anything +// smaller is treated as anchoring jitter and ignored so we don't trip out of +// follow-latest for pixel-level content growth. +const MANUAL_BROWSE_UPWARD_THRESHOLD_PX = 16; + +type SessionScrollMode = "follow-latest" | "manual-browse"; + +type SessionScrollControllerOptions = { + selectedSessionId: string | null; + renderedMessages: unknown; + containerRef: RefObject; + contentRef: RefObject; +}; + +export function useSessionScrollController( + options: SessionScrollControllerOptions, +) { + const [mode, setMode] = useState("follow-latest"); + const [topClippedMessageId, setTopClippedMessageId] = useState(null); + + const lastKnownScrollTopRef = useRef(0); + const programmaticScrollRef = useRef(false); + const programmaticScrollResetRafARef = useRef(undefined); + const programmaticScrollResetRafBRef = useRef(undefined); + const observedContentHeightRef = useRef(0); + const lastGestureAtRef = useRef(0); + const previousSessionIdRef = useRef(null); + + const isAtBottom = mode === "follow-latest"; + + const hasScrollGesture = useCallback( + () => Date.now() - lastGestureAtRef.current < SCROLL_GESTURE_WINDOW_MS, + [], + ); + + const updateOverflowAnchor = useCallback(() => { + const container = options.containerRef.current; + if (!container) return; + container.style.overflowAnchor = isAtBottom ? "none" : "auto"; + }, [isAtBottom, options.containerRef]); + + const markScrollGesture = useCallback( + (target?: EventTarget | null) => { + const container = options.containerRef.current; + if (!container) return; + + const el = target instanceof Element ? target : undefined; + const nested = el?.closest("[data-scrollable]"); + if (nested && nested !== container) return; + + lastGestureAtRef.current = Date.now(); + }, + [options.containerRef], + ); + + const clearProgrammaticScrollReset = useCallback(() => { + if (programmaticScrollResetRafARef.current !== undefined) { + window.cancelAnimationFrame(programmaticScrollResetRafARef.current); + programmaticScrollResetRafARef.current = undefined; + } + if (programmaticScrollResetRafBRef.current !== undefined) { + window.cancelAnimationFrame(programmaticScrollResetRafBRef.current); + programmaticScrollResetRafBRef.current = undefined; + } + }, []); + + const releaseProgrammaticScrollSoon = useCallback(() => { + clearProgrammaticScrollReset(); + programmaticScrollResetRafARef.current = window.requestAnimationFrame(() => { + programmaticScrollResetRafARef.current = undefined; + programmaticScrollResetRafBRef.current = window.requestAnimationFrame(() => { + programmaticScrollResetRafBRef.current = undefined; + programmaticScrollRef.current = false; + }); + }); + }, [clearProgrammaticScrollReset]); + + const refreshTopClippedMessage = useCallback(() => { + const container = options.containerRef.current; + 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); + }, [options.containerRef]); + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = "auto") => { + const container = options.containerRef.current; + if (!container) return; + + setMode("follow-latest"); + setTopClippedMessageId(null); + programmaticScrollRef.current = true; + + if (behavior === "smooth") { + container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); + releaseProgrammaticScrollSoon(); + return; + } + + container.scrollTop = container.scrollHeight; + window.requestAnimationFrame(() => { + const next = options.containerRef.current; + if (!next) { + programmaticScrollRef.current = false; + return; + } + next.scrollTop = next.scrollHeight; + releaseProgrammaticScrollSoon(); + }); + }, + [options.containerRef, releaseProgrammaticScrollSoon], + ); + + const handleScroll = useCallback>( + (event) => { + const container = event.currentTarget; + const currentTop = container.scrollTop; + const previousTop = lastKnownScrollTopRef.current; + const delta = currentTop - previousTop; + const scrolledUp = delta <= -MANUAL_BROWSE_UPWARD_THRESHOLD_PX; + const userGestured = hasScrollGesture(); + + // If the user scrolls up meaningfully while a programmatic scroll is + // in flight, abandon the programmatic state and switch to manual browse + // immediately. Without this the ResizeObserver's auto-scroll during + // streaming keeps re-anchoring us to the bottom and the user can never + // actually get away from the tail of the transcript. + if (programmaticScrollRef.current && (userGestured || scrolledUp)) { + programmaticScrollRef.current = false; + clearProgrammaticScrollReset(); + setMode("manual-browse"); + lastKnownScrollTopRef.current = currentTop; + refreshTopClippedMessage(); + return; + } + + if (programmaticScrollRef.current) { + lastKnownScrollTopRef.current = currentTop; + refreshTopClippedMessage(); + return; + } + + // Even without a fresh gesture, a strong upward delta means the user + // dragged a scrollbar or triggered a keyboard paging shortcut. Treat it + // as a manual browse request. + if (!userGestured && !scrolledUp) { + lastKnownScrollTopRef.current = currentTop; + refreshTopClippedMessage(); + return; + } + + const bottomGap = container.scrollHeight - (currentTop + container.clientHeight); + if (bottomGap <= FOLLOW_LATEST_BOTTOM_GAP_PX) { + setMode("follow-latest"); + } else if (scrolledUp) { + setMode("manual-browse"); + } + lastKnownScrollTopRef.current = currentTop; + refreshTopClippedMessage(); + }, + [clearProgrammaticScrollReset, hasScrollGesture, refreshTopClippedMessage], + ); + + const jumpToLatest = useCallback( + (behavior: ScrollBehavior = "smooth") => { + scrollToBottom(behavior); + }, + [scrollToBottom], + ); + + const jumpToStartOfMessage = useCallback( + (behavior: ScrollBehavior = "smooth") => { + const messageId = topClippedMessageId; + const container = options.containerRef.current; + 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" }); + }, + [options.containerRef, topClippedMessageId], + ); + + useEffect(() => { + updateOverflowAnchor(); + }, [updateOverflowAnchor]); + + useEffect(() => { + const content = options.contentRef.current; + if (!content) return; + + observedContentHeightRef.current = content.offsetHeight; + const observer = new ResizeObserver(() => { + const nextContent = options.contentRef.current; + if (!nextContent) return; + + const nextHeight = nextContent.offsetHeight; + const grew = nextHeight > observedContentHeightRef.current + 1; + observedContentHeightRef.current = nextHeight; + + // Only re-anchor to the bottom when we're already in follow-latest mode + // AND the user isn't actively scrolling. If they've touched the wheel, + // touchpad, or scrollbar in the last SCROLL_GESTURE_WINDOW_MS, treat + // that as intent to break out of autoscroll and leave their position + // alone until the next handleScroll tick reclassifies the mode. + if (grew && isAtBottom && !hasScrollGesture()) { + scrollToBottom("auto"); + return; + } + + refreshTopClippedMessage(); + }); + + observer.observe(content); + return () => observer.disconnect(); + }, [hasScrollGesture, isAtBottom, options.contentRef, refreshTopClippedMessage, scrollToBottom]); + + useEffect(() => { + if (options.selectedSessionId === previousSessionIdRef.current) return; + previousSessionIdRef.current = options.selectedSessionId; + if (!options.selectedSessionId) return; + + setMode("follow-latest"); + setTopClippedMessageId(null); + observedContentHeightRef.current = 0; + queueMicrotask(() => scrollToBottom("auto")); + }, [options.selectedSessionId, scrollToBottom]); + + useEffect(() => { + void options.renderedMessages; + queueMicrotask(refreshTopClippedMessage); + }, [options.renderedMessages, refreshTopClippedMessage]); + + useEffect(() => { + return () => { + clearProgrammaticScrollReset(); + }; + }, [clearProgrammaticScrollReset]); + + return { + isAtBottom, + topClippedMessageId, + handleScroll, + markScrollGesture, + scrollToBottom, + jumpToLatest, + jumpToStartOfMessage, + }; +} diff --git a/apps/app/src/react-app/domains/session/surface/session-render-state.ts b/apps/app/src/react-app/domains/session/surface/session-render-state.ts new file mode 100644 index 0000000000..d6a9a29f70 --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/session-render-state.ts @@ -0,0 +1,46 @@ +import type { UIMessage } from "ai"; + +import type { OpenworkSessionSnapshot } from "../../../../app/lib/openwork-server"; +import { mergeSnapshotAndLiveMessages, messageListContainsAll } from "../sync/message-merge"; +import { snapshotToUIMessages } from "../sync/usechat-adapter"; + +export function resolveRenderedSessionSnapshot(input: { + sessionId: string; + currentSnapshot: OpenworkSessionSnapshot | null | undefined; + cachedRendered: { sessionId: string; snapshot: OpenworkSessionSnapshot } | null | undefined; +}) { + if (input.currentSnapshot?.session.id === input.sessionId) { + return input.currentSnapshot; + } + if ( + input.cachedRendered?.sessionId === input.sessionId && + input.cachedRendered.snapshot.session.id === input.sessionId + ) { + return input.cachedRendered.snapshot; + } + return null; +} + +export function deriveRenderedSessionMessages(input: { + transcriptState: UIMessage[] | null | undefined; + snapshot: OpenworkSessionSnapshot | null | undefined; + includeLiveOnlyMessages?: boolean; +}) { + const liveMessages = input.transcriptState ?? []; + const snapshotMessages = input.snapshot && input.snapshot.messages.length > 0 + ? snapshotToUIMessages(input.snapshot) + : []; + + if (liveMessages.length > 0 && snapshotMessages.length === 0) return liveMessages; + if (liveMessages.length === 0 && snapshotMessages.length > 0) return snapshotMessages; + if (liveMessages.length > 0 && snapshotMessages.length > 0) { + if (messageListContainsAll(liveMessages, snapshotMessages)) return liveMessages; + return mergeSnapshotAndLiveMessages(snapshotMessages, liveMessages, { + appendLiveOnlyMessages: input.includeLiveOnlyMessages, + }); + } + if (input.snapshot && input.snapshot.messages.length > 0) { + return snapshotMessages; + } + return input.transcriptState ?? []; +} diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx new file mode 100644 index 0000000000..8b4aa2e37a --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -0,0 +1,854 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { UIMessage } from "ai"; +import { useQuery } from "@tanstack/react-query"; +import type { SessionStatus } from "@opencode-ai/sdk/v2/client"; + +import { createClient, unwrap } from "../../../../app/lib/opencode"; +import { abortSessionSafe } from "../../../../app/lib/opencode-session"; +import { readWorkspaceCloudImports, type CloudImportedPlugin } from "../../../../app/cloud/import-state"; +import type { + OpenworkServerClient, + OpenworkSessionSnapshot, +} from "../../../../app/lib/openwork-server"; +import type { + ComposerAttachment, + ComposerDraft, + ComposerPart, + McpServerEntry, + McpStatusMap, + SkillCard, +} from "../../../../app/types"; +import { + publishInspectorSlice, + recordInspectorEvent, +} from "../../../shell/app-inspector"; +import { getReactQueryClient } from "../../../infra/query-client"; +import { ReactSessionComposer } from "./composer/composer"; +import { DevProfiler } from "../../../shell/dev-profiler"; +import { OwDotTicker } from "../../../shell/dot-ticker"; +import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; +import type { ReactComposerNotice } from "./composer/notice"; +import { SessionDebugPanel } from "./debug-panel"; +import { deriveRenderedSessionMessages, resolveRenderedSessionSnapshot } from "./session-render-state"; +import { SessionTranscript } from "./message-list"; +import { deriveSessionRenderModel } from "../sync/transition-controller"; +import { useSessionScrollController } from "./scroll-controller"; +import { + seedSessionState, + statusKey as reactStatusKey, + transcriptKey as reactTranscriptKey, +} from "../sync/session-sync"; + +const EMPTY_TRANSCRIPT: UIMessage[] = []; +const IDLE_STATUS: SessionStatus = { type: "idle" }; + +type SessionError = { + message: string; + kind?: "model-not-found" | "generic"; + /** For model-not-found: the model that failed. */ + failedModel?: { providerID: string; modelID: string }; + /** For model-not-found: suggested replacements from the backend. */ + suggestions?: Array<{ providerID: string; modelID: string }>; +}; + +export type SessionSurfaceProps = { + client: OpenworkServerClient; + workspaceId: string; + workspaceRoot: string; + sessionId: string; + opencodeBaseUrl: string; + openworkToken: string; + developerMode: boolean; + modelLabel: string; + onModelClick: () => void; + onSendDraft: (draft: ComposerDraft) => void; + onDraftChange: (draft: ComposerDraft) => void; + attachmentsEnabled: boolean; + attachmentsDisabledReason: string | null; + modelVariantLabel: string; + modelVariant: string | null; + modelBehaviorOptions?: { value: string | null; label: string }[]; + onModelVariantChange: (value: string | null) => void; + agentLabel: string; + selectedAgent: string | null; + listAgents: () => Promise; + onSelectAgent: (agent: string | null) => void; + listCommands: () => Promise; + recentFiles: string[]; + searchFiles: (query: string) => Promise; + isRemoteWorkspace: boolean; + isSandboxWorkspace: boolean; + onChangeModel?: (model: { providerID: string; modelID: string }) => void; + onUploadInboxFiles?: ((files: File[], options?: { notify?: boolean }) => void | Promise) | null; + onOpenSettingsSection?: ((section: "commands" | "skills" | "mcps" | "plugins") => void) | undefined; +}; + +function transcriptToText(messages: UIMessage[]) { + return messages + .map((message) => { + const header = message.role === "user" ? "You" : message.role === "assistant" ? "OpenWork" : message.role; + const body = message.parts + .flatMap((part) => { + if (part.type === "text") return [part.text]; + if (part.type === "reasoning") return [part.text]; + if (part.type === "dynamic-tool") { + if (part.state === "output-error") return [`[tool:${part.toolName}] ${part.errorText}`]; + if (part.state === "output-available") return [`[tool:${part.toolName}] ${JSON.stringify(part.output)}`]; + return [`[tool:${part.toolName}] ${JSON.stringify(part.input)}`]; + } + return []; + }) + .join("\n\n"); + return `${header}\n${body}`.trim(); + }) + .filter(Boolean) + .join("\n\n---\n\n"); +} + +function statusLabel(snapshot: OpenworkSessionSnapshot | undefined, busy: boolean) { + if (busy) return "Running..."; + if (snapshot?.status.type === "busy") return "Running..."; + if (snapshot?.status.type === "retry") return `Retrying: ${snapshot.status.message}`; + return "Ready"; +} + +function useSharedQueryState(queryKey: readonly unknown[], fallback: T) { + const queryClient = getReactQueryClient(); + // useSyncExternalStore requires getSnapshot to return the same reference + // while the external store has not changed. Callers must pass stable + // fallbacks for empty cache states. + return useSyncExternalStore( + (callback) => queryClient.getQueryCache().subscribe(callback), + () => (queryClient.getQueryData(queryKey) ?? fallback), + () => fallback, + ); +} + +function messageHasVisibleAssistantOutput(message: UIMessage) { + if (message.role !== "assistant") return false; + return message.parts.some((part) => { + if ("text" in part && typeof part.text === "string") return part.text.trim().length > 0; + return part.type === "dynamic-tool" || part.type === "file"; + }); +} + +function AssistantWaitingCard() { + return ( +
+
+ + Thinking +
+
+ ); +} + +function parseSessionError(thrown: unknown): SessionError { + const raw = thrown instanceof Error ? thrown.message : String(thrown); + // Try to detect ProviderModelNotFoundError from the SDK error shape. + // The error message may be a JSON string from our serializer in session-route. + try { + const parsed = JSON.parse(raw); + if (parsed?.name === "ProviderModelNotFoundError" && parsed?.data) { + const { providerID, modelID, suggestions } = parsed.data; + return { + message: `Model ${providerID}/${modelID} is not available.`, + kind: "model-not-found", + failedModel: { providerID, modelID }, + suggestions: Array.isArray(suggestions) ? suggestions : [], + }; + } + } catch { + // Not JSON — fall through to plain message + } + // Check if the raw string mentions model-not-found patterns + if (/ProviderModelNotFoundError/i.test(raw) || /model.*not found/i.test(raw)) { + return { message: raw, kind: "model-not-found" }; + } + return { message: raw || "Failed to send prompt." }; +} + +function SessionErrorCard({ error, onDismiss, onChangeModel, onOpenModelPicker }: { + error: SessionError; + onDismiss: () => void; + onChangeModel?: (model: { providerID: string; modelID: string }) => void; + onOpenModelPicker?: () => void; +}) { + return ( +
+
+
+
+
{error.message}
+ {error.kind === "model-not-found" ? ( +
+ {error.suggestions && error.suggestions.length > 0 ? ( + error.suggestions.map((s) => ( + + )) + ) : null} + +
+ ) : null} +
+ +
+
+
+ ); +} + +function revokeAttachmentPreview(attachment: { previewUrl?: string | undefined }) { + if (!attachment.previewUrl) return; + URL.revokeObjectURL(attachment.previewUrl); +} + +export function SessionSurface(props: SessionSurfaceProps) { + const [draft, setDraft] = useState(""); + const [attachments, setAttachments] = useState([]); + const [mentions, setMentions] = useState>({}); + const [pasteParts, setPasteParts] = useState>([]); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const [showDelayedLoading, setShowDelayedLoading] = useState(false); + const [awaitingAssistantBaseline, setAwaitingAssistantBaseline] = useState(null); + const [rendered, setRendered] = useState<{ sessionId: string; snapshot: OpenworkSessionSnapshot } | null>(null); + const [toolSkills, setToolSkills] = useState([]); + const [toolMcpServers, setToolMcpServers] = useState([]); + const [toolMcpStatus, setToolMcpStatus] = useState(null); + const [toolMcpStatuses, setToolMcpStatuses] = useState({}); + const [toolImportedPlugins, setToolImportedPlugins] = useState([]); + const hydratedKeyRef = useRef(null); + const attachmentsRef = useRef([]); + attachmentsRef.current = attachments; + const opencodeClient = useMemo( + () => createClient(props.opencodeBaseUrl, undefined, { token: props.openworkToken, mode: "openwork" }), + [props.opencodeBaseUrl, props.openworkToken], + ); + + const snapshotQueryKey = useMemo( + () => ["react-session-snapshot", props.workspaceId, props.sessionId], + [props.workspaceId, props.sessionId], + ); + const transcriptQueryKey = useMemo( + () => reactTranscriptKey(props.workspaceId, props.sessionId), + [props.workspaceId, props.sessionId], + ); + const statusQueryKey = useMemo( + () => reactStatusKey(props.workspaceId, props.sessionId), + [props.workspaceId, props.sessionId], + ); + const snapshotQuery = useQuery({ + queryKey: snapshotQueryKey, + queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item, + staleTime: 500, + }); + + const currentSnapshot = snapshotQuery.data?.session.id === props.sessionId ? snapshotQuery.data : null; + const transcriptState = useSharedQueryState(transcriptQueryKey, EMPTY_TRANSCRIPT); + const statusState = useSharedQueryState(statusQueryKey, currentSnapshot?.status ?? IDLE_STATUS); + + useEffect(() => { + if (!currentSnapshot) return; + setRendered({ sessionId: props.sessionId, snapshot: currentSnapshot }); + }, [props.sessionId, currentSnapshot]); + + useEffect(() => { + hydratedKeyRef.current = null; + setError(null); + setSending(false); + setShowDelayedLoading(false); + setAwaitingAssistantBaseline(null); + // Clear draft + attachments + mentions on session change so typed text + // doesn't bleed across sessions (and across workspaces). The sessionId + // effectively changes when the workspace changes too because the route + // navigates to the remembered session id for that workspace. + setDraft(""); + setAttachments((current) => { + current.forEach(revokeAttachmentPreview); + return []; + }); + setMentions({}); + setPasteParts([]); + setNotice(null); + }, [props.sessionId]); + + useEffect(() => { + return () => { + attachmentsRef.current.forEach(revokeAttachmentPreview); + }; + }, []); + + useEffect(() => { + if (!notice) return; + const id = window.setTimeout(() => setNotice(null), 2400); + return () => window.clearTimeout(id); + }, [notice]); + + // Publish a composer inspector slice so external drivers can read draft + // state, attachments, mentions, and sending status from the running app. + useEffect(() => { + const dispose = publishInspectorSlice("composer", () => ({ + workspaceId: props.workspaceId, + sessionId: props.sessionId, + draft, + draftLength: draft.length, + attachments: attachments.map((attachment) => ({ + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.size, + kind: attachment.kind, + })), + mentions, + pasteParts: pasteParts.map((part) => ({ + id: part.id, + label: part.label, + lines: part.lines, + })), + sending, + error, + hasNotice: Boolean(notice), + })); + return dispose; + }, [ + attachments, + draft, + error, + mentions, + notice, + pasteParts, + props.sessionId, + props.workspaceId, + sending, + ]); + + useEffect(() => { + recordInspectorEvent("session.mounted", { + workspaceId: props.workspaceId, + sessionId: props.sessionId, + }); + }, [props.sessionId, props.workspaceId]); + + useEffect(() => { + if (!currentSnapshot) return; + seedSessionState(props.workspaceId, currentSnapshot); + }, [currentSnapshot, props.workspaceId]); + + useEffect(() => { + if (!currentSnapshot) return; + const key = `${props.sessionId}:${currentSnapshot.session.time?.updated ?? currentSnapshot.session.time?.created ?? 0}:${currentSnapshot.messages.length}`; + if (hydratedKeyRef.current === key) return; + hydratedKeyRef.current = key; + seedSessionState(props.workspaceId, currentSnapshot); + }, [props.sessionId, currentSnapshot, props.workspaceId]); + + const snapshot = resolveRenderedSessionSnapshot({ + sessionId: props.sessionId, + currentSnapshot, + cachedRendered: rendered, + }); + const liveStatus = statusState ?? snapshot?.status ?? IDLE_STATUS; + const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry"; + const renderedMessages = useMemo( + () => deriveRenderedSessionMessages({ transcriptState, snapshot, includeLiveOnlyMessages: chatStreaming }), + [chatStreaming, snapshot, transcriptState], + ); + const pendingSessionLoad = !snapshot && snapshotQuery.isLoading && renderedMessages.length === 0; + const assistantOutputAfterAwaitStart = useMemo(() => { + if (awaitingAssistantBaseline === null) return false; + return renderedMessages + .slice(awaitingAssistantBaseline) + .some(messageHasVisibleAssistantOutput); + }, [awaitingAssistantBaseline, renderedMessages]); + const showAssistantWaitState = awaitingAssistantBaseline !== null && !assistantOutputAfterAwaitStart; + useReactRenderWatchdog("SessionSurface", { + sessionId: props.sessionId, + workspaceId: props.workspaceId, + messageCount: renderedMessages.length, + liveStatus: liveStatus.type, + sending, + pendingSessionLoad, + showAssistantWaitState, + hasSnapshot: Boolean(snapshot), + }); + + useEffect(() => { + if (!pendingSessionLoad) { + setShowDelayedLoading(false); + return; + } + const id = window.setTimeout(() => setShowDelayedLoading(true), 2000); + return () => window.clearTimeout(id); + }, [pendingSessionLoad]); + + useEffect(() => { + if (awaitingAssistantBaseline === null) return; + if (assistantOutputAfterAwaitStart) { + setAwaitingAssistantBaseline(null); + return; + } + if (sending || liveStatus.type !== "idle" || renderedMessages.length <= awaitingAssistantBaseline) return; + const id = window.setTimeout(() => setAwaitingAssistantBaseline(null), 1200); + return () => window.clearTimeout(id); + }, [assistantOutputAfterAwaitStart, awaitingAssistantBaseline, liveStatus.type, renderedMessages.length, sending]); + + const model = deriveSessionRenderModel({ + intendedSessionId: props.sessionId, + renderedSessionId: renderedMessages.length > 0 || snapshot ? props.sessionId : null, + hasSnapshot: Boolean(snapshot) || renderedMessages.length > 0, + isFetching: snapshotQuery.isFetching, + isError: snapshotQuery.isError || Boolean(error), + }); + + const buildDraft = useCallback((text: string, nextAttachments: ComposerAttachment[]): ComposerDraft => { + const trimmed = text.trim(); + const slashMatch = trimmed.match(/^\/([^\s]+)\s*(.*)$/); + const parts: ComposerPart[] = text.split(/(\[pasted text [^\]]+\]|@[^\s@]+)/).flatMap((segment) => { + if (!segment) return [] as ComposerDraft["parts"]; + const pasteMatch = segment.match(/^\[pasted text (.+)\]$/); + if (pasteMatch) { + const target = pasteParts.find((item) => item.label === pasteMatch[1]); + if (target) { + return [{ type: "paste", id: target.id, label: target.label, text: target.text, lines: target.lines }]; + } + } + if (segment.startsWith("@")) { + const value = segment.slice(1); + const kind = mentions[value]; + if (kind === "agent") return [{ type: "agent", name: value } satisfies ComposerDraft["parts"][number]]; + if (kind === "file") return [{ type: "file", path: value, label: value } satisfies ComposerDraft["parts"][number]]; + } + return [{ type: "text", text: segment } satisfies ComposerDraft["parts"][number]]; + }); + // Expand paste placeholders in resolvedText so the model receives + // the actual pasted content instead of "[pasted text