diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc5bcbdc..c679886f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,11 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} with: projectPath: './apps/desktop' tagName: v__VERSION__ diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 7481272b..343e04a0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -43,6 +43,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + with: projectPath: './apps/desktop' # tagName and releaseId omitted to only build without uploading diff --git a/apps/desktop/next.config.ts b/apps/desktop/next.config.ts index 1affc84e..9eb2376b 100644 --- a/apps/desktop/next.config.ts +++ b/apps/desktop/next.config.ts @@ -1,11 +1,12 @@ import type { NextConfig } from "next"; import path from "node:path"; +import { withSentryConfig } from "@sentry/nextjs"; const nextConfig: NextConfig = { output: 'export', outputFileTracingRoot: path.join(__dirname, "../../"), turbopack: { - root: __dirname, + root: path.join(__dirname, "../../"), }, images: { unoptimized: true, @@ -31,7 +32,13 @@ const nextConfig: NextConfig = { "picomatch", "react-markdown", "remark-gfm", - "tailwind-merge" + "tailwind-merge", + "@sentry/nextjs", + "@opentelemetry/api", + "@opentelemetry/sdk-trace-base", + "@opentelemetry/sdk-trace-web", + "@opentelemetry/instrumentation-xml-http-request", + "@opentelemetry/instrumentation-fetch" ], }, // Configuración simplificada de indicadores para evitar errores de tipos @@ -40,4 +47,27 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withSentryConfig(nextConfig, { + // For all available options, see: + // https://www.npmjs.com/package/@sentry/webpack-plugin#options + + org: "unsetsoft", + project: "trixty-ide", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + webpack: { + // Tree-shaking options for reducing bundle size + treeshake: { + // Automatically tree-shake Sentry logger statements to reduce bundle size + removeDebugLogging: true, + }, + }, +}); diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json deleted file mode 100644 index 228e5124..00000000 --- a/apps/desktop/package-lock.json +++ /dev/null @@ -1,1392 +0,0 @@ -{ - "name": "@trixty/desktop", - "version": "1.0.10", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@trixty/desktop", - "version": "1.0.10", - "dependencies": { - "@monaco-editor/react": "^4.7.0", - "@tauri-apps/api": "^2.1.1", - "@tauri-apps/plugin-dialog": "^2.7.0", - "@tauri-apps/plugin-fs": "^2.5.0", - "@tauri-apps/plugin-http": "^2.5.8", - "@tauri-apps/plugin-positioner": "^2.3.1", - "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-shell": "^2.3.5", - "@tauri-apps/plugin-store": "^2.4.2", - "@tauri-apps/plugin-updater": "~2", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", - "clsx": "^2.1.1", - "framer-motion": "^12.38.0", - "lucide-react": "^1.8.0", - "monaco-editor": "^0.55.1", - "next": "16.2.3", - "picomatch": "^4.0.4", - "react": "19.2.4", - "react-dom": "19.2.4", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^4.10.0", - "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.5.0" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4", - "@tauri-apps/cli": "^2.10.1", - "@types/node": "^20", - "@types/picomatch": "^4.0.3", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "16.2.3", - "tailwindcss": "^4", - "typescript": "^5" - } - }, - "../../node_modules/.pnpm/@monaco-editor+react@4.7.0_monaco-editor@0.55.1_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/@monaco-editor/react": { - "version": "4.7.0", - "license": "MIT", - "dependencies": { - "@monaco-editor/loader": "^1.5.0" - }, - "devDependencies": { - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^14.0.0", - "@typescript-eslint/eslint-plugin": "^5.54.0", - "@typescript-eslint/parser": "^5.54.0", - "eslint": "^8.35.0", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsx-a11y": "6.7.1", - "eslint-plugin-react": "7.32.2", - "eslint-plugin-react-hooks": "4.6.0", - "husky": "^4.2.3", - "prettier": "^2.8.7", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "tsup": "^6.7.0", - "typescript": "^4.9.5", - "vitest": "^0.29.2" - }, - "peerDependencies": { - "monaco-editor": ">= 0.25.0 < 1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "../../node_modules/.pnpm/@tailwindcss+postcss@4.2.2/node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "postcss": "^8.5.6", - "tailwindcss": "4.2.2" - }, - "devDependencies": { - "@types/node": "^20.19.0", - "@types/postcss-import": "14.0.3", - "dedent": "1.7.1", - "internal-example-plugin": "0.0.0", - "postcss-import": "^16.1.1" - } - }, - "../../node_modules/.pnpm/@tauri-apps+api@2.10.1/node_modules/@tauri-apps/api": { - "version": "2.10.1", - "license": "Apache-2.0 OR MIT", - "devDependencies": { - "@eslint/js": "^9.29.0", - "@rollup/plugin-terser": "0.4.4", - "@rollup/plugin-typescript": "12.3.0", - "@types/eslint": "^9.6.1", - "@types/node": "^24.0.0", - "eslint": "^9.29.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-security": "3.0.1", - "fast-glob": "3.3.3", - "globals": "^17.0.0", - "rollup": "4.57.0", - "tslib": "^2.8.1", - "typescript": "^5.8.3", - "typescript-eslint": "^8.34.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "../../node_modules/.pnpm/@tauri-apps+cli@2.10.1/node_modules/@tauri-apps/cli": { - "version": "2.10.1", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "devDependencies": { - "@napi-rs/cli": "^3.4.1", - "@types/node": "^24.0.0", - "cross-env": "10.1.0", - "vitest": "^4.0.0" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.1", - "@tauri-apps/cli-darwin-x64": "2.10.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", - "@tauri-apps/cli-linux-arm64-musl": "2.10.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-musl": "2.10.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", - "@tauri-apps/cli-win32-x64-msvc": "2.10.1" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-dialog@2.7.0/node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.0", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-fs@2.5.0/node_modules/@tauri-apps/plugin-fs": { - "version": "2.5.0", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-http@2.5.8/node_modules/@tauri-apps/plugin-http": { - "version": "2.5.8", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-positioner@2.3.1/node_modules/@tauri-apps/plugin-positioner": { - "version": "2.3.1", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-process@2.3.1/node_modules/@tauri-apps/plugin-process": { - "version": "2.3.1", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-shell@2.3.5/node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-store@2.4.2/node_modules/@tauri-apps/plugin-store": { - "version": "2.4.2", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "../../node_modules/.pnpm/@tauri-apps+plugin-updater@2.10.1/node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.1", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, - "../../node_modules/.pnpm/@types+node@20.19.39/node_modules/@types/node": { - "version": "20.19.39", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "../../node_modules/.pnpm/@types+picomatch@4.0.3/node_modules/@types/picomatch": { - "version": "4.0.3", - "dev": true, - "license": "MIT" - }, - "../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom": { - "version": "19.2.3", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "../../node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react": { - "version": "19.2.14", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "../../node_modules/.pnpm/@xterm+addon-fit@0.11.0/node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "license": "MIT" - }, - "../../node_modules/.pnpm/@xterm+xterm@6.0.0/node_modules/@xterm/xterm": { - "version": "6.0.0", - "license": "MIT", - "workspaces": [ - "addons/*" - ], - "devDependencies": { - "@lunapaint/png-codec": "^0.2.0", - "@playwright/test": "^1.37.1", - "@stylistic/eslint-plugin": "^2.3.0", - "@types/chai": "^4.2.22", - "@types/debug": "^4.1.7", - "@types/deep-equal": "^1.0.1", - "@types/express": "4", - "@types/express-ws": "^3.0.1", - "@types/jsdom": "^16.2.13", - "@types/mocha": "^9.0.0", - "@types/node": "^18.16.0", - "@types/trusted-types": "^1.0.6", - "@types/utf8": "^3.0.0", - "@types/webpack": "^5.28.0", - "@types/ws": "^8.2.0", - "@typescript-eslint/eslint-plugin": "^6.2.00", - "@typescript-eslint/parser": "^6.2.00", - "chai": "^4.3.4", - "cross-env": "^7.0.3", - "deep-equal": "^2.0.5", - "esbuild": "~0.25.2", - "eslint": "^8.56.0", - "eslint-plugin-jsdoc": "^46.9.1", - "express": "^4.19.2", - "express-ws": "^5.0.2", - "jsdom": "^18.0.1", - "mocha": "^10.1.0", - "mustache": "^4.2.0", - "node-pty": "1.1.0-beta19", - "nyc": "^15.1.0", - "source-map-loader": "^3.0.0", - "source-map-support": "^0.5.20", - "ts-loader": "^9.3.1", - "typescript": "5.5", - "utf8": "^3.0.0", - "webpack": "^5.61.0", - "webpack-cli": "^4.9.1", - "ws": "^8.2.3", - "xterm-benchmark": "^0.3.1" - } - }, - "../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx": { - "version": "2.1.1", - "license": "MIT", - "devDependencies": { - "esm": "3.2.25", - "terser": "4.8.0", - "uvu": "0.5.4" - }, - "engines": { - "node": ">=6" - } - }, - "../../node_modules/.pnpm/eslint-config-next@16.2.3_@typescript-eslint+parser@8.58.2_eslint@9.39.4_jiti@2.6.1__typescri_xir3oxqdsnhmjdv6i7uhwgobui/node_modules/eslint-config-next": { - "version": "16.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@next/eslint-plugin-next": "16.2.3", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsx-a11y": "^6.10.0", - "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", - "globals": "16.4.0", - "typescript-eslint": "^8.46.0" - }, - "devDependencies": { - "@types/eslint": "9.6.1", - "@types/eslint-plugin-jsx-a11y": "6.10.1", - "typescript": "5.9.2" - }, - "peerDependencies": { - "eslint": ">=9.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "../../node_modules/.pnpm/eslint@9.39.4_jiti@2.6.1/node_modules/eslint": { - "version": "9.39.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "devDependencies": { - "@arethetypeswrong/cli": "^0.18.0", - "@babel/core": "^7.4.3", - "@babel/preset-env": "^7.4.3", - "@cypress/webpack-preprocessor": "^6.0.2", - "@eslint/json": "^0.13.2", - "@trunkio/launcher": "^1.3.4", - "@types/esquery": "^1.5.4", - "@types/node": "^22.13.14", - "@typescript-eslint/parser": "^8.4.0", - "babel-loader": "^8.0.5", - "c8": "^7.12.0", - "chai": "^4.0.1", - "cheerio": "^0.22.0", - "common-tags": "^1.8.0", - "core-js": "^3.1.3", - "cypress": "^14.1.0", - "ejs": "^3.0.2", - "eslint": "file:.", - "eslint-config-eslint": "file:packages/eslint-config-eslint", - "eslint-plugin-eslint-plugin": "^6.0.0", - "eslint-plugin-expect-type": "^0.6.0", - "eslint-plugin-yml": "^1.14.0", - "eslint-release": "^3.3.0", - "eslint-rule-composer": "^0.3.0", - "eslump": "^3.0.0", - "esprima": "^4.0.1", - "fast-glob": "^3.2.11", - "fs-teardown": "^0.1.3", - "glob": "^10.0.0", - "globals": "^16.2.0", - "got": "^11.8.3", - "gray-matter": "^4.0.3", - "jiti": "^2.6.1", - "jiti-v2.0": "npm:jiti@2.0.x", - "jiti-v2.1": "npm:jiti@2.1.x", - "knip": "^5.60.2", - "lint-staged": "^11.0.0", - "markdown-it": "^12.2.0", - "markdown-it-container": "^3.0.0", - "marked": "^4.0.8", - "metascraper": "^5.25.7", - "metascraper-description": "^5.25.7", - "metascraper-image": "^5.29.3", - "metascraper-logo": "^5.25.7", - "metascraper-logo-favicon": "^5.25.7", - "metascraper-title": "^5.25.7", - "mocha": "^11.7.1", - "node-polyfill-webpack-plugin": "^1.0.3", - "npm-license": "^0.3.3", - "pirates": "^4.0.5", - "progress": "^2.0.3", - "proxyquire": "^2.0.1", - "recast": "^0.23.0", - "regenerator-runtime": "^0.14.0", - "semver": "^7.5.3", - "shelljs": "^0.10.0", - "sinon": "^11.0.0", - "typescript": "^5.3.3", - "webpack": "^5.23.0", - "webpack-cli": "^4.5.0", - "yorkie": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "../../node_modules/.pnpm/framer-motion@12.38.0_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/framer-motion": { - "version": "12.38.0", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", - "tslib": "^2.4.0" - }, - "devDependencies": { - "@radix-ui/react-dialog": "^1.1.15", - "@thednp/dommatrix": "^2.0.11", - "@types/three": "0.137.0", - "three": "0.137.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "../../node_modules/.pnpm/lucide-react@1.8.0_react@19.2.4/node_modules/lucide-react": { - "version": "1.8.0", - "license": "ISC", - "devDependencies": { - "@lucide/build-icons": "1.1.0", - "@lucide/rollup-plugins": "1.0.0", - "@lucide/shared": "1.0.0", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^14.1.2", - "@types/react": "^18.2.37", - "@vitejs/plugin-react": "^4.6.0", - "jest-serializer-html": "^7.1.0", - "react": "18.2.0", - "react-dom": "18.2.0", - "rollup": "^4.59.0", - "rollup-plugin-dts": "^6.2.3", - "rollup-plugin-preserve-directives": "^0.4.0", - "typescript": "^5.8.3", - "vite": "^7.2.4" - }, - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "../../node_modules/.pnpm/monaco-editor@0.55.1/node_modules/monaco-editor": { - "version": "0.55.1", - "license": "MIT", - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - }, - "devDependencies": { - "@playwright/test": "^1.56.1", - "@rollup/plugin-alias": "^5.1.1", - "@rollup/plugin-node-resolve": "^16.0.2", - "@types/mocha": "^10.0.10", - "@types/shelljs": "^0.8.11", - "@types/trusted-types": "^1.0.6", - "@typescript/vfs": "^1.3.5", - "@vscode/monaco-lsp-client": "file:./monaco-lsp-client", - "chai": "^4.3.6", - "clean-css": "^5.2.4", - "css-loader": "^6.7.1", - "esbuild": "^0.25.9", - "esbuild-plugin-alias": "^0.2.1", - "file-loader": "^6.2.0", - "glob": "^7.2.0", - "http-server": "^14.1.1", - "husky": "^7.0.4", - "jsdom": "^19.0.0", - "jsonc-parser": "^3.0.0", - "mocha": "^11.7.4", - "monaco-editor-core": "0.55.1", - "parcel": "^2.7.0", - "pin-github-action": "^1.8.0", - "postcss-url": "^10.1.3", - "prettier": "^2.5.1", - "pretty-quick": "^3.1.3", - "requirejs": "^2.3.7", - "rollup": "^4.52.4", - "rollup-plugin-delete": "^3.0.1", - "rollup-plugin-dts": "^6.2.3", - "rollup-plugin-esbuild": "^6.2.1", - "rollup-plugin-import-css": "^4.0.2", - "rollup-plugin-keep-css-imports": "^1.0.0", - "shelljs": "^0.8.5", - "style-loader": "^3.3.1", - "terser": "^5.14.2", - "ts-node": "^10.6.0", - "typescript": "^5.9.3", - "vite": "^7.1.11", - "vscode-css-languageservice": "6.2.14", - "vscode-html-languageservice": "5.2.0", - "vscode-json-languageservice": "5.3.11", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-languageserver-types": "3.17.5", - "vscode-uri": "3.0.8", - "webpack": "^5.76.0", - "yaserver": "^0.4.0" - } - }, - "../../node_modules/.pnpm/next@16.2.3_@babel+core@7.29.0_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/next": { - "version": "16.2.3", - "license": "MIT", - "dependencies": { - "@next/env": "16.2.3", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.9.19", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "devDependencies": { - "@babel/core": "7.26.10", - "@babel/eslint-parser": "7.24.6", - "@babel/generator": "7.27.0", - "@babel/plugin-syntax-bigint": "7.8.3", - "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/plugin-syntax-import-attributes": "7.26.0", - "@babel/plugin-syntax-jsx": "7.25.9", - "@babel/plugin-syntax-typescript": "7.25.4", - "@babel/plugin-transform-class-properties": "7.25.9", - "@babel/plugin-transform-export-namespace-from": "7.25.9", - "@babel/plugin-transform-modules-commonjs": "7.26.3", - "@babel/plugin-transform-numeric-separator": "7.25.9", - "@babel/plugin-transform-object-rest-spread": "7.25.9", - "@babel/plugin-transform-runtime": "7.26.10", - "@babel/preset-env": "7.26.9", - "@babel/preset-react": "7.26.3", - "@babel/preset-typescript": "7.27.0", - "@babel/runtime": "7.27.0", - "@babel/traverse": "7.27.0", - "@babel/types": "7.27.0", - "@base-ui-components/react": "1.0.0-beta.2", - "@capsizecss/metrics": "3.4.0", - "@edge-runtime/cookies": "6.0.0", - "@edge-runtime/ponyfill": "4.0.0", - "@edge-runtime/primitives": "6.0.0", - "@hapi/accept": "5.0.2", - "@jest/transform": "29.5.0", - "@jest/types": "29.5.0", - "@modelcontextprotocol/sdk": "1.18.1", - "@mswjs/interceptors": "0.23.0", - "@napi-rs/triples": "1.2.0", - "@next/font": "16.2.3", - "@next/polyfill-module": "16.2.3", - "@next/polyfill-nomodule": "16.2.3", - "@next/react-refresh-utils": "16.2.3", - "@next/swc": "16.2.3", - "@opentelemetry/api": "1.6.0", - "@playwright/test": "1.58.2", - "@rspack/core": "1.6.7", - "@storybook/addon-a11y": "8.6.0", - "@storybook/addon-essentials": "8.6.0", - "@storybook/addon-interactions": "8.6.0", - "@storybook/addon-webpack5-compiler-swc": "3.0.0", - "@storybook/blocks": "8.6.0", - "@storybook/react": "8.6.0", - "@storybook/react-webpack5": "8.6.0", - "@storybook/test": "8.6.0", - "@storybook/test-runner": "0.21.0", - "@swc/core": "1.11.24", - "@swc/types": "0.1.7", - "@taskr/clear": "1.1.0", - "@taskr/esnext": "1.1.0", - "@types/babel__code-frame": "7.0.6", - "@types/babel__core": "7.20.5", - "@types/babel__generator": "7.27.0", - "@types/babel__template": "7.4.4", - "@types/babel__traverse": "7.20.7", - "@types/bytes": "3.1.1", - "@types/ci-info": "2.0.0", - "@types/compression": "0.0.36", - "@types/content-disposition": "0.5.4", - "@types/content-type": "1.1.3", - "@types/cookie": "0.3.3", - "@types/cross-spawn": "6.0.0", - "@types/debug": "4.1.5", - "@types/express-serve-static-core": "4.17.33", - "@types/fresh": "0.5.0", - "@types/glob": "7.1.1", - "@types/jsonwebtoken": "9.0.0", - "@types/lodash": "4.14.198", - "@types/lodash.curry": "4.1.6", - "@types/path-to-regexp": "1.7.0", - "@types/picomatch": "2.3.3", - "@types/platform": "1.3.4", - "@types/react": "19.0.8", - "@types/react-dom": "19.0.3", - "@types/react-is": "18.2.4", - "@types/semver": "7.3.1", - "@types/send": "0.14.4", - "@types/serve-handler": "6.1.4", - "@types/shell-quote": "1.7.1", - "@types/text-table": "0.2.1", - "@types/ua-parser-js": "0.7.36", - "@types/webpack-sources1": "npm:@types/webpack-sources@0.1.5", - "@types/ws": "8.2.0", - "@vercel/ncc": "0.34.0", - "@vercel/nft": "0.27.1", - "@vercel/routing-utils": "5.2.0", - "@vercel/turbopack-ecmascript-runtime": "*", - "acorn": "8.14.0", - "anser": "1.4.9", - "arg": "4.1.0", - "assert": "2.0.0", - "async-retry": "1.2.3", - "async-sema": "3.0.0", - "axe-playwright": "2.0.3", - "babel-loader": "10.0.0", - "babel-plugin-react-compiler": "0.0.0-experimental-1371fcb-20260227", - "babel-plugin-transform-define": "2.0.0", - "babel-plugin-transform-react-remove-prop-types": "0.4.24", - "browserify-zlib": "0.2.0", - "browserslist": "4.28.1", - "buffer": "5.6.0", - "busboy": "1.6.0", - "bytes": "3.1.1", - "ci-info": "watson/ci-info#f43f6a1cefff47fb361c88cf4b943fdbcaafe540", - "cli-select": "1.1.2", - "client-only": "0.0.1", - "commander": "12.1.0", - "comment-json": "3.0.3", - "compression": "1.7.4", - "conf": "5.0.0", - "constants-browserify": "1.0.0", - "content-disposition": "0.5.3", - "content-type": "1.0.4", - "cookie": "0.4.1", - "cross-env": "6.0.3", - "cross-spawn": "7.0.3", - "crypto-browserify": "3.12.0", - "css-loader": "7.1.2", - "css.escape": "1.5.1", - "cssnano-preset-default": "7.0.6", - "data-uri-to-buffer": "3.0.1", - "debug": "4.1.1", - "devalue": "2.0.1", - "domain-browser": "4.19.0", - "edge-runtime": "4.0.1", - "events": "3.3.0", - "find-up": "4.1.0", - "fresh": "0.5.2", - "glob": "7.1.7", - "gzip-size": "5.1.1", - "http-proxy": "1.18.1", - "http-proxy-agent": "5.0.0", - "https-browserify": "1.0.0", - "https-proxy-agent": "5.0.1", - "icss-utils": "5.1.0", - "ignore-loader": "0.1.2", - "image-size": "1.2.1", - "ipaddr.js": "2.2.0", - "is-docker": "2.0.0", - "is-wsl": "2.2.0", - "jest-worker": "27.5.1", - "json5": "2.2.3", - "jsonwebtoken": "9.0.0", - "loader-runner": "4.3.0", - "loader-utils2": "npm:loader-utils@2.0.4", - "loader-utils3": "npm:loader-utils@3.1.3", - "lodash.curry": "4.1.1", - "mini-css-extract-plugin": "2.4.4", - "msw": "2.3.0", - "nanoid": "3.1.32", - "native-url": "0.3.4", - "neo-async": "2.6.1", - "node-html-parser": "5.3.3", - "ora": "4.0.4", - "os-browserify": "0.3.0", - "p-limit": "3.1.0", - "p-queue": "6.6.2", - "path-browserify": "1.0.1", - "path-to-regexp": "6.3.0", - "picomatch": "4.0.1", - "postcss-flexbugs-fixes": "5.0.2", - "postcss-modules-extract-imports": "3.0.0", - "postcss-modules-local-by-default": "4.2.0", - "postcss-modules-scope": "3.0.0", - "postcss-modules-values": "4.0.0", - "postcss-preset-env": "7.4.3", - "postcss-safe-parser": "6.0.0", - "postcss-scss": "4.0.3", - "postcss-value-parser": "4.2.0", - "process": "0.11.10", - "punycode": "2.1.1", - "querystring-es3": "0.2.1", - "raw-body": "2.4.1", - "react-refresh": "0.12.0", - "recast": "0.23.11", - "regenerator-runtime": "0.13.4", - "safe-stable-stringify": "2.5.0", - "sass-loader": "16.0.5", - "schema-utils2": "npm:schema-utils@2.7.1", - "schema-utils3": "npm:schema-utils@3.0.0", - "semver": "7.3.2", - "send": "0.18.0", - "serve-handler": "6.1.6", - "server-only": "0.0.1", - "setimmediate": "1.0.5", - "shell-quote": "1.7.3", - "source-map": "0.6.1", - "source-map-loader": "5.0.0", - "source-map08": "npm:source-map@0.8.0-beta.0", - "stacktrace-parser": "0.1.10", - "storybook": "8.6.0", - "stream-browserify": "3.0.0", - "stream-http": "3.1.1", - "strict-event-emitter": "0.5.0", - "string_decoder": "1.3.0", - "string-hash": "1.1.3", - "strip-ansi": "6.0.0", - "style-loader": "4.0.0", - "superstruct": "1.0.3", - "tar": "7.5.11", - "taskr": "1.1.0", - "terser": "5.27.0", - "terser-webpack-plugin": "5.3.9", - "text-table": "0.2.0", - "timers-browserify": "2.0.12", - "tty-browserify": "0.0.1", - "typescript": "5.9.2", - "ua-parser-js": "1.0.35", - "unistore": "3.4.1", - "util": "0.12.4", - "vm-browserify": "1.1.2", - "watchpack": "2.4.0", - "web-vitals": "4.2.1", - "webpack": "5.98.0", - "webpack-sources1": "npm:webpack-sources@1.4.3", - "webpack-sources3": "npm:webpack-sources@3.2.3", - "ws": "8.2.3", - "zod": "3.25.76", - "zod-validation-error": "3.4.0" - }, - "engines": { - "node": ">=20.9.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.3", - "@next/swc-darwin-x64": "16.2.3", - "@next/swc-linux-arm64-gnu": "16.2.3", - "@next/swc-linux-arm64-musl": "16.2.3", - "@next/swc-linux-x64-gnu": "16.2.3", - "@next/swc-linux-x64-musl": "16.2.3", - "@next/swc-win32-arm64-msvc": "16.2.3", - "@next/swc-win32-x64-msvc": "16.2.3", - "sharp": "^0.34.5" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch": { - "version": "4.0.4", - "license": "MIT", - "devDependencies": { - "eslint": "^8.57.0", - "fill-range": "^7.0.1", - "gulp-format-md": "^2.0.0", - "mocha": "^10.4.0", - "nyc": "^15.1.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom": { - "version": "19.2.4", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "../../node_modules/.pnpm/react-markdown@10.1.0_@types+react@19.2.14_react@19.2.4/node_modules/react-markdown": { - "version": "10.1.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "devDependencies": { - "@testing-library/react": "^16.0.0", - "@types/node": "^22.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "c8": "^10.0.0", - "concat-stream": "^2.0.0", - "esbuild": "^0.25.0", - "eslint-plugin-react": "^7.0.0", - "global-jsdom": "^26.0.0", - "prettier": "^3.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "rehype-raw": "^7.0.0", - "rehype-starry-night": "^2.0.0", - "remark-cli": "^12.0.0", - "remark-gfm": "^4.0.0", - "remark-preset-wooorm": "^11.0.0", - "remark-toc": "^9.0.0", - "type-coverage": "^2.0.0", - "typescript": "^5.0.0", - "xo": "^0.60.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, - "../../node_modules/.pnpm/react-resizable-panels@4.10.0_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/react-resizable-panels": { - "version": "4.10.0", - "license": "MIT", - "devDependencies": { - "@csstools/postcss-oklab-function": "^4.0.11", - "@eslint/js": "^9.30.1", - "@headlessui/react": "^2.2.4", - "@headlessui/tailwindcss": "^0.2.2", - "@heroicons/react": "^2.2.0", - "@tailwindcss/vite": "^4.1.11", - "@tailwindplus/elements": "^1.0.5", - "@testing-library/jest-dom": "^6.6.4", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "@types/bytes": "^3.1.5", - "@types/compression": "^1.8.1", - "@types/express": "^5.0.5", - "@types/markdown-it": "^14.1.2", - "@types/node": "^24.2.0", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^3.10.2", - "bytes": "^3.1.2", - "clsx": "^2.1.1", - "compression": "^1.8.1", - "concurrently": "^9.2.1", - "cross-env": "^10.1.0", - "csstype": "^3.1.3", - "eslint": "^9.30.1", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "eslint-plugin-sonarjs": "^3.0.6", - "express": "^5.1.0", - "globals": "^16.3.0", - "husky": "^9.1.7", - "jsdom": "^26.1.0", - "lint-staged": "^16.1.4", - "markdown-it": "^14.1.0", - "marked": "^16.4.1", - "postcss": "^8.5.6", - "prettier": "3.6.2", - "prettier-plugin-tailwindcss": "^0.7.1", - "puppeteer": "^24.38.0", - "react": "^19.2.3", - "react-docgen-typescript": "^2.4.0", - "react-dom": "^19.2.3", - "react-error-boundary": "^6.0.0", - "react-lib-tools": "0.0.48", - "react-router-dom": "^7.6.3", - "rimraf": "^6.1.2", - "rollup-plugin-terser": "^7.0.2", - "rollup-plugin-visualizer": "^6.0.3", - "rollup-preserve-directives": "^1.1.3", - "sharp": "^0.34.5", - "sirv": "^3.0.2", - "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11", - "terser": "^5.43.1", - "ts-blank-space": "^0.6.2", - "ts-node": "^10.9.2", - "tsx": "^4.21.0", - "typescript": "~5.8.3", - "typescript-eslint": "^8.35.1", - "typescript-json-schema": "^0.65.1", - "vite": "^7.0.4", - "vite-plugin-dts": "^4.5.4", - "vite-plugin-svgr": "^4.3.0", - "vitest": "^3.2.4", - "vitest-fail-on-console": "^0.10.1", - "zustand": "^5.0.7" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "../../node_modules/.pnpm/react@19.2.4/node_modules/react": { - "version": "19.2.4", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "../../node_modules/.pnpm/remark-gfm@4.0.1/node_modules/remark-gfm": { - "version": "4.0.1", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "c8": "^10.0.0", - "is-hidden": "^2.0.0", - "prettier": "^3.0.0", - "remark": "^15.0.0", - "remark-cli": "^12.0.0", - "remark-preset-wooorm": "^11.0.0", - "string-width": "^6.0.0", - "to-vfile": "^8.0.0", - "type-coverage": "^2.0.0", - "typescript": "^5.0.0", - "xo": "^0.60.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "../../node_modules/.pnpm/tailwind-merge@3.5.0/node_modules/tailwind-merge": { - "version": "3.5.0", - "license": "MIT", - "devDependencies": { - "@babel/core": "^7.29.0", - "@babel/preset-env": "^7.29.0", - "@codspeed/vitest-plugin": "^5.1.0", - "@rollup/plugin-babel": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-typescript": "^12.3.0", - "@types/node": "^24.10.9", - "@vitest/coverage-v8": "^4.0.18", - "@vitest/eslint-plugin": "^1.6.6", - "babel-plugin-annotate-pure-calls": "^0.5.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "eslint": "^9.39.2", - "eslint-plugin-import": "^2.32.0", - "globby": "^16.1.0", - "prettier": "^3.8.1", - "rollup": "^4.57.1", - "rollup-plugin-delete": "^3.0.2", - "rollup-plugin-dts": "^6.3.0", - "tslib": "^2.8.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0", - "vitest": "^4.0.18", - "zx": "^8.8.5" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "../../node_modules/.pnpm/tailwindcss@4.2.2/node_modules/tailwindcss": { - "version": "4.2.2", - "dev": true, - "license": "MIT", - "devDependencies": { - "@jridgewell/remapping": "^2.3.5", - "@tailwindcss/oxide": "^4.2.2", - "@types/node": "^20.19.0", - "dedent": "1.7.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1" - } - }, - "../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": { - "version": "5.9.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "devDependencies": { - "@dprint/formatter": "^0.4.1", - "@dprint/typescript": "0.93.4", - "@esfx/canceltoken": "^1.0.0", - "@eslint/js": "^9.20.0", - "@octokit/rest": "^21.1.1", - "@types/chai": "^4.3.20", - "@types/diff": "^7.0.1", - "@types/minimist": "^1.2.5", - "@types/mocha": "^10.0.10", - "@types/ms": "^0.7.34", - "@types/node": "latest", - "@types/source-map-support": "^0.5.10", - "@types/which": "^3.0.4", - "@typescript-eslint/rule-tester": "^8.24.1", - "@typescript-eslint/type-utils": "^8.24.1", - "@typescript-eslint/utils": "^8.24.1", - "azure-devops-node-api": "^14.1.0", - "c8": "^10.1.3", - "chai": "^4.5.0", - "chokidar": "^4.0.3", - "diff": "^7.0.0", - "dprint": "^0.49.0", - "esbuild": "^0.25.0", - "eslint": "^9.20.1", - "eslint-formatter-autolinkable-stylish": "^1.4.0", - "eslint-plugin-regexp": "^2.7.0", - "fast-xml-parser": "^4.5.2", - "glob": "^10.4.5", - "globals": "^15.15.0", - "hereby": "^1.10.0", - "jsonc-parser": "^3.3.1", - "knip": "^5.44.4", - "minimist": "^1.2.8", - "mocha": "^10.8.2", - "mocha-fivemat-progress-reporter": "^0.1.0", - "monocart-coverage-reports": "^2.12.1", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "playwright": "^1.50.1", - "source-map-support": "^0.5.21", - "tslib": "^2.8.1", - "typescript": "^5.7.3", - "typescript-eslint": "^8.24.1", - "which": "^3.0.1" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@monaco-editor/react": { - "resolved": "../../node_modules/.pnpm/@monaco-editor+react@4.7.0_monaco-editor@0.55.1_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/@monaco-editor/react", - "link": true - }, - "node_modules/@tailwindcss/postcss": { - "resolved": "../../node_modules/.pnpm/@tailwindcss+postcss@4.2.2/node_modules/@tailwindcss/postcss", - "link": true - }, - "node_modules/@tauri-apps/api": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+api@2.10.1/node_modules/@tauri-apps/api", - "link": true - }, - "node_modules/@tauri-apps/cli": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+cli@2.10.1/node_modules/@tauri-apps/cli", - "link": true - }, - "node_modules/@tauri-apps/plugin-dialog": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-dialog@2.7.0/node_modules/@tauri-apps/plugin-dialog", - "link": true - }, - "node_modules/@tauri-apps/plugin-fs": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-fs@2.5.0/node_modules/@tauri-apps/plugin-fs", - "link": true - }, - "node_modules/@tauri-apps/plugin-http": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-http@2.5.8/node_modules/@tauri-apps/plugin-http", - "link": true - }, - "node_modules/@tauri-apps/plugin-positioner": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-positioner@2.3.1/node_modules/@tauri-apps/plugin-positioner", - "link": true - }, - "node_modules/@tauri-apps/plugin-process": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-process@2.3.1/node_modules/@tauri-apps/plugin-process", - "link": true - }, - "node_modules/@tauri-apps/plugin-shell": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-shell@2.3.5/node_modules/@tauri-apps/plugin-shell", - "link": true - }, - "node_modules/@tauri-apps/plugin-store": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-store@2.4.2/node_modules/@tauri-apps/plugin-store", - "link": true - }, - "node_modules/@tauri-apps/plugin-updater": { - "resolved": "../../node_modules/.pnpm/@tauri-apps+plugin-updater@2.10.1/node_modules/@tauri-apps/plugin-updater", - "link": true - }, - "node_modules/@types/node": { - "resolved": "../../node_modules/.pnpm/@types+node@20.19.39/node_modules/@types/node", - "link": true - }, - "node_modules/@types/picomatch": { - "resolved": "../../node_modules/.pnpm/@types+picomatch@4.0.3/node_modules/@types/picomatch", - "link": true - }, - "node_modules/@types/react": { - "resolved": "../../node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react", - "link": true - }, - "node_modules/@types/react-dom": { - "resolved": "../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom", - "link": true - }, - "node_modules/@xterm/addon-fit": { - "resolved": "../../node_modules/.pnpm/@xterm+addon-fit@0.11.0/node_modules/@xterm/addon-fit", - "link": true - }, - "node_modules/@xterm/xterm": { - "resolved": "../../node_modules/.pnpm/@xterm+xterm@6.0.0/node_modules/@xterm/xterm", - "link": true - }, - "node_modules/clsx": { - "resolved": "../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx", - "link": true - }, - "node_modules/eslint": { - "resolved": "../../node_modules/.pnpm/eslint@9.39.4_jiti@2.6.1/node_modules/eslint", - "link": true - }, - "node_modules/eslint-config-next": { - "resolved": "../../node_modules/.pnpm/eslint-config-next@16.2.3_@typescript-eslint+parser@8.58.2_eslint@9.39.4_jiti@2.6.1__typescri_xir3oxqdsnhmjdv6i7uhwgobui/node_modules/eslint-config-next", - "link": true - }, - "node_modules/framer-motion": { - "resolved": "../../node_modules/.pnpm/framer-motion@12.38.0_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/framer-motion", - "link": true - }, - "node_modules/lucide-react": { - "resolved": "../../node_modules/.pnpm/lucide-react@1.8.0_react@19.2.4/node_modules/lucide-react", - "link": true - }, - "node_modules/monaco-editor": { - "resolved": "../../node_modules/.pnpm/monaco-editor@0.55.1/node_modules/monaco-editor", - "link": true - }, - "node_modules/next": { - "resolved": "../../node_modules/.pnpm/next@16.2.3_@babel+core@7.29.0_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/next", - "link": true - }, - "node_modules/picomatch": { - "resolved": "../../node_modules/.pnpm/picomatch@4.0.4/node_modules/picomatch", - "link": true - }, - "node_modules/react": { - "resolved": "../../node_modules/.pnpm/react@19.2.4/node_modules/react", - "link": true - }, - "node_modules/react-dom": { - "resolved": "../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom", - "link": true - }, - "node_modules/react-markdown": { - "resolved": "../../node_modules/.pnpm/react-markdown@10.1.0_@types+react@19.2.14_react@19.2.4/node_modules/react-markdown", - "link": true - }, - "node_modules/react-resizable-panels": { - "resolved": "../../node_modules/.pnpm/react-resizable-panels@4.10.0_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/react-resizable-panels", - "link": true - }, - "node_modules/remark-gfm": { - "resolved": "../../node_modules/.pnpm/remark-gfm@4.0.1/node_modules/remark-gfm", - "link": true - }, - "node_modules/tailwind-merge": { - "resolved": "../../node_modules/.pnpm/tailwind-merge@3.5.0/node_modules/tailwind-merge", - "link": true - }, - "node_modules/tailwindcss": { - "resolved": "../../node_modules/.pnpm/tailwindcss@4.2.2/node_modules/tailwindcss", - "link": true - }, - "node_modules/typescript": { - "resolved": "../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript", - "link": true - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", - "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", - "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", - "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", - "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", - "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", - "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", - "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", - "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - } - } -} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 364ad8a3..0936fe70 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@trixty/desktop", - "version": "1.0.10", + "version": "1.0.14", "private": true, "scripts": { "dev": "next dev", @@ -15,6 +15,12 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation-fetch": "^0.215.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.215.0", + "@opentelemetry/sdk-trace-base": "^2.7.0", + "@opentelemetry/sdk-trace-web": "^2.7.0", + "@sentry/nextjs": "^10.50.0", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-fs": "^2.5.0", @@ -26,6 +32,7 @@ "@tauri-apps/plugin-updater": "~2", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "^12.10.2", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "lucide-react": "^1.8.0", @@ -38,7 +45,11 @@ "react-resizable-panels": "^4.10.0", "react-virtuoso": "^4.14.1", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.5.0" + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "y-monaco": "^0.1.6", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.30" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/apps/desktop/sentry.client.config.ts b/apps/desktop/sentry.client.config.ts new file mode 100644 index 00000000..8c525a9c --- /dev/null +++ b/apps/desktop/sentry.client.config.ts @@ -0,0 +1,27 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1.0, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: true, + + replaysOnErrorSampleRate: 1.0, + + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // You can remove this option if you're not planning to use the Sentry browser profiling integration: + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration(), + Sentry.consoleLoggingIntegration({ levels: ["log", "warn", "error"] }), + ], +}); diff --git a/apps/desktop/sentry.edge.config.ts b/apps/desktop/sentry.edge.config.ts new file mode 100644 index 00000000..89c353b0 --- /dev/null +++ b/apps/desktop/sentry.edge.config.ts @@ -0,0 +1,19 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // Enable sending user PII (Personally Identifiable Information) + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: true, +}); diff --git a/apps/desktop/sentry.server.config.ts b/apps/desktop/sentry.server.config.ts new file mode 100644 index 00000000..adb37d35 --- /dev/null +++ b/apps/desktop/sentry.server.config.ts @@ -0,0 +1,18 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // Enable sending user PII (Personally Identifiable Information) + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: true, +}); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 29f4877a..8a2010ae 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -11,12 +11,15 @@ dependencies = [ "grep-searcher", "html2text", "ignore", + "keyring", "log", "notify", "portable-pty", "regex", "reqwest 0.12.28", "scraper", + "sentry", + "sentry-tracing", "serde", "serde_json", "sysinfo", @@ -38,9 +41,19 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "uuid", "walkdir", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -189,6 +202,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.21.7" @@ -610,7 +638,7 @@ dependencies = [ "bitflags 2.11.1", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -791,6 +819,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1153,6 +1191,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1181,6 +1231,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1188,7 +1247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1202,6 +1261,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1249,6 +1314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1491,6 +1557,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -1750,6 +1822,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html2text" version = "0.12.6" @@ -1838,6 +1921,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -1876,6 +1965,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2270,6 +2375,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2569,6 +2684,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2617,6 +2749,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2729,6 +2873,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2753,6 +2918,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2833,8 +3030,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -2852,6 +3068,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2870,18 +3095,72 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3220,7 +3499,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.28.0", "serial2", "shared_library", "shell-words", @@ -3688,17 +3967,21 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3710,6 +3993,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -3844,6 +4128,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4132,6 +4422,114 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sentry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +dependencies = [ + "httpdate", + "native-tls", + "reqwest 0.12.28", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +dependencies = [ + "once_cell", + "rand 0.8.6", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +dependencies = [ + "debugid", + "hex", + "rand 0.8.6", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.228" @@ -5345,6 +5743,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5624,6 +6032,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5701,6 +6118,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.8" @@ -5774,6 +6204,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 72436be2..13bde4fd 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -54,16 +54,27 @@ grep-searcher = "0.1" regex = "1" dirs = "5" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "http2", "charset", "gzip", "brotli"] } -tokio = { version = "1", features = ["time", "rt-multi-thread", "process"] } +tokio = { version = "1", features = ["time", "rt-multi-thread", "process", "net", "io-util"] } +uuid = { version = "1", features = ["v4", "serde"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "registry"] } urlencoding = "2" sysinfo = { version = "0.36.1", default-features = false, features = ["system"] } scraper = "0.19" html2text = "0.12" tauri-plugin-http = "2" notify = "6" +sentry = { version = "0.34", features = ["backtrace", "contexts", "panic", "transport", "reqwest"] } +sentry-tracing = "0.34" url = "2" +# OS-native secret store: macOS Keychain, Windows Credential Manager, +# Linux Secret Service / kwallet. Used to back AI provider API keys +# instead of writing them to `settings.json` plaintext. Pinned to 3.x +# because keyring 4.x pulls `turso` (an embedded SQLite engine) and +# `bon-macros` as transitive deps for its DB-backed unified store — +# none of which we use; our `Entry::new` flow only needs the native +# OS keychain backends that 3.x exposes by default. +keyring = "3" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/apps/desktop/src-tauri/src/cli.rs b/apps/desktop/src-tauri/src/cli.rs index e10f6f6a..edee9a08 100644 --- a/apps/desktop/src-tauri/src/cli.rs +++ b/apps/desktop/src-tauri/src/cli.rs @@ -27,43 +27,38 @@ use std::path::PathBuf; use crate::error::redact_user_paths; -/// Result of parsing argv. When the user didn't supply a path (cold start -/// from the launcher, Start Menu, Dock) every variant falls back to -/// `Empty`, which is how we express "use the normal startup flow". +/// Result of parsing a single workspace path. #[derive(Debug, PartialEq, Eq)] pub enum CliWorkspace { - /// No `--path` or positional argument was given. + /// No path was given. Empty, - /// The user supplied a path and it resolved to an existing directory. + /// User supplied a valid directory path. Path(PathBuf), - /// The user supplied something, but it failed validation. We keep the - /// reason so the setup hook can log it (via `redact_user_paths`) and the - /// frontend can optionally surface a toast later. The path is the raw - /// input, not the resolved form — we might not have been able to - /// resolve it at all (e.g. doesn't exist). + /// User supplied an invalid path or flag value. Invalid { raw: String, reason: String }, } -/// Parses the current process argv and returns the first workspace path -/// it finds (or `Empty`). Split from [`parse_args`] so tests can drive it -/// without shelling out. -pub fn parse_cli_workspace() -> CliWorkspace { +/// Result of parsing argv. +#[derive(Debug, PartialEq, Eq)] +pub struct CliResult { + /// The workspace path to open, if any. + pub workspace: CliWorkspace, + /// The Discord RPC join secret, if any. + pub discord_join_secret: Option, +} + +/// Parses the current process argv and returns the result. +pub fn parse_cli_args() -> CliResult { let raw: Vec = env::args().collect(); parse_args(&raw, env::current_dir().ok()) } -/// Looks for either `--path ` or the first positional argument after -/// the program name (argv[0]). A `--` sentinel terminates flag parsing, so -/// `TrixtyIDE -- --path` treats `--path` as a literal workspace. -/// -/// `cwd` is the current working directory used to resolve relative paths -/// and the `.` shorthand. Threading it through as a parameter (instead of -/// reading `env::current_dir()` inside) keeps the core logic -/// deterministically testable on any platform. -pub fn parse_args(argv: &[String], cwd: Option) -> CliWorkspace { - // Skip argv[0] (the program name). Nothing else to do on a bare launch. +/// Looks for either `--path `, the first positional argument, +/// or Discord RPC join flags. +pub fn parse_args(argv: &[String], cwd: Option) -> CliResult { let mut iter = argv.iter().skip(1).peekable(); - let mut candidate: Option = None; + let mut workspace_candidate: Option = None; + let mut discord_join_secret: Option = None; let mut seen_double_dash = false; while let Some(arg) = iter.next() { @@ -72,43 +67,60 @@ pub fn parse_args(argv: &[String], cwd: Option) -> CliWorkspace { seen_double_dash = true; continue; } + + // Handle Discord RPC join flag + if arg == "--discord-rpc-join-secret" { + if let Some(val) = iter.next() { + discord_join_secret = Some(val.clone()); + continue; + } + } + if let Some(val) = arg.strip_prefix("--discord-rpc-join-secret=") { + discord_join_secret = Some(val.to_string()); + continue; + } + if arg == "--path" { - // `--path X` — take the next token regardless of shape. match iter.next() { Some(value) => { - candidate = Some(value.clone()); - break; + workspace_candidate = Some(value.clone()); + continue; } None => { - return CliWorkspace::Invalid { - raw: "--path".to_string(), - reason: "--path flag requires a value".to_string(), + return CliResult { + workspace: CliWorkspace::Invalid { + raw: "--path".to_string(), + reason: "--path flag requires a value".to_string(), + }, + discord_join_secret, }; } } } if let Some(value) = arg.strip_prefix("--path=") { - candidate = Some(value.to_string()); - break; + workspace_candidate = Some(value.to_string()); + continue; } - // Other `--foo` tokens are not ours; ignore them so future - // Tauri-level flags (`--webview-version`, debug flags added by - // tooling) don't get mis-parsed as workspace paths. + if arg.starts_with("--") { continue; } } - // First positional (or first token after `--`). This is where - // `tide .` and `tide c:\test` land when there's no explicit flag. - candidate = Some(arg.clone()); - break; + + if workspace_candidate.is_none() { + workspace_candidate = Some(arg.clone()); + } } - let Some(raw) = candidate else { - return CliWorkspace::Empty; + let workspace = match workspace_candidate { + Some(raw) => resolve_workspace_path(&raw, cwd.as_deref()), + None => CliWorkspace::Empty, }; - resolve_workspace_path(&raw, cwd.as_deref()) + CliResult { + workspace, + discord_join_secret, + } } /// Resolves a user-supplied path string to an absolute, canonical directory diff --git a/apps/desktop/src-tauri/src/discord_rpc.rs b/apps/desktop/src-tauri/src/discord_rpc.rs new file mode 100644 index 00000000..aeeed926 --- /dev/null +++ b/apps/desktop/src-tauri/src/discord_rpc.rs @@ -0,0 +1,321 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::env; +use std::path::PathBuf; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +#[cfg(unix)] +use tokio::net::UnixStream; +use uuid::Uuid; +use log::{info, warn, error}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::mpsc; + +#[cfg(windows)] +use tokio::net::windows::named_pipe::ClientOptions; + +const CLIENT_ID: &str = "1499852888850301038"; + +#[derive(Debug, Clone, Copy)] +#[repr(u32)] +pub enum OpCode { + Handshake = 0, + Frame = 1, +} + +pub enum RpcMessage { + UpdateActivity(Option), + AcceptJoin(String), + RejectJoin(String), +} + +pub struct DiscordRpc { + client_id: String, + tx: Option>, +} + +enum IpcStream { + #[cfg(windows)] + Windows(tokio::net::windows::named_pipe::NamedPipeClient), + #[cfg(unix)] + Unix(UnixStream), +} + +impl IpcStream { + async fn send(&mut self, opcode: OpCode, payload: &str) -> Result<(), String> { + let mut header = [0u8; 8]; + header[0..4].copy_from_slice(&(opcode as u32).to_le_bytes()); + header[4..8].copy_from_slice(&(payload.len() as u32).to_le_bytes()); + + match self { + #[cfg(windows)] + IpcStream::Windows(s) => { + s.write_all(&header).await.map_err(|e| e.to_string())?; + s.write_all(payload.as_bytes()).await.map_err(|e| e.to_string())?; + } + #[cfg(unix)] + IpcStream::Unix(s) => { + s.write_all(&header).await.map_err(|e| e.to_string())?; + s.write_all(payload.as_bytes()).await.map_err(|e| e.to_string())?; + } + } + Ok(()) + } + + async fn recv(&mut self) -> Result<(u32, String), String> { + let mut header = [0u8; 8]; + match self { + #[cfg(windows)] + IpcStream::Windows(s) => { + s.read_exact(&mut header).await.map_err(|e| e.to_string())?; + } + #[cfg(unix)] + IpcStream::Unix(s) => { + s.read_exact(&mut header).await.map_err(|e| e.to_string())?; + } + } + + let opcode = u32::from_le_bytes([header[0], header[1], header[2], header[3]]); + let length = u32::from_le_bytes([header[4], header[5], header[6], header[7]]); + + let mut payload = vec![0u8; length as usize]; + match self { + #[cfg(windows)] + IpcStream::Windows(s) => { + s.read_exact(&mut payload).await.map_err(|e| e.to_string())?; + } + #[cfg(unix)] + IpcStream::Unix(s) => { + s.read_exact(&mut payload).await.map_err(|e| e.to_string())?; + } + } + + Ok((opcode, String::from_utf8_lossy(&payload).into_owned())) + } +} + +impl DiscordRpc { + pub fn new() -> Self { + Self { + client_id: CLIENT_ID.to_string(), + tx: None, + } + } + + pub fn start(&mut self, app_handle: AppHandle) { + if self.tx.is_some() { + return; + } + + let (tx, mut rx) = mpsc::unbounded_channel::(); + self.tx = Some(tx); + let client_id = self.client_id.clone(); + + tauri::async_runtime::spawn(async move { + loop { + match Self::try_connect(&client_id).await { + Ok(mut stream) => { + info!("[Discord] Connected and handshaked."); + + let _ = Self::do_subscribe(&mut stream, "ACTIVITY_JOIN").await; + let _ = Self::do_subscribe(&mut stream, "ACTIVITY_SPECTATE").await; + let _ = Self::do_subscribe(&mut stream, "ACTIVITY_JOIN_REQUEST").await; + + loop { + tokio::select! { + Some(msg) = rx.recv() => { + let payload = match msg { + RpcMessage::UpdateActivity(activity) => json!({ + "cmd": "SET_ACTIVITY", + "args": { "pid": std::process::id(), "activity": activity }, + "nonce": Uuid::new_v4().to_string() + }).to_string(), + RpcMessage::AcceptJoin(user_id) => json!({ + "cmd": "SEND_ACTIVITY_JOIN_INVITE", + "args": { "user_id": user_id }, + "nonce": Uuid::new_v4().to_string() + }).to_string(), + RpcMessage::RejectJoin(user_id) => json!({ + "cmd": "CLOSE_ACTIVITY_JOIN_REQUEST", + "args": { "user_id": user_id }, + "nonce": Uuid::new_v4().to_string() + }).to_string(), + }; + + if let Err(e) = stream.send(OpCode::Frame, &payload).await { + error!("[Discord] Send failed: {}", e); + break; + } + } + res = stream.recv() => { + match res { + Ok((_opcode, payload)) => { + if let Ok(v) = serde_json::from_str::(&payload) { + if let Some(evt) = v["evt"].as_str() { + match evt { + "ACTIVITY_JOIN" | "ACTIVITY_SPECTATE" | "ACTIVITY_JOIN_REQUEST" => { + let _ = app_handle.emit("discord-rpc-event", v); + } + _ => {} + } + } + } + } + Err(e) => { + error!("[Discord] Receive failed: {}", e); + break; + } + } + } + } + } + } + Err(e) => { + warn!("[Discord] Connection failed: {}. Retrying in 10s...", e); + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + } + } + } + }); + } + + async fn try_connect(client_id: &str) -> Result { + #[cfg(windows)] + { + for i in 0..10 { + let pipe_path = format!(r"\\.\pipe\discord-ipc-{}", i); + if let Ok(client) = ClientOptions::new().open(&pipe_path) { + let mut stream = IpcStream::Windows(client); + if Self::do_handshake(&mut stream, client_id).await.is_ok() { + return Ok(stream); + } + } + } + } + + #[cfg(unix)] + { + let tmp_dirs = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]; + let mut paths = Vec::new(); + for var in tmp_dirs { + if let Ok(val) = env::var(var) { + paths.push(PathBuf::from(val)); + } + } + paths.push(PathBuf::from("/tmp")); + + for path in paths { + for i in 0..10 { + let socket_path = path.join(format!("discord-ipc-{}", i)); + if let Ok(s) = UnixStream::connect(&socket_path).await { + let mut stream = IpcStream::Unix(s); + if Self::do_handshake(&mut stream, client_id).await.is_ok() { + return Ok(stream); + } + } + } + } + } + + Err("Could not find Discord IPC".to_string()) + } + + async fn do_handshake(stream: &mut IpcStream, client_id: &str) -> Result<(), String> { + let payload = json!({ "v": 1, "client_id": client_id }).to_string(); + stream.send(OpCode::Handshake, &payload).await?; + let (_opcode, _response) = stream.recv().await?; + Ok(()) + } + + async fn do_subscribe(stream: &mut IpcStream, evt: &str) -> Result<(), String> { + let payload = json!({ + "cmd": "SUBSCRIBE", + "evt": evt, + "nonce": Uuid::new_v4().to_string() + }).to_string(); + stream.send(OpCode::Frame, &payload).await?; + Ok(()) + } + + pub fn set_activity(&self, activity: Option) -> Result<(), String> { + if let Some(tx) = &self.tx { + tx.send(RpcMessage::UpdateActivity(activity)).map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("RPC not started".to_string()) + } + } + + pub fn accept_join_request(&self, user_id: String) -> Result<(), String> { + if let Some(tx) = &self.tx { + tx.send(RpcMessage::AcceptJoin(user_id)).map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("RPC not started".to_string()) + } + } + + pub fn reject_join_request(&self, user_id: String) -> Result<(), String> { + if let Some(tx) = &self.tx { + tx.send(RpcMessage::RejectJoin(user_id)).map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("RPC not started".to_string()) + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Activity { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub assets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub party: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secrets: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Party { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option<[u32; 2]>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Secrets { + #[serde(skip_serializing_if = "Option::is_none")] + pub join: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub spectate: Option, + #[serde(rename = "match", skip_serializing_if = "Option::is_none")] + pub match_: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Timestamps { + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Assets { + #[serde(skip_serializing_if = "Option::is_none")] + pub large_image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub large_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub small_image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub small_text: Option, +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a63898e3..fc04f159 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod fs_guard; mod fs_watcher; mod http; mod pty; +mod discord_rpc; use cli::CliWorkspace; use error::redact_user_paths; @@ -27,6 +28,7 @@ use sysinfo::System; use tauri::Manager; use tauri_plugin_store::StoreExt; use tokio::process::Command; +use tracing_subscriber::prelude::*; /// Creates a [`tokio::process::Command`] that will NOT show a console window /// on Windows. On other platforms this is equivalent to `Command::new`. @@ -236,6 +238,12 @@ fn new_initial_cli_workspace(path: Option) -> InitialCliWorkspace { InitialCliWorkspace(Arc::new(Mutex::new(path))) } +pub struct InitialJoinSecret(Arc>>); + +fn new_initial_join_secret(secret: Option) -> InitialJoinSecret { + InitialJoinSecret(Arc::new(Mutex::new(secret))) +} + /// Returns (and consumes) the CLI-supplied workspace path. The frontend /// invokes this once during initial load; subsequent calls return `None`. /// @@ -255,10 +263,38 @@ fn take_initial_cli_workspace( Ok(guard.take().map(|p| p.to_string_lossy().into_owned())) } +#[derive(Debug, serde::Deserialize)] +struct SentryContext { + sentry_trace: Option, + baggage: Option, +} + +impl SentryContext { + fn continue_trace(&self, name: &str) -> Option { + let mut headers = Vec::new(); + if let Some(ref trace) = self.sentry_trace { + headers.push(("sentry-trace", trace.as_str())); + } + if let Some(ref baggage) = self.baggage { + headers.push(("baggage", baggage.as_str())); + } + + if headers.is_empty() { + return None; + } + + let tx_ctx = + sentry::TransactionContext::continue_from_headers(name, "tauri.command", headers); + Some(sentry::start_transaction(tx_ctx)) + } +} + struct SystemState { sys: System, } +pub struct DiscordState(pub Arc>); + #[derive(Serialize)] pub struct FileEntry { name: String, @@ -270,7 +306,9 @@ pub struct FileEntry { fn read_directory( path: String, workspace: tauri::State<'_, WorkspaceState>, + _sentry_context: Option, ) -> Result, String> { + let _tx = _sentry_context.and_then(|ctx| ctx.continue_trace("read_directory")); let resolved = resolve_within_workspace(&path, workspace.inner())?; let dir_iter = match fs::read_dir(&resolved) { Ok(iter) => iter, @@ -307,7 +345,12 @@ fn read_directory( const READ_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024; #[tauri::command] -fn read_file(path: String, workspace: tauri::State<'_, WorkspaceState>) -> Result { +fn read_file( + path: String, + workspace: tauri::State<'_, WorkspaceState>, + _sentry_context: Option, +) -> Result { + let _tx = _sentry_context.and_then(|ctx| ctx.continue_trace("read_file")); let resolved = resolve_within_workspace(&path, workspace.inner())?; let metadata = fs::metadata(&resolved).map_err(|e| { let err = format!("Failed to stat file {}: {}", path, e); @@ -352,7 +395,9 @@ fn write_file( path: String, content: String, workspace: tauri::State<'_, WorkspaceState>, + _sentry_context: Option, ) -> Result<(), String> { + let _tx = _sentry_context.and_then(|ctx| ctx.continue_trace("write_file")); let resolved = resolve_within_workspace(&path, workspace.inner())?; fs_atomic::write_atomic(&resolved, content.as_bytes()).map_err(|e| { let err = format!("Failed to write file {}: {}", path, e); @@ -529,7 +574,9 @@ async fn execute_command( command: String, args: Vec, cwd: Option, + _sentry_context: Option, ) -> Result { + let _tx = _sentry_context.and_then(|ctx| ctx.continue_trace("execute_command")); // Spawn the program directly on every platform. The previous Windows // branch wrapped the call in `cmd /C `, which asks // `cmd.exe` to parse `&`, `|`, `^`, `%VAR%`, quoted redirections, etc. @@ -761,6 +808,41 @@ struct GitLogEntry { /// balloon memory. 1000 is well past any UI scroll scenario. const GIT_LOG_MAX_LIMIT: u32 = 1000; +#[tauri::command] +async fn set_discord_activity( + activity: Option, + state: tauri::State<'_, DiscordState>, +) -> Result<(), String> { + let rpc = state.0.lock().await; + rpc.set_activity(activity) +} + +#[tauri::command] +async fn accept_discord_join_request( + user_id: String, + state: tauri::State<'_, DiscordState>, +) -> Result<(), String> { + let rpc = state.0.lock().await; + rpc.accept_join_request(user_id) +} + +#[tauri::command] +async fn reject_discord_join_request( + user_id: String, + state: tauri::State<'_, DiscordState>, +) -> Result<(), String> { + let rpc = state.0.lock().await; + rpc.reject_join_request(user_id) +} + +#[tauri::command] +async fn get_initial_join_secret( + state: tauri::State<'_, InitialJoinSecret>, +) -> Result, String> { + let mut secret = state.0.lock().map_err(|e| e.to_string())?; + Ok(secret.take()) +} + #[tauri::command] async fn git_log(path: String, limit: Option) -> Result, String> { let n = limit.unwrap_or(50).min(GIT_LOG_MAX_LIMIT); @@ -2067,6 +2149,448 @@ async fn ollama_proxy_cancel( Ok(()) } +// ============================================================ +// Cloud streaming (SSE) +// ============================================================ + +/// Same shape as `OllamaStreams` but for the cloud-AI bridge. Each open +/// `cloud_proxy_stream` task registers a oneshot sender keyed by the +/// caller-supplied `streamId` so a follow-up `cloud_proxy_cancel` can +/// terminate it from the renderer. +// Newtype wrapper (not a type alias) so Tauri's TypeId-keyed state map +// can distinguish this from `OllamaStreams`, which has identical shape. +// Two aliases of the same `Arc>` collapse to the same TypeId +// and `.manage()` panics with "state for type ... is already being managed". +#[derive(Clone)] +struct CloudStreams(Arc>>>); + +fn new_cloud_streams() -> CloudStreams { + CloudStreams(Arc::new(Mutex::new(HashMap::new()))) +} + +/// Payload for the `cloud-stream` event. Mirror of `OllamaStreamPayload`'s +/// camelCase wire form. Cloud providers each speak a slightly different +/// JSON shape inside the SSE `data:` lines, so we forward the raw payload +/// (joined by `\n` for multi-line events) and let the per-provider TS +/// adapter extract the delta. Keeping the Rust side provider-agnostic +/// keeps `cloud_proxy_stream` a thin transport that the visual editors +/// and future agent-mode loop can reuse without each gaining its own +/// protocol switch. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CloudStreamPayload { + stream_id: String, + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// Splits an SSE stream buffer into complete events. Per the SSE spec, +/// events are separated by a blank line — `\n\n` or `\r\n\r\n`. Within an +/// event the `data:` lines are concatenated by `\n` (also per spec). We +/// drop everything else (`event:`, `id:`, `retry:`, comments) because none +/// of the four cloud providers in our allow-list use them to convey the +/// generation delta. +/// +/// Pure function so it can be unit tested without standing up a real HTTP +/// stream. Mirrors the Ollama side's `split_ndjson_lines` design. +fn split_sse_events(buffer: &mut String) -> Vec { + let mut events = Vec::new(); + loop { + let crlf = buffer.find("\r\n\r\n"); + let lf = buffer.find("\n\n"); + let (idx, sep_len) = match (crlf, lf) { + (Some(c), Some(l)) if c <= l => (c, 4), + (Some(c), None) => (c, 4), + (_, Some(l)) => (l, 2), + (None, None) => break, + }; + let event = buffer[..idx].to_string(); + buffer.drain(..idx + sep_len); + + let mut data_lines: Vec = Vec::new(); + for raw_line in event.split('\n') { + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + if let Some(rest) = line.strip_prefix("data:") { + // Per SSE: a single space after the colon is part of the + // separator and must be stripped; further whitespace is + // payload. `strip_prefix(" ")` is `Some(rest)` exactly when + // a single leading space exists, so this gives the right + // semantics for both `data:` and `data: ` framings. + let payload = rest.strip_prefix(' ').unwrap_or(rest); + data_lines.push(payload.to_string()); + } + } + if !data_lines.is_empty() { + events.push(data_lines.join("\n")); + } + } + events +} + +/// Streaming sibling of `cloud_proxy`. Subjects every request to the same +/// per-host method + path + header allow-list, then pumps SSE events back +/// to the renderer through `cloud-stream` tagged with the caller-supplied +/// `streamId`. The Rust side is provider-agnostic — each `data:` payload +/// is forwarded verbatim and the TS adapter parses the OpenAI / Anthropic +/// / Gemini-specific JSON shape. +/// +/// Returns `Ok(())` as soon as the cancel handle is registered. The actual +/// transport runs on a detached tokio task so the bridge isn't held open +/// for the duration of a multi-second generation. Transport / size / +/// non-2xx failures are surfaced as `cloud-stream` `error` events. +#[tauri::command] +async fn cloud_proxy_stream( + app: tauri::AppHandle, + streams: tauri::State<'_, CloudStreams>, + stream_id: String, + method: String, + url: String, + headers: Option>, + body: Option, +) -> Result<(), String> { + use tauri::Emitter; + + let policy = validate_cloud_proxy_request(&url, &method)?; + + // Register the cancel handle BEFORE spawning so a fast follow-up + // `cloud_proxy_cancel` arriving between `spawn` and the first poll + // still has an entry to fire. + let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel::<()>(); + { + let mut guard = streams + .0 + .lock() + .map_err(|e| format!("cloud streams mutex poisoned: {}", e))?; + guard.insert(stream_id.clone(), cancel_tx); + } + + let app_for_task = app.clone(); + let streams_for_task = (*streams).clone(); + let stream_id_for_task = stream_id.clone(); + let allowed_headers = policy.allowed_headers; + + tauri::async_runtime::spawn(async move { + let client = http::shared_client(); + let mut request = match method.as_str() { + "POST" => client.post(&url), + "GET" => client.get(&url), + _ => unreachable!("method validated above"), + }; + request = request.timeout(std::time::Duration::from_secs(CLOUD_PROXY_TIMEOUT_SECS)); + + if let Some(pairs) = headers { + for (name, value) in pairs { + let lower = name.to_ascii_lowercase(); + if !allowed_headers.iter().any(|h| *h == lower) { + continue; + } + request = request.header(name, value); + } + } + if let Some(json_body) = body { + request = request.json(&json_body); + } + + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + let _ = app_for_task.emit( + "cloud-stream", + CloudStreamPayload { + stream_id: stream_id_for_task.clone(), + kind: "error", + data: None, + error: Some(format!("request failed: {}", e)), + }, + ); + remove_cloud_stream(&streams_for_task, &stream_id_for_task); + return; + } + }; + + let status = response.status().as_u16(); + if !response.status().is_success() { + // Drain the body once and surface as a single error event so the + // frontend can show a useful message instead of "stream failed". + let body_text = response.text().await.unwrap_or_default(); + let _ = app_for_task.emit( + "cloud-stream", + CloudStreamPayload { + stream_id: stream_id_for_task.clone(), + kind: "error", + data: None, + error: Some(format!("HTTP {}: {}", status, body_text)), + }, + ); + remove_cloud_stream(&streams_for_task, &stream_id_for_task); + return; + } + + let mut response = response; + let mut buffer = String::new(); + let mut total_bytes: usize = 0; + let mut sent_done = false; + + loop { + if cancel_rx.try_recv().is_ok() + || matches!( + cancel_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Closed) + ) + { + break; + } + + let chunk = match response.chunk().await { + Ok(Some(b)) => b, + Ok(None) => break, // EOF + Err(e) => { + let _ = app_for_task.emit( + "cloud-stream", + CloudStreamPayload { + stream_id: stream_id_for_task.clone(), + kind: "error", + data: None, + error: Some(format!("chunk read failed: {}", e)), + }, + ); + remove_cloud_stream(&streams_for_task, &stream_id_for_task); + return; + } + }; + + total_bytes = total_bytes.saturating_add(chunk.len()); + if total_bytes > CLOUD_PROXY_MAX_BODY { + let _ = app_for_task.emit( + "cloud-stream", + CloudStreamPayload { + stream_id: stream_id_for_task.clone(), + kind: "error", + data: None, + error: Some(format!( + "Cloud stream exceeded the {} MiB cap", + CLOUD_PROXY_MAX_BODY / (1024 * 1024) + )), + }, + ); + remove_cloud_stream(&streams_for_task, &stream_id_for_task); + return; + } + + buffer.push_str(&String::from_utf8_lossy(&chunk)); + let events = split_sse_events(&mut buffer); + for data in events { + // OpenAI / OpenRouter terminate with `data: [DONE]`. We + // collapse that to a structured `done` event so the TS + // side doesn't have to special-case the sentinel — and + // future providers that adopt the same convention work + // for free. + if data == "[DONE]" { + let _ = app_for_task.emit( + "cloud-stream", + CloudStreamPayload { + stream_id: stream_id_for_task.clone(), + kind: "done", + data: None, + error: None, + }, + ); + sent_done = true; + break; + } + let _ = app_for_task.emit( + "cloud-stream", + CloudStreamPayload { + stream_id: stream_id_for_task.clone(), + kind: "data", + data: Some(data), + error: None, + }, + ); + } + + if sent_done { + break; + } + } + + if !sent_done { + // Anthropic + Gemini close the connection on completion instead + // of sending a terminal sentinel. Synthesise a `done` event so + // the frontend's awaiter resolves either way. + let _ = app_for_task.emit( + "cloud-stream", + CloudStreamPayload { + stream_id: stream_id_for_task.clone(), + kind: "done", + data: None, + error: None, + }, + ); + } + + remove_cloud_stream(&streams_for_task, &stream_id_for_task); + }); + + Ok(()) +} + +fn remove_cloud_stream(streams: &CloudStreams, stream_id: &str) { + if let Ok(mut guard) = streams.0.lock() { + guard.remove(stream_id); + } +} + +/// Cancels an in-flight `cloud_proxy_stream` task. Idempotent. +#[tauri::command] +async fn cloud_proxy_cancel( + streams: tauri::State<'_, CloudStreams>, + stream_id: String, +) -> Result<(), String> { + let sender = { + let mut guard = streams + .0 + .lock() + .map_err(|e| format!("cloud streams mutex poisoned: {}", e))?; + guard.remove(&stream_id) + }; + if let Some(tx) = sender { + let _ = tx.send(()); + } + Ok(()) +} + +// ============================================================ +// Provider secret storage (OS keychain) +// ============================================================ +// +// Backs the four cloud-AI provider API keys with the OS native secret +// store (macOS Keychain, Windows Credential Manager, Linux Secret +// Service / kwallet) instead of `settings.json` plaintext. The +// `keyring` crate handles the per-platform shimming. +// +// Service name is fixed at `trixty.ide` so the entries are namespaced +// to this app and not visible to other Trixty tooling. Allowed +// providers are pinned to a hard-coded list so a renderer XSS can't +// probe arbitrary keychain entries by passing crafted strings. + +const SECRET_SERVICE: &str = "trixty.ide"; +const SECRET_ALLOWED_PROVIDERS: &[&str] = &["openai", "anthropic", "gemini", "openrouter"]; + +fn validate_secret_provider(provider: &str) -> Result<(), String> { + if SECRET_ALLOWED_PROVIDERS.contains(&provider) { + Ok(()) + } else { + Err(format!( + "Provider {} is not on the secret-store allow-list", + provider + )) + } +} + +fn provider_entry(provider: &str) -> Result { + keyring::Entry::new(SECRET_SERVICE, provider).map_err(|e| format!("keychain: {}", e)) +} + +#[tauri::command] +async fn set_provider_secret(provider: String, secret: String) -> Result<(), String> { + validate_secret_provider(&provider)?; + let entry = provider_entry(&provider)?; + entry + .set_password(&secret) + .map_err(|e| format!("keychain set failed: {}", e)) +} + +/// Returns the stored secret for a provider, or `None` if no entry +/// exists. Distinguishes `NoEntry` from real failures so the caller +/// (settings UI) can render an empty input without surfacing an +/// error toast for the common "never configured" case. +#[tauri::command] +async fn get_provider_secret(provider: String) -> Result, String> { + validate_secret_provider(&provider)?; + let entry = provider_entry(&provider)?; + match entry.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keychain get failed: {}", e)), + } +} + +#[tauri::command] +async fn clear_provider_secret(provider: String) -> Result<(), String> { + validate_secret_provider(&provider)?; + let entry = provider_entry(&provider)?; + match entry.delete_credential() { + Ok(()) => Ok(()), + // Idempotent: clearing a never-set provider is a no-op rather + // than an error so the UI's "Remove" button works the same way + // regardless of prior state. + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("keychain delete failed: {}", e)), + } +} + +/// Probe whether a provider has a stored secret without exposing the +/// secret itself. The settings panel uses this on mount to render the +/// "Configured" badge so the actual key only travels through IPC when +/// the user explicitly clicks reveal or sends a chat. +#[tauri::command] +async fn has_provider_secret(provider: String) -> Result { + validate_secret_provider(&provider)?; + let entry = provider_entry(&provider)?; + match entry.get_password() { + Ok(_) => Ok(true), + Err(keyring::Error::NoEntry) => Ok(false), + Err(e) => Err(format!("keychain probe failed: {}", e)), + } +} + +/// Spawn a new TrixtyIDE process pointing at the given workspace +/// folder. Each instance gets its own JS realm AND its own Rust +/// state, so the existing single-window architecture works without +/// cross-instance synchronization. The new process inherits the +/// current binary path and forwards the workspace via the existing +/// `--path` flag the `cli` module already parses. +/// +/// Validates the path is an absolute directory before spawning so a +/// crafted argument can't trick the launcher into running a sibling +/// binary or exec-ing through a symlink. +#[tauri::command] +async fn spawn_workspace_instance(path: String) -> Result<(), String> { + let candidate = std::path::Path::new(&path); + let resolved = candidate + .canonicalize() + .map_err(|e| format!("invalid path: {}", e))?; + if !resolved.is_dir() { + return Err("path is not a directory".to_string()); + } + + let exe = + std::env::current_exe().map_err(|e| format!("could not resolve current exe: {}", e))?; + + // Use the std-library Command for a fire-and-forget spawn — we + // do not need the tokio variant here because the new process is + // detached, and `silent_command` is reserved for child tasks + // whose stdout we still consume in this binary. + let mut cmd = std::process::Command::new(&exe); + cmd.arg("--path").arg(resolved); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW so the spawn doesn't flash a console; the + // new TrixtyIDE process is GUI-only, same as the current one. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + cmd.spawn().map_err(|e| format!("spawn failed: {}", e))?; + Ok(()) +} + #[tauri::command] fn create_directory( path: String, @@ -2164,19 +2688,42 @@ fn delete_path(path: String, workspace: tauri::State<'_, WorkspaceState>) -> Res #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Initialize Sentry + let dsn = std::env::var("SENTRY_DSN") + .ok() + .or_else(|| option_env!("SENTRY_DSN").map(|s| s.to_string())); + + let _sentry = sentry::init(( + dsn, + sentry::ClientOptions { + release: sentry::release_name!(), + ..Default::default() + }, + )); + + // Configure tracing with Sentry layer + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .with(tracing_subscriber::fmt::layer()) + .with(sentry_tracing::layer()) + .init(); + // Parse the workspace path from argv BEFORE handing control to Tauri. We // want the resulting `PathBuf` in managed state by the time the setup // hook and the `take_initial_cli_workspace` command fire; reading argv // inside `.setup()` would work too, but keeping it at the top keeps the // data flow easy to follow ("CLI arg → managed state → frontend read"). - let cli_workspace = cli::parse_cli_workspace(); - let initial_cli_workspace = match &cli_workspace { - CliWorkspace::Path(p) => Some(p.clone()), - CliWorkspace::Empty => None, - CliWorkspace::Invalid { raw, reason } => { + // Parse the workspace path and Discord join secret from argv BEFORE + // handing control to Tauri. + let cli_result = cli::parse_cli_args(); + let initial_cli_workspace = match &cli_result.workspace { + cli::CliWorkspace::Path(p) => Some(p.clone()), + cli::CliWorkspace::Empty => None, + cli::CliWorkspace::Invalid { raw, reason } => { // Log and fall back to a normal startup rather than aborting. - // Raw input is redacted because it can contain the user's home - // path; the reason string is already redacted in `cli.rs`. warn!( "Ignoring invalid CLI workspace argument {}: {}", redact_user_paths(raw), @@ -2185,6 +2732,7 @@ pub fn run() { None } }; + let initial_join_secret = cli_result.discord_join_secret; // Pre-populate the `WorkspaceState` guard with the CLI path so the // earliest `read_file` / `read_directory` calls from the frontend @@ -2216,28 +2764,17 @@ pub fn run() { ) .build(), ) - .plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .targets([ - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { - file_name: Some("trixty".into()), - }), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), - ]) - .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne) - .max_file_size(1_000_000) // 1MB - .build(), - ) .manage::(new_pty_sessions()) .manage::(new_ollama_streams()) + .manage::(new_cloud_streams()) .manage(Arc::new(Mutex::new(SystemState { sys: System::new_all(), }))) .manage(Arc::new(Mutex::new(FsWatcherState::new()))) .manage::(workspace_state) .manage::(new_initial_cli_workspace(initial_cli_workspace)) + .manage::(new_initial_join_secret(initial_join_secret)) + .manage(DiscordState(Arc::new(tokio::sync::Mutex::new(discord_rpc::DiscordRpc::new())))) .invoke_handler(tauri::generate_handler![ read_directory, read_file, @@ -2293,6 +2830,13 @@ pub fn run() { ollama_proxy_stream, ollama_proxy_cancel, cloud_proxy, + cloud_proxy_stream, + cloud_proxy_cancel, + set_provider_secret, + get_provider_secret, + clear_provider_secret, + has_provider_secret, + spawn_workspace_instance, create_directory, reveal_path, delete_path, @@ -2301,7 +2845,11 @@ pub fn run() { unwatch_all, set_workspace_root, take_initial_cli_workspace, - about::get_trixty_about_info + about::get_trixty_about_info, + set_discord_activity, + accept_discord_join_request, + reject_discord_join_request, + get_initial_join_secret ]) .setup(|app| { // Main window is required — failing fast with a structured error beats @@ -2445,6 +2993,11 @@ pub fn run() { let _ = main_window.set_focus(); }); + // Start Discord RPC background task + let discord_state = app.handle().state::(); + let mut discord_rpc = discord_state.0.blocking_lock(); + discord_rpc.start(app.handle().clone()); + Ok(()) }) .build(tauri::generate_context!()) @@ -2702,3 +3255,99 @@ mod split_ndjson_lines_tests { assert!(buf.is_empty()); } } + +#[cfg(test)] +mod split_sse_events_tests { + use super::split_sse_events; + + #[test] + fn parses_one_complete_event() { + let mut buf = String::from("data: {\"text\":\"hi\"}\n\n"); + let out = split_sse_events(&mut buf); + assert_eq!(out, vec![String::from("{\"text\":\"hi\"}")]); + assert!( + buf.is_empty(), + "complete event should leave empty remainder" + ); + } + + #[test] + fn joins_multiple_data_lines_within_an_event_with_newline() { + let mut buf = String::from("data: line one\ndata: line two\n\n"); + let out = split_sse_events(&mut buf); + assert_eq!(out, vec![String::from("line one\nline two")]); + } + + #[test] + fn skips_event_id_retry_and_comment_lines() { + let mut buf = + String::from("event: ping\nid: 42\nretry: 1000\n: this is a comment\ndata: hello\n\n"); + let out = split_sse_events(&mut buf); + assert_eq!(out, vec![String::from("hello")]); + } + + #[test] + fn drops_events_without_a_data_line() { + let mut buf = String::from("event: ping\n\ndata: real\n\n"); + let out = split_sse_events(&mut buf); + assert_eq!(out, vec![String::from("real")]); + } + + #[test] + fn holds_partial_event_in_buffer_until_next_chunk() { + let mut buf = String::new(); + buf.push_str("data: {\"a\":1}"); + let first = split_sse_events(&mut buf); + assert!(first.is_empty(), "no terminator yet"); + buf.push_str("\n\n"); + let second = split_sse_events(&mut buf); + assert_eq!(second, vec![String::from("{\"a\":1}")]); + } + + #[test] + fn handles_crlf_separators() { + let mut buf = String::from("data: foo\r\n\r\ndata: bar\r\n\r\n"); + let out = split_sse_events(&mut buf); + assert_eq!(out, vec![String::from("foo"), String::from("bar")]); + assert!(buf.is_empty()); + } + + #[test] + fn passes_through_done_sentinel_unchanged() { + let mut buf = String::from("data: [DONE]\n\n"); + let out = split_sse_events(&mut buf); + assert_eq!(out, vec![String::from("[DONE]")]); + } + + #[test] + fn empty_input_is_a_noop() { + let mut buf = String::new(); + let out = split_sse_events(&mut buf); + assert!(out.is_empty()); + assert!(buf.is_empty()); + } + + #[test] + fn handles_back_to_back_events_in_one_chunk() { + let mut buf = String::from("data: {\"a\":1}\n\ndata: {\"a\":2}\n\ndata: [DONE]\n\n"); + let out = split_sse_events(&mut buf); + assert_eq!( + out, + vec![ + String::from("{\"a\":1}"), + String::from("{\"a\":2}"), + String::from("[DONE]"), + ] + ); + } + + #[test] + fn preserves_a_single_leading_space_after_data_colon() { + // Per SSE spec: a single space after `data:` is part of the + // separator, not the payload. Anything beyond that single space is + // payload — so `data: hello` carries ` hello` (one leading space). + let mut buf = String::from("data: hello\n\n"); + let out = split_sse_events(&mut buf); + assert_eq!(out, vec![String::from(" hello")]); + } +} diff --git a/apps/desktop/src/addons/builtin.agent-support/AgentSettings.tsx b/apps/desktop/src/addons/builtin.agent-support/AgentSettings.tsx index 76bcd7c1..936c67aa 100644 --- a/apps/desktop/src/addons/builtin.agent-support/AgentSettings.tsx +++ b/apps/desktop/src/addons/builtin.agent-support/AgentSettings.tsx @@ -114,7 +114,7 @@ const AgentSettings: React.FC = ({ activeTab }) => {
-

Trixty AI Agent

+

{t('welcome.title')} {t('agent.configuration.ai_agent_suffix')}

{t('agent.profile.protected')}

@@ -322,12 +322,10 @@ const AgentSettings: React.FC = ({ activeTab }) => {

- Unlocks cloud providers (OpenAI, Anthropic, Gemini, - OpenRouter) in the chat header and reveals the - Provider Keys submenu where you store API credentials. + {t('agent.configuration.provider_keys_desc')}

@@ -138,10 +225,12 @@ export const ProviderKeysPanel: React.FC = () => { htmlFor={`provider-model-input-${provider}`} className="text-[10px] font-bold text-[#888] uppercase tracking-wider" > - Models + {t('agent.provider-keys.models_label')} - {models.length} entr{models.length === 1 ? "y" : "ies"} + {models.length === 1 + ? t('agent.provider-keys.models_count_singular', { count: 1 }) + : t('agent.provider-keys.models_count_plural', { count: models.length })}
@@ -169,14 +258,13 @@ export const ProviderKeysPanel: React.FC = () => { className="px-3 py-2 rounded-lg text-[11px] font-bold uppercase tracking-wider flex items-center gap-1.5 transition-colors bg-blue-500/15 hover:bg-blue-500/25 text-blue-300 border border-blue-500/30 disabled:opacity-40 disabled:cursor-not-allowed" > - Add + {t('common.add')}
{models.length === 0 ? (

- No models added yet. Add the exact model IDs you want - available in the chat header. + {t('agent.provider-keys.no_models')}

) : (
    @@ -206,7 +294,7 @@ export const ProviderKeysPanel: React.FC = () => {
    - Changes save automatically. + {t('agent.provider-keys.auto_save')}
    ); diff --git a/apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx b/apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx index 6156c6d8..c1090cca 100644 --- a/apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx +++ b/apps/desktop/src/addons/builtin.ai-assistant/AiChatComponent.tsx @@ -24,7 +24,14 @@ import { ToolApprovalPanel } from "./ToolApprovalPanel"; import { classifyToolError, formatToolError, failureKey } from "./toolErrors"; import { extractPlan } from "./planExtractor"; import { streamOllamaChat, type OllamaStreamFinalMessage } from "./ollamaStream"; -import { cloudChat, keyForProvider, type ChatMessage as ProviderChatMessage } from "@/api/providers/client"; +import { + streamCloudChat, + cloudAgentChat, + type ChatMessage as ProviderChatMessage, + type CanonicalHistoryEntry, + type ToolDefinition, +} from "@/api/providers/client"; +import { getProviderSecret, type SecretProvider } from "@/api/providerSecrets"; import { PROVIDERS, PROVIDER_IDS } from "@/api/providers/registry"; type ToolArgs = Record; @@ -537,15 +544,277 @@ const AiChatComponent: React.FC = () => { const controller = new AbortController(); abortControllerRef.current = controller; - // Cloud-provider branch (issue #267). Routes the request through the - // generic `cloud_proxy` instead of Ollama's bridge. Tools / agent / - // streaming are intentionally not wired for cloud yet — every provider - // has a different SSE envelope and tool-call format. Single-shot - // chat works for all four providers we ship today. + // Cloud-provider branch (issue #267). Two sub-paths: + // - chatMode === 'agent' + workspace open → cloud-agent loop with + // IDE_TOOLS, mirroring the Ollama agent pattern but per-provider + // tool serialization (`cloudAgentChat` from providers/client). + // - everything else → streaming text via `cloud_proxy_stream`. const activeProvider = aiSettings.activeProvider ?? "ollama"; + if (activeProvider !== "ollama" && chatMode === "agent" && rootPath) { + try { + const cloudKey = await getProviderSecret(activeProvider as SecretProvider); + if (!cloudKey) { + addMessageToSession(activeSessionId, { + role: "ai", + text: `Error: no API key set for ${activeProvider}. Add one under Settings → Provider Keys.`, + }); + return; + } + const providerModelList = aiSettings.providerModels[activeProvider] ?? []; + let modelToUse = selectedModel; + if (!providerModelList.includes(modelToUse)) { + modelToUse = + aiSettings.lastModelByProvider?.[activeProvider] ?? + providerModelList[0] ?? + ""; + if (!modelToUse) { + addMessageToSession(activeSessionId, { + role: "ai", + text: `Error: no model registered for ${activeProvider}.`, + }); + return; + } + setSelectedModel(modelToUse); + } + + // Build awareness inline — same compute the Ollama branch does + // below. Cloud-agent needs the same workspace/system context the + // local model gets so its tool decisions are grounded. + const systemInfo = cachedSystemInfo ?? (await getSystemInfo()); + const projectStack = cachedStack ?? (await detectProjectStack(rootPath)); + const awarenessBlock = generateAwarenessBlock({ + system: systemInfo, + stack: projectStack, + settings: { + ai: aiSettings, + editor: editorSettings, + system: systemSettings, + locale: locale, + }, + skills: skills.map((s) => ({ + id: s.id, + name: s.name, + active: activeSkills.includes(s.id), + })), + docs: docs.map((d) => ({ + id: d.id, + name: d.name, + active: activeDocs.includes(d.id), + })), + mode: chatMode, + rootPath, + internetAccess: "Enabled (via web_search tool)", + projectTreeSummary: projectTree, + }); + const systemPrompt = getSystemPrompt(); + const workspaceContext = `Workspace Root: ${rootPath}\n`; + const currentContext = currentFile + ? `${t("ai.context.focused_file")}: ${currentFile.path}\n` + : ""; + + const canonicalHistory: CanonicalHistoryEntry[] = [ + { + role: "system", + content: `${systemPrompt}\n\n${awarenessBlock}\n\n${workspaceContext}${currentContext}`, + }, + ]; + for (const m of activeSession.messages) { + if (m.role === "user") { + canonicalHistory.push({ role: "user", content: m.text }); + } else if (m.role === "ai") { + if (m.tool_calls && m.tool_calls.length > 0) { + canonicalHistory.push({ + role: "assistant_with_tools", + content: m.text || "", + tool_calls: m.tool_calls.map((tc) => ({ + id: tc.id || crypto.randomUUID(), + type: "function" as const, + function: { + name: tc.function.name, + // Session messages store args as a parsed object (Ollama + // shape) but the canonical history threads them as a + // JSON-encoded string so the per-provider translator can + // re-emit them in the right wire envelope. + arguments: + typeof tc.function.arguments === "string" + ? tc.function.arguments + : JSON.stringify(tc.function.arguments), + }, + })), + }); + } else { + canonicalHistory.push({ role: "assistant", content: m.text }); + } + } else if (m.role === "tool") { + canonicalHistory.push({ + role: "tool_result", + tool_call_id: m.tool_id || "", + tool_name: "", + content: m.text, + }); + } + } + canonicalHistory.push({ role: "user", content: userMessage }); + + let agentLoop = true; + let iterations = 0; + const MAX_ITERATIONS = aiSettings.deepMode ? 15 : 5; + let agentLastFailureKey: string | null = null; + let agentConsecutiveFailureCount = 0; + + while (agentLoop && iterations < MAX_ITERATIONS) { + if (controller.signal.aborted) return; + iterations += 1; + const result = await cloudAgentChat({ + provider: activeProvider as Exclude, + apiKey: cloudKey, + model: modelToUse, + history: canonicalHistory, + tools: IDE_TOOLS as ToolDefinition[], + temperature: aiSettings.temperature, + maxTokens: aiSettings.maxTokens, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + + if (!result.ok && result.toolCalls.length === 0) { + addMessageToSession(activeSessionId, { + role: "ai", + text: `Error: ${result.error || "cloud provider returned no content"}`, + }); + break; + } + + if (result.toolCalls.length === 0) { + addMessageToSession(activeSessionId, { + role: "ai", + text: result.text, + }); + canonicalHistory.push({ role: "assistant", content: result.text }); + agentLoop = false; + break; + } + + // Tool-call turn — render assistant bubble + execute each call. + // Session messages store args as a parsed object (Ollama shape), + // so we JSON.parse the string the cloud adapter handed us. + const normalizedCalls = result.toolCalls.map((tc) => { + let parsedArgs: ToolArgs; + try { + parsedArgs = JSON.parse(tc.function.arguments || "{}") as ToolArgs; + } catch { + parsedArgs = {} as ToolArgs; + } + return { + function: { name: tc.function.name, arguments: parsedArgs }, + id: tc.id, + type: tc.type, + }; + }); + addMessageToSession(activeSessionId, { + role: "ai", + text: result.text || t("ai.status.interacting"), + tool_calls: normalizedCalls, + }); + canonicalHistory.push({ + role: "assistant_with_tools", + content: result.text, + tool_calls: result.toolCalls, + }); + + let abortedOnRepeat = false; + for (const tc of result.toolCalls) { + if (controller.signal.aborted) return; + const toolName = tc.function.name; + let toolArgs: ToolArgs; + try { + toolArgs = JSON.parse(tc.function.arguments || "{}") as ToolArgs; + } catch { + toolArgs = {} as ToolArgs; + } + const callId = tc.id; + + let effectiveArgs: ToolArgs = toolArgs; + let toolResult: unknown; + if (aiSettings.alwaysAllowTools) { + toolResult = await executeToolInternal(toolName, toolArgs); + } else { + const approval = await requestToolApproval({ + id: callId, + name: toolName, + args: toolArgs, + }); + if (approval.allowed) { + effectiveArgs = approval.args; + toolResult = await executeToolInternal(toolName, effectiveArgs); + } else { + toolResult = t("ai.error.user_denied"); + } + } + + const resultStr = + typeof toolResult === "string" + ? toolResult + : JSON.stringify(toolResult, null, 2); + canonicalHistory.push({ + role: "tool_result", + tool_call_id: callId, + tool_name: toolName, + content: resultStr, + }); + addMessageToSession(activeSessionId, { + role: "tool", + text: resultStr, + tool_id: callId, + }); + + const isFailure = + typeof resultStr === "string" && resultStr.startsWith("= 2) { + abortedOnRepeat = true; + break; + } + } + if (abortedOnRepeat) { + addMessageToSession(activeSessionId, { + role: "warning", + text: t("ai.error.repeat_failure"), + }); + break; + } + } + } catch (err) { + if (!controller.signal.aborted) { + logger.error("[cloud agent] failed:", err); + addMessageToSession(activeSessionId, { + role: "ai", + text: `Error: ${String(err)}`, + }); + } + } finally { + setIsTyping(false); + abortControllerRef.current = null; + } + return; + } + if (activeProvider !== "ollama") { try { - const cloudKey = keyForProvider(aiSettings.providerKeys, activeProvider); + // API key lives in the OS keychain (set via Settings → Provider + // Keys). Fetched per-send rather than cached on the component + // so a key revoked or rotated mid-session takes effect on the + // next message instead of the next page reload. + const cloudKey = await getProviderSecret(activeProvider as SecretProvider); // Guard against a stale `selectedModel` that belongs to a // different provider than the one currently active — e.g. the // user switched provider but never reopened the model menu. @@ -583,26 +852,47 @@ const AiChatComponent: React.FC = () => { })), { role: "user", content: userMessage }, ]; - const result = await cloudChat({ - provider: activeProvider, - apiKey: cloudKey, - model: modelToUse, - messages: cloudHistory, - temperature: aiSettings.temperature, - maxTokens: aiSettings.maxTokens, - signal: controller.signal, - }); + + // Lazily create the assistant bubble on the first delta so we + // don't leave an empty placeholder behind if the request fails + // before any content arrives. Same pattern the Ollama branch + // uses below. + let placeholderPushed = false; + const pushPlaceholderOnce = () => { + if (placeholderPushed) return; + addMessageToSession(activeSessionId, { role: "ai", text: "" }); + placeholderPushed = true; + }; + + const result = await streamCloudChat( + { + provider: activeProvider, + apiKey: cloudKey, + model: modelToUse, + messages: cloudHistory, + temperature: aiSettings.temperature, + maxTokens: aiSettings.maxTokens, + signal: controller.signal, + }, + (delta) => { + pushPlaceholderOnce(); + appendToLastAiMessage(activeSessionId, delta); + }, + ); if (controller.signal.aborted) return; - if (!result.ok) { + if (!result.ok && !placeholderPushed) { addMessageToSession(activeSessionId, { role: "ai", text: `Error: ${result.error || "cloud provider returned no content"}`, }); - } else { - addMessageToSession(activeSessionId, { - role: "ai", - text: result.text, - }); + } else if (!result.ok) { + // Stream started, then failed mid-flight — append the error + // inline so the user can see how far the model got before the + // failure. The bubble already exists so we don't push a new one. + appendToLastAiMessage( + activeSessionId, + `\n\n[Error: ${result.error || "stream interrupted"}]`, + ); } } catch (err) { if (!controller.signal.aborted) { @@ -1034,6 +1324,9 @@ const AiChatComponent: React.FC = () => { const keyMissing = meta.kind === "cloud" && !aiSettings.providerKeys[pid as Exclude]; + + if (keyMissing) return null; + return ( ); })} @@ -1083,7 +1371,7 @@ const AiChatComponent: React.FC = () => { {showModelMenu && ( -
    +
    {t('ai.models.local_title')} {t('ai.models.found', { count: models.length.toString() })} diff --git a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx index 89bcef8a..23f1ca6b 100644 --- a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx +++ b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx @@ -16,8 +16,10 @@ import { useUI } from "@/context/UIContext"; import { useWorkspace } from "@/context/WorkspaceContext"; import { useSettings } from "@/context/SettingsContext"; import { useL10n } from "@/hooks/useL10n"; +import { useCollaboration } from "@/context/CollaborationContext"; import ContextMenu from "@/components/ui/ContextMenu"; import { useClickOutside } from "@/hooks/useClickOutside"; +import { FileIcon } from "@/components/ui/FileIcon"; import { useFocusTrap } from "@/hooks/useFocusTrap"; import { logger } from "@/lib/logger"; import pm from "picomatch"; @@ -58,6 +60,7 @@ const GitExplorerComponent: React.FC = () => { const { rootPath, setRootPath } = useWorkspace(); const { aiSettings, systemSettings } = useSettings(); const { t } = useL10n(); + const { isCollaborating, role, ydoc } = useCollaboration(); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [expandedDirs, setExpandedDirs] = useState>({}); @@ -151,6 +154,39 @@ const GitExplorerComponent: React.FC = () => { }; const loadDirectory = useCallback(async (path: string, parentPath?: string) => { + if (isCollaborating && role === "guest" && ydoc) { + const workspaceMap = ydoc.getMap("workspace"); + const data = workspaceMap.get(path) as FileEntry[]; + + if (data) { + if (!parentPath) { setEntries(data); } else { + setEntries((prev) => { + const update = (items: FileEntry[]): FileEntry[] => items.map((i) => { + if (i.path === path) return { ...i, children: data }; + if (i.children) return { ...i, children: update(i.children) }; + return i; + }); + return update(prev); + }); + } + } else { + // Request from host + const dirRequests = ydoc.getMap("dir-requests"); + dirRequests.set(path, Date.now()); + + // Listen for results + const onSync = () => { + const newData = workspaceMap.get(path); + if (newData) { + loadDirectory(path, parentPath); + workspaceMap.unobserve(onSync); + } + }; + workspaceMap.observe(onSync); + } + return; + } + setLoading(true); try { const data = await invoke("read_directory", { path }); @@ -611,6 +647,27 @@ const GitExplorerComponent: React.FC = () => { setExpandedDirs((p) => ({ ...p, [entry.path]: !exp })); if (!exp && (!entry.children || entry.children.length === 0)) await loadDirectory(entry.path, entry.path); } else { + if (isCollaborating && role === "guest" && ydoc) { + const sharedText = ydoc.getText(`file:${entry.path}`); + if (sharedText.length > 0) { + openFile(entry.path, entry.name, sharedText.toString()); + } else { + // Request from host + const fileRequests = ydoc.getMap("file-requests"); + fileRequests.set(entry.path, Date.now()); + + // Show a temporary loading state or just wait for Yjs sync + const onSync = () => { + if (sharedText.length > 0) { + openFile(entry.path, entry.name, sharedText.toString()); + sharedText.unobserve(onSync); + } + }; + sharedText.observe(onSync); + } + return; + } + const bins = [".png",".jpg",".jpeg",".gif",".exe",".dll",".bin",".zip",".pdf",".ico",".woff",".woff2",".ttf"]; if (bins.some((e) => entry.name.toLowerCase().endsWith(e))) { openFile(entry.path, entry.name, "", "binary"); @@ -685,7 +742,7 @@ const GitExplorerComponent: React.FC = () => { // Section header const Header = ({ title, right }: { title: string; right?: React.ReactNode }) => ( -
    +
    {title} {right}
    @@ -704,7 +761,7 @@ const GitExplorerComponent: React.FC = () => {
    { - if (ev.currentTarget === ev.target) { + if (!(ev.target as Element).closest('[role="treeitem"]')) { ev.preventDefault(); // Right click on empty area - target root if (rootPath) { @@ -772,9 +829,7 @@ const GitExplorerComponent: React.FC = () => { style={{ paddingLeft: `${node.level * 14 + 12}px` }} className="flex items-center py-1 gap-2" > - {node.type === "file" - ? - : } + { `} > {e.is_dir ? (expandedDirs[e.path] ? : ) :
    } - {e.is_dir ? : } + {e.name}
    ); diff --git a/apps/desktop/src/api/builtin.l10n.ts b/apps/desktop/src/api/builtin.l10n.ts index b2c9f0b8..11637546 100644 --- a/apps/desktop/src/api/builtin.l10n.ts +++ b/apps/desktop/src/api/builtin.l10n.ts @@ -47,6 +47,12 @@ export function registerBuiltinTranslations() { 'settings.application.reset_desc': 'Reset all settings, chat history, and configuration. This action cannot be undone.', 'settings.application.reset_button': 'Reset Trixty IDE', 'settings.application.reset_confirm': 'Are you absolutely sure you want to reset everything? Your chats and settings will be lost forever.', + 'settings.application.discord_rpc': 'Discord Rich Presence', + 'settings.application.discord_rpc.enabled': 'Enable Discord Rich Presence', + 'settings.application.discord_rpc.details': 'Show file and project details', + 'settings.application.discord_rpc.collaboration': 'Allow others to request to join', + 'titlebar.collab.start': 'Start Collaboration Session', + 'titlebar.collab.active': 'Collaboration Session Active', 'marketplace.title': 'Marketplace', 'marketplace.desc': 'Discover, install, and manage extensions from Git', 'marketplace.button': 'Open Marketplace', @@ -148,19 +154,63 @@ export function registerBuiltinTranslations() { 'git.reset.confirm_hard': 'Hard reset to {target}? This discards working tree changes.', 'git.revert.confirm': 'Revert commit {hash}?', 'git.stash.drop_confirm': 'Drop stash {ref}?', + + 'titlebar.layout.left': 'Toggle Left Panel', + 'titlebar.layout.bottom': 'Toggle Bottom Panel', + 'titlebar.layout.right': 'Toggle Right Panel', + 'titlebar.layout.zen_enter': 'Enter Zen Mode (Ctrl+K Z)', + 'titlebar.layout.zen_exit': 'Exit Zen Mode (Esc)', + + 'common.remove': 'Remove', + 'common.close': 'Close', + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.add': 'Add', + 'common.no_entries': 'No entries yet.', 'git.status.init_success': 'Repository initialized āœ“', 'git.status.commit_success': 'Commit āœ“', + 'editor.source': 'Source', + + 'visual.gitignore.title': 'Ignore Rules', + 'visual.gitignore.desc': 'Define files and directories that Git should ignore.', + 'visual.gitignore.add': 'Add Rule', + 'visual.gitignore.pattern_placeholder': 'e.g. node_modules/, *.log, .env', + 'visual.gitignore.info': 'Rules are processed top to bottom. Use / to match relative to the root, * as a wildcard, and ** for recursive matching.', + + 'visual.env.title': 'Environment variables', + 'visual.env.desc': 'Edits sync to the source view. Comments and blank lines are preserved.', + 'visual.env.add': 'Add variable', + 'visual.env.key_placeholder': 'KEY', + 'visual.env.value_placeholder': 'value', + 'visual.env.comment_placeholder': 'comment (optional)', + 'visual.env.reveal': 'Reveal value', + 'visual.env.hide': 'Hide value', + + 'visual.package.identity': 'Identity', + 'visual.package.name': 'Name', + 'visual.package.version': 'Version', + 'visual.package.description': 'Description', + 'visual.package.license': 'License', + 'visual.package.author': 'Author', + 'visual.package.scripts': 'Scripts', + 'visual.package.dependencies': 'Dependencies', + 'visual.package.dev_dependencies': 'Dev Dependencies', + 'visual.package.peer_dependencies': 'Peer Dependencies', + + 'visual.gitignore.preserved': '{count} comment / blank line preserved in the source file.', + 'visual.env.preserved': '{count} comment / blank line preserved verbatim.', + 'visual.package.engines': 'Engines', + 'git.status.stash_success': 'Stashed āœ“', + 'git.status.stash_pop_success': 'Stash applied āœ“', + 'git.status.branch_created': 'Branch created āœ“', + 'git.status.all_staged': 'All staged āœ“', + 'git.status.no_staged_changes': '⚠ No staged changes', 'git.status.push_success': 'Push āœ“', 'git.status.pull_success': 'Pull āœ“', 'git.status.fetch_success': 'Fetch āœ“', 'git.status.merge_success': 'Merge āœ“', 'git.status.reset_success': 'Reset āœ“', 'git.status.revert_success': 'Revert āœ“', - 'git.status.stash_success': 'Stashed āœ“', - 'git.status.stash_pop_success': 'Stash applied āœ“', - 'git.status.branch_created': 'Branch created āœ“', - 'git.status.all_staged': 'All staged āœ“', - 'git.status.no_staged_changes': '⚠ No staged changes', 'git.status.checkout_success': 'Switched to "{branch}" āœ“', 'git.branch.detached': '(detached HEAD)', 'git.explorer.safe_dir_title': 'Git detected a permission issue in this folder.', @@ -216,6 +266,18 @@ export function registerBuiltinTranslations() { 'welcome.shortcut.open_folder': 'Open Folder', 'welcome.shortcut.terminal': 'Terminal', + 'extension.approval.title': 'Grant capabilities to {name}', + 'extension.approval.desc': 'This extension is asking to use the following parts of Trixty. Grant only what you trust.', + 'extension.approval.legacy': 'This extension does not declare a capability list. Approving it grants access to every sandbox capability. Prefer extensions whose package.json includes a trixty.capabilities array.', + 'extension.approval.quick_select': 'Quick select:', + 'extension.approval.all': 'all', + 'extension.approval.none': 'none', + 'extension.approval.no_caps': 'This extension requested no capabilities. It will run with no host access.', + 'extension.approval.prev_approved': 'previously approved', + 'extension.approval.prev_denied': 'previously denied', + 'extension.approval.deny_all': 'Deny all and disable', + 'extension.approval.grant': 'Grant selected', + 'status.powered_by': 'Powered by {engine}', 'status.cursor_pos': 'Ln {line}, Col {col}', 'status.indentation': 'Spaces: {count}', @@ -339,8 +401,6 @@ export function registerBuiltinTranslations() { 'common.loading': 'Loading...', 'common.error': 'Error: {message}', - 'common.save': 'Save', - 'common.cancel': 'Cancel', 'common.prev': 'Back', 'common.next': 'Next', 'common.dismiss': 'Dismiss', @@ -425,7 +485,43 @@ export function registerBuiltinTranslations() { 'agent.configuration.loadonstartup_desc': 'Automatically load the selected model when Trixty starts.', 'agent.configuration.loadonstartup_warning': 'Warning: This may cause system slowdown and increase Trixty\'s initial loading time while the model is being loaded into memory.', 'agent.configuration.loadonstartup_ping_desc': 'Trixty will trigger a background ping to load models upon application initialization.', - 'agent.configuration.keepalive_unit': 'minutes' + 'agent.configuration.keepalive_unit': 'minutes', + 'agent.configuration.ai_agent_suffix': 'AI Agent', + 'agent.provider_keys.models_count_singular': '{count} entry', + 'agent.provider_keys.models_count_plural': '{count} entries', + 'agent.configuration.time_m': '{n}m', + 'agent.configuration.time_h': '{n}h', + 'onboarding.language.en_native': 'US English', + 'onboarding.language.es_native': 'Spanish', + 'agent.configuration.provider_keys_label': 'Allow Provider Keys', + 'agent.configuration.provider_keys_desc': 'Unlocks cloud providers (OpenAI, Anthropic, Gemini, OpenRouter) in the chat header and reveals the Provider Keys submenu where you store API credentials.', + 'agent.configuration.inline_suggestions_label': 'Inline code suggestions', + 'agent.configuration.inline_suggestions_desc': 'Ghost-text completions in the editor powered by Ollama (FIM). Tab accepts, Esc dismisses.', + 'agent.configuration.model_override_label': 'Model override', + 'agent.configuration.model_override_placeholder': 'qwen2.5-coder:7b (chat model if empty)', + 'agent.configuration.debounce_label': 'Debounce (ms)', + + 'agent.provider-keys.title': 'Provider Keys', + 'agent.provider-keys.desc': 'Configure API credentials and curated model lists for cloud AI providers.', + 'agent.provider-keys.keychain_info': 'Keys are stored in your OS native secret store — macOS Keychain, Windows Credential Manager, or Linux Secret Service / kwallet — and never written to settings.json.', + 'agent.provider-keys.docs_link': 'Docs', + 'agent.provider-keys.key_set': 'Key set', + 'agent.provider-keys.no_key': 'No key', + 'agent.provider-keys.api_key_label': 'API key', + 'agent.provider-keys.api_key_placeholder': 'Paste your {name} API key', + 'agent.provider-keys.hide_key': 'Hide key', + 'agent.provider-keys.reveal_key': 'Reveal key', + 'agent.provider-keys.models_label': 'Models', + 'agent.provider-keys.models_count_singular': '{count} entry', + 'agent.provider-keys.models_count_plural': '{count} entries', + 'agent.provider-keys.no_models': 'No models added yet. Add the exact model IDs you want available in the chat header.', + 'agent.provider-keys.auto_save': 'Changes save automatically.', + + 'settings.application.language.en': 'English', + 'settings.application.language.es': 'EspaƱol', + 'settings.about.logo_alt': 'Trixty Logo', + 'settings.general.exclude_title_part1': 'Files: ', + 'settings.general.exclude_title_part2': 'Exclude' }); // SPANISH @@ -470,6 +566,12 @@ export function registerBuiltinTranslations() { 'settings.application.reset_desc': 'Restablece todos los ajustes, historial de chats y configuración. Esta acción no se puede deshacer.', 'settings.application.reset_button': 'Restablecer Trixty IDE', 'settings.application.reset_confirm': 'ĀæEstĆ”s absolutamente seguro de que quieres restablecer todo? Tus chats y ajustes se perderĆ”n para siempre.', + 'settings.application.discord_rpc': 'Discord Rich Presence', + 'settings.application.discord_rpc.enabled': 'Habilitar Discord Rich Presence', + 'settings.application.discord_rpc.details': 'Mostrar detalles de archivos y proyecto', + 'settings.application.discord_rpc.collaboration': 'Permitir que otros soliciten unirse', + 'titlebar.collab.start': 'Iniciar sesión de colaboración', + 'titlebar.collab.active': 'Sesión de colaboración activa', 'marketplace.title': 'Marketplace', 'marketplace.desc': 'Descubre, instala y gestiona extensiones desde Git', 'marketplace.button': 'Abrir Marketplace', @@ -571,6 +673,51 @@ export function registerBuiltinTranslations() { 'git.reset.confirm_hard': 'ĀæReset --hard a {target}? Se descartan los cambios del working tree.', 'git.revert.confirm': 'ĀæRevertir el commit {hash}?', 'git.stash.drop_confirm': 'ĀæDescartar el stash {ref}?', + + 'titlebar.layout.left': 'Alternar Panel Izquierdo', + 'titlebar.layout.bottom': 'Alternar Panel Inferior', + 'titlebar.layout.right': 'Alternar Panel Derecho', + 'titlebar.layout.zen_enter': 'Entrar en Modo Zen (Ctrl+K Z)', + 'titlebar.layout.zen_exit': 'Salir de Modo Zen (Esc)', + + 'common.remove': 'Eliminar', + 'common.close': 'Cerrar', + 'common.save': 'Guardar', + 'common.cancel': 'Cancelar', + 'common.add': 'AƱadir', + 'common.no_entries': 'Sin entradas aĆŗn.', + + 'editor.source': 'Código', + + 'visual.gitignore.title': 'Reglas de Ignorado', + 'visual.gitignore.desc': 'Define los archivos y carpetas que Git debe ignorar.', + 'visual.gitignore.add': 'AƱadir Regla', + 'visual.gitignore.pattern_placeholder': 'ej. node_modules/, *.log, .env', + 'visual.gitignore.info': 'Las reglas se procesan de arriba a abajo. Usa / para rutas desde la raĆ­z, * como comodĆ­n, y ** para recursividad.', + + 'visual.env.title': 'Variables de Entorno', + 'visual.env.desc': 'Los cambios se sincronizan con la vista de código. Los comentarios y lĆ­neas en blanco se mantienen.', + 'visual.env.add': 'AƱadir Variable', + 'visual.env.key_placeholder': 'CLAVE', + 'visual.env.value_placeholder': 'valor', + 'visual.env.comment_placeholder': 'comentario (opcional)', + 'visual.env.reveal': 'Ver valor', + 'visual.env.hide': 'Ocultar valor', + + 'visual.package.identity': 'Identidad', + 'visual.package.name': 'Nombre', + 'visual.package.version': 'Versión', + 'visual.package.description': 'Descripción', + 'visual.package.license': 'Licencia', + 'visual.package.author': 'Autor', + 'visual.package.scripts': 'Scripts', + 'visual.package.dependencies': 'Dependencias', + 'visual.package.dev_dependencies': 'Dependencias de Desarrollo', + 'visual.package.peer_dependencies': 'Dependencias Paritarias', + + 'visual.gitignore.preserved': '{count} comentario / lĆ­nea en blanco preservada en el archivo fuente.', + 'visual.env.preserved': '{count} comentario / lĆ­nea en blanco preservada textualmente.', + 'visual.package.engines': 'Motores', 'git.status.init_success': 'Repositorio inicializado āœ“', 'git.status.commit_success': 'Commit realizado āœ“', 'git.status.push_success': 'Push realizado āœ“', @@ -639,6 +786,18 @@ export function registerBuiltinTranslations() { 'welcome.shortcut.open_folder': 'Abrir Carpeta', 'welcome.shortcut.terminal': 'Terminal', + 'extension.approval.title': 'Conceder permisos a {name}', + 'extension.approval.desc': 'Esta extensión solicita usar las siguientes partes de Trixty. Concede solo lo que confĆ­es.', + 'extension.approval.legacy': 'Esta extensión no declara una lista de permisos. Aprobarla concede acceso a todas las capacidades del sandbox. Prefiere extensiones cuyo package.json incluya un array trixty.capabilities.', + 'extension.approval.quick_select': 'Selección rĆ”pida:', + 'extension.approval.all': 'todas', + 'extension.approval.none': 'ninguna', + 'extension.approval.no_caps': 'Esta extensión no solicitó permisos. Se ejecutarĆ” sin acceso al sistema.', + 'extension.approval.prev_approved': 'aprobado previamente', + 'extension.approval.prev_denied': 'denegado previamente', + 'extension.approval.deny_all': 'Denegar todo y desactivar', + 'extension.approval.grant': 'Conceder seleccionados', + 'status.powered_by': 'Potenciado por {engine}', 'status.cursor_pos': 'LĆ­n {line}, Col {col}', 'status.indentation': 'Espacios: {count}', @@ -681,7 +840,6 @@ export function registerBuiltinTranslations() { 'ai.models.found': '{count} encontrados', 'ai.status.interacting': 'Interactuando con el sistema...', 'ai.status.tool_result': 'Resultado de herramienta', - 'ai.error.user_denied': 'El usuario denegó la ejecución de la herramienta.', 'ai.error.request_failed': 'Error en la petición a Ollama', 'ai.auto_execute_label': 'Auto-ejecutar Herramientas', 'ai.auto_execute_desc': 'Permite que la IA ejecute comandos y escriba archivos sin preguntar', @@ -761,8 +919,6 @@ export function registerBuiltinTranslations() { 'common.loading': 'Cargando...', 'common.error': 'Error: {message}', - 'common.save': 'Guardar', - 'common.cancel': 'Cancelar', 'common.prev': 'AtrĆ”s', 'common.next': 'Siguiente', 'common.dismiss': 'Descartar', @@ -847,6 +1003,41 @@ export function registerBuiltinTranslations() { 'agent.configuration.loadonstartup_desc': 'Carga automĆ”ticamente el modelo seleccionado al abrir Trixty.', 'agent.configuration.loadonstartup_warning': 'Aviso: Esto puede provocar ralentización del sistema y hacer que Trixty tarde mĆ”s en cargar mientras el modelo se sube a la memoria.', 'agent.configuration.loadonstartup_ping_desc': 'Trixty enviarĆ” una seƱal en segundo plano para cargar los modelos al iniciar la aplicación.', - 'agent.configuration.keepalive_unit': 'minutos' + 'agent.configuration.keepalive_unit': 'minutos', + 'agent.configuration.provider_keys_label': 'Permitir Claves de Proveedor', + 'agent.configuration.provider_keys_desc': 'Desbloquea proveedores en la nube (OpenAI, Anthropic, Gemini, OpenRouter) en el encabezado del chat y revela el submenĆŗ Claves de Proveedor donde guardas las credenciales API.', + 'agent.configuration.inline_suggestions_label': 'Sugerencias de código en lĆ­nea', + 'agent.configuration.inline_suggestions_desc': 'Completado de texto fantasma en el editor potenciado por Ollama (FIM). Tab acepta, Esc descarta.', + 'agent.configuration.model_override_label': 'Sobrescribir modelo', + 'agent.configuration.model_override_placeholder': 'qwen2.5-coder:7b (modelo de chat si estĆ” vacĆ­o)', + 'agent.configuration.debounce_label': 'Debounce (ms)', + + 'agent.provider-keys.title': 'Claves de Proveedor', + 'agent.provider-keys.desc': 'Configura las credenciales API y las listas de modelos para los proveedores de IA en la nube.', + 'agent.provider-keys.keychain_info': 'Las claves se guardan en el almacĆ©n de secretos nativo de tu SO — macOS Keychain, Windows Credential Manager o Linux Secret Service / kwallet — y nunca se escriben en settings.json.', + 'agent.provider-keys.docs_link': 'Docs', + 'agent.provider-keys.key_set': 'Clave configurada', + 'agent.provider-keys.no_key': 'Sin clave', + 'agent.provider-keys.api_key_label': 'Clave API', + 'agent.provider-keys.api_key_placeholder': 'Pega tu clave API de {name}', + 'agent.provider-keys.hide_key': 'Ocultar clave', + 'agent.provider-keys.reveal_key': 'Ver clave', + 'agent.provider-keys.models_label': 'Modelos', + 'agent.provider-keys.models_count_singular': '{count} entrada', + 'agent.provider-keys.models_count_plural': '{count} entradas', + 'agent.provider-keys.no_models': 'AĆŗn no hay modelos. AƱade los IDs exactos de los modelos que quieras disponibles en el encabezado del chat.', + 'agent.provider-keys.auto_save': 'Los cambios se guardan automĆ”ticamente.', + + 'settings.application.language.en': 'InglĆ©s', + 'settings.application.language.es': 'EspaƱol', + 'settings.about.logo_alt': 'Logo de Trixty', + 'settings.general.exclude_title_part1': 'Archivos: ', + 'settings.general.exclude_title_part2': 'Excluir', + 'agent.configuration.keepalive_unit': 'minutos', + 'agent.configuration.ai_agent_suffix': 'Agente de IA', + 'agent.configuration.time_m': '{n}m', + 'agent.configuration.time_h': '{n}h', + 'onboarding.language.en_native': 'InglĆ©s (EE. UU.)', + 'onboarding.language.es_native': 'EspaƱol', }); } diff --git a/apps/desktop/src/api/crossWindowSync.ts b/apps/desktop/src/api/crossWindowSync.ts new file mode 100644 index 00000000..a3be937a --- /dev/null +++ b/apps/desktop/src/api/crossWindowSync.ts @@ -0,0 +1,103 @@ +"use client"; + +import { isTauri } from "@/api/tauri"; +import { logger } from "@/lib/logger"; + +/** + * Each Tauri WebviewWindow runs its own JS realm. To sync mutable + * state slices (chat history, terminal tabs, …) between the main + * shell and any detached floating window, we broadcast a Tauri event + * tagged with the originating window's session id and ignore loopbacks + * inside the same realm. + * + * `WINDOW_SESSION_ID` is minted once per JS realm — stable for the + * lifetime of the window, but distinct between main + floating + * windows even when they share the same process. + */ +export const WINDOW_SESSION_ID = (() => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Older runtimes without `randomUUID` still expose WebCrypto's + // `getRandomValues`. We use it instead of `Math.random` so the id is + // unpredictable enough that any future security-sensitive use of + // `WINDOW_SESSION_ID` stays safe. + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const buf = new Uint32Array(2); + crypto.getRandomValues(buf); + return `win-${buf[0].toString(36)}${buf[1].toString(36)}-${Date.now()}`; + } + // Last-resort fallback for environments without WebCrypto (very old + // jsdom, etc.). The id is only used to suppress event echos; in this + // path collisions only cause a duplicate apply, which our consumers + // already handle idempotently. + return `win-${Date.now().toString(36)}-${performance.now().toString(36)}`; +})(); + +/** + * Tauri event name used by the cross-window sync channel. `` + * identifies the state slice (e.g. `chat`); the payload always + * carries `{ sender, data }` so receivers can drop their own echos. + */ +const SYNC_EVENT_PREFIX = "trixty:state-sync:"; + +interface SyncPayload { + sender: string; + data: T; +} + +/** + * Broadcast a state slice to every other Tauri window. The current + * window NEVER sees its own broadcast (Tauri's emit doesn't loop back, + * and the sender check below is belt + suspenders for shared-realm + * test harnesses). + * + * Outside Tauri (e.g. `next dev` in a regular browser, vitest) this + * is a no-op so callers don't have to gate every emit on `isTauri()`. + */ +export async function broadcastState(key: string, data: T): Promise { + if (!isTauri()) return; + try { + const { emit } = await import("@tauri-apps/api/event"); + await emit(`${SYNC_EVENT_PREFIX}${key}`, { + sender: WINDOW_SESSION_ID, + data, + } satisfies SyncPayload); + } catch (err) { + logger.debug(`[crossWindowSync] broadcast(${key}) failed:`, err); + } +} + +/** + * Subscribe to broadcasts of a state slice from other Tauri windows. + * Returns an `unlisten` function the caller passes to its useEffect + * cleanup. The handler only runs for events whose `sender` differs + * from the current window's session id, so a window NEVER applies + * its own broadcast. + * + * If the underlying `listen` registration fails (transport down, + * Tauri unavailable) the returned cleanup is still safe to call — + * we resolve a noop instead of throwing. + */ +export async function subscribeToBroadcasts( + key: string, + handler: (data: T) => void, +): Promise<() => void> { + if (!isTauri()) return () => undefined; + try { + const { listen } = await import("@tauri-apps/api/event"); + const unlisten = await listen>( + `${SYNC_EVENT_PREFIX}${key}`, + (event) => { + const payload = event.payload; + if (!payload || typeof payload !== "object") return; + if (payload.sender === WINDOW_SESSION_ID) return; + handler(payload.data); + }, + ); + return unlisten; + } catch (err) { + logger.debug(`[crossWindowSync] subscribe(${key}) failed:`, err); + return () => undefined; + } +} diff --git a/apps/desktop/src/api/floatingWindowRegistry.ts b/apps/desktop/src/api/floatingWindowRegistry.ts index d28c7af8..cf506b48 100644 --- a/apps/desktop/src/api/floatingWindowRegistry.ts +++ b/apps/desktop/src/api/floatingWindowRegistry.ts @@ -2,7 +2,13 @@ import { logger } from "@/lib/logger"; import { isTauri } from "@/api/tauri"; import { trixtyStore } from "@/api/store"; -export type DetachablePanel = "right" | "left"; +export type DetachablePanel = "right" | "left" | "bottom"; + +/** Special viewId reserved for the (single) detachable BottomPanel. The + * shell renders a placeholder when this ID is detached, and the + * floating page recognises it to render `` directly + * instead of going through the regular view registry. */ +export const BOTTOM_PANEL_VIEW_ID = "trixty.builtin.bottom-panel"; export interface DetachedBounds { x: number; diff --git a/apps/desktop/src/api/providerSecrets.ts b/apps/desktop/src/api/providerSecrets.ts new file mode 100644 index 00000000..abc9c446 --- /dev/null +++ b/apps/desktop/src/api/providerSecrets.ts @@ -0,0 +1,79 @@ +"use client"; + +import { safeInvoke as invoke } from "@/api/tauri"; +import { logger } from "@/lib/logger"; + +/** + * Cloud-AI provider IDs whose API keys live in the OS keychain. Mirror + * of the Rust `SECRET_ALLOWED_PROVIDERS` allow-list — keep both in sync + * if you add a provider. + */ +export type SecretProvider = "openai" | "anthropic" | "gemini" | "openrouter"; + +/** + * Stash an API key in the OS keychain. Overwrites any previous value + * silently. Empty strings clear the entry, mirroring the way the old + * `aiSettings.providerKeys` field treated `""` as "no key". + */ +export async function setProviderSecret( + provider: SecretProvider, + secret: string, +): Promise { + if (!secret) { + await clearProviderSecret(provider); + return; + } + await invoke("set_provider_secret", { provider, secret }); +} + +/** + * Retrieve a provider's stored secret. Returns `""` for both + * "never set" and "explicitly empty" so callers can use a single + * truthy check (`if (key) ...`) the same way the old plaintext + * settings field worked. + */ +export async function getProviderSecret( + provider: SecretProvider, +): Promise { + try { + const secret = await invoke("get_provider_secret", { provider }); + return secret ?? ""; + } catch (err) { + logger.warn(`[providerSecrets] read failed for ${provider}:`, err); + return ""; + } +} + +/** + * Probe whether a provider has any stored secret. Cheaper than + * `getProviderSecret` on Linux (libsecret returns the value either way, + * but on macOS this can avoid a Touch-ID prompt) and good enough for + * the "Configured" pill in the Settings UI. + */ +export async function hasProviderSecret( + provider: SecretProvider, +): Promise { + try { + return await invoke("has_provider_secret", { provider }); + } catch (err) { + logger.warn(`[providerSecrets] probe failed for ${provider}:`, err); + return false; + } +} + +export async function clearProviderSecret( + provider: SecretProvider, +): Promise { + try { + await invoke("clear_provider_secret", { provider }); + } catch (err) { + logger.warn(`[providerSecrets] clear failed for ${provider}:`, err); + } +} + +export const SECRET_PROVIDERS: SecretProvider[] = [ + "openai", + "anthropic", + "gemini", + "openrouter", +]; diff --git a/apps/desktop/src/api/providers/client.test.ts b/apps/desktop/src/api/providers/client.test.ts new file mode 100644 index 00000000..284082c8 --- /dev/null +++ b/apps/desktop/src/api/providers/client.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { extractStreamDelta, extractFullResponse } from "./client"; + +describe("extractStreamDelta", () => { + it("returns empty string for the [DONE] sentinel and non-JSON input", () => { + for (const provider of ["openai", "anthropic", "gemini", "openrouter"] as const) { + expect(extractStreamDelta(provider, "[DONE]")).toBe(""); + expect(extractStreamDelta(provider, "")).toBe(""); + expect(extractStreamDelta(provider, "not-json")).toBe(""); + } + }); + + it("extracts choices[0].delta.content for OpenAI / OpenRouter", () => { + const data = JSON.stringify({ + choices: [{ delta: { content: "hello" } }], + }); + expect(extractStreamDelta("openai", data)).toBe("hello"); + expect(extractStreamDelta("openrouter", data)).toBe("hello"); + }); + + it("returns empty for OpenAI events without delta.content (role-only chunks)", () => { + const roleOnly = JSON.stringify({ + choices: [{ delta: { role: "assistant" } }], + }); + expect(extractStreamDelta("openai", roleOnly)).toBe(""); + }); + + it("extracts content_block_delta.text_delta for Anthropic", () => { + const data = JSON.stringify({ + type: "content_block_delta", + delta: { type: "text_delta", text: "world" }, + }); + expect(extractStreamDelta("anthropic", data)).toBe("world"); + }); + + it("ignores Anthropic housekeeping events (message_start, ping, message_stop)", () => { + expect( + extractStreamDelta("anthropic", JSON.stringify({ type: "ping" })), + ).toBe(""); + expect( + extractStreamDelta("anthropic", JSON.stringify({ type: "message_start" })), + ).toBe(""); + expect( + extractStreamDelta("anthropic", JSON.stringify({ type: "message_stop" })), + ).toBe(""); + }); + + it("ignores Anthropic content_block_delta with non-text_delta variants", () => { + // Future-proof: tool_use deltas show up under content_block_delta too + // and must NOT be appended as if they were chat text. + const toolUse = JSON.stringify({ + type: "content_block_delta", + delta: { type: "input_json_delta", partial_json: "{...}" }, + }); + expect(extractStreamDelta("anthropic", toolUse)).toBe(""); + }); + + it("extracts candidates[0].content.parts[*].text for Gemini", () => { + const data = JSON.stringify({ + candidates: [ + { content: { parts: [{ text: "foo" }, { text: " bar" }] } }, + ], + }); + expect(extractStreamDelta("gemini", data)).toBe("foo bar"); + }); + + it("returns empty for Gemini events without text parts", () => { + expect( + extractStreamDelta( + "gemini", + JSON.stringify({ candidates: [{ finishReason: "STOP" }] }), + ), + ).toBe(""); + }); +}); + +describe("extractFullResponse", () => { + it("extracts choices[0].message.content for OpenAI / OpenRouter", () => { + const body = JSON.stringify({ + choices: [{ message: { content: "complete reply" } }], + }); + expect(extractFullResponse("openai", body)).toBe("complete reply"); + expect(extractFullResponse("openrouter", body)).toBe("complete reply"); + }); + + it("joins all content[*].text blocks for Anthropic, ignoring tool_use", () => { + const body = JSON.stringify({ + content: [ + { type: "text", text: "alpha " }, + { type: "tool_use", id: "x", name: "y", input: {} }, + { type: "text", text: "beta" }, + ], + }); + expect(extractFullResponse("anthropic", body)).toBe("alpha beta"); + }); + + it("joins all candidates[*].content.parts[*].text blocks for Gemini", () => { + const body = JSON.stringify({ + candidates: [ + { content: { parts: [{ text: "one " }, { text: "two" }] } }, + { content: { parts: [{ text: " three" }] } }, + ], + }); + expect(extractFullResponse("gemini", body)).toBe("one two three"); + }); + + it("returns empty string for non-JSON or empty bodies", () => { + for (const provider of ["openai", "anthropic", "gemini", "openrouter"] as const) { + expect(extractFullResponse(provider, "")).toBe(""); + expect(extractFullResponse(provider, "garbage")).toBe(""); + } + }); +}); diff --git a/apps/desktop/src/api/providers/client.ts b/apps/desktop/src/api/providers/client.ts index d53aeb31..ce26222e 100644 --- a/apps/desktop/src/api/providers/client.ts +++ b/apps/desktop/src/api/providers/client.ts @@ -1,8 +1,20 @@ "use client"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import { safeInvoke as invoke } from "@/api/tauri"; -import type { ProviderId, ProviderKeys } from "@/context/SettingsContext"; +import type { ProviderId } from "@/context/SettingsContext"; import { logger } from "@/lib/logger"; +import { + type CanonicalHistoryEntry, + type ToolDefinition, + type UnifiedToolCall, + extractToolCallsFromBody, + translateHistoryForProvider, + translateToolsForProvider, +} from "./cloudTools"; + +export type { CanonicalHistoryEntry, ToolDefinition, UnifiedToolCall }; export interface ChatMessage { role: "system" | "user" | "assistant"; @@ -25,14 +37,144 @@ export interface CloudChatResult { error?: string; } +export interface CloudAgentRequest { + provider: Exclude; + apiKey: string; + model: string; + /** Canonical history maintained by the renderer across agent turns. */ + history: CanonicalHistoryEntry[]; + tools: ToolDefinition[]; + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; +} + +export interface CloudAgentResult { + ok: boolean; + text: string; + toolCalls: UnifiedToolCall[]; + error?: string; +} + +interface ProviderRequest { + url: string; + headers: Array<[string, string]>; + body: unknown; +} + /** - * Single-shot non-streaming chat for cloud providers. Streaming for - * cloud is a follow-up — every provider here uses a different SSE - * envelope and the Rust side currently only has a streaming bridge for - * Ollama. Returning the whole reply once the request resolves lets us - * wire all four providers without a per-provider streaming bridge. + * Build the URL / headers / body for a provider chat call. The `stream` + * flag toggles the streaming endpoint (Gemini changes URL; the others + * just set `stream: true` in the body) so the same builder backs both + * `cloudChat` and `streamCloudChat`. */ -export async function cloudChat(req: CloudChatRequest): Promise { +function buildProviderRequest( + req: CloudChatRequest, + stream: boolean, +): ProviderRequest { + switch (req.provider) { + case "openai": + return { + url: "https://api.openai.com/v1/chat/completions", + headers: [ + ["Authorization", `Bearer ${req.apiKey}`], + ["Content-Type", "application/json"], + ], + body: { + model: req.model, + messages: req.messages, + temperature: req.temperature ?? 0.7, + max_tokens: req.maxTokens ?? 2048, + stream, + }, + }; + case "openrouter": + return { + url: "https://openrouter.ai/api/v1/chat/completions", + headers: [ + ["Authorization", `Bearer ${req.apiKey}`], + ["Content-Type", "application/json"], + ["HTTP-Referer", "https://github.com/TrixtyAI/ide"], + ["X-Title", "Trixty IDE"], + ], + body: { + model: req.model, + messages: req.messages, + temperature: req.temperature ?? 0.7, + max_tokens: req.maxTokens ?? 2048, + stream, + }, + }; + case "anthropic": { + // Anthropic separates the system prompt from the messages array. + const systemMessages = req.messages.filter((m) => m.role === "system"); + const conversation = req.messages.filter((m) => m.role !== "system"); + const system = systemMessages + .map((m) => m.content) + .join("\n\n") + .trim(); + return { + url: "https://api.anthropic.com/v1/messages", + headers: [ + ["x-api-key", req.apiKey], + ["anthropic-version", "2023-06-01"], + ["Content-Type", "application/json"], + ], + body: { + model: req.model, + max_tokens: req.maxTokens ?? 2048, + temperature: req.temperature ?? 0.7, + system: system || undefined, + messages: conversation.map((m) => ({ + role: m.role === "assistant" ? "assistant" : "user", + content: m.content, + })), + stream, + }, + }; + } + case "gemini": { + // Gemini supports the API key either as a query param or the + // `x-goog-api-key` header. We use the header form so the key never + // shows up in URL logs (Tauri's `e.to_string()` on a transport + // failure echoes the URL, OS-level proxies log query strings, etc.). + const path = stream ? "streamGenerateContent?alt=sse" : "generateContent"; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent( + req.model, + )}:${path}`; + const systemMessages = req.messages.filter((m) => m.role === "system"); + const conversation = req.messages.filter((m) => m.role !== "system"); + const systemInstruction = systemMessages.length + ? { + role: "user", + parts: [ + { text: systemMessages.map((m) => m.content).join("\n\n") }, + ], + } + : undefined; + return { + url, + headers: [ + ["x-goog-api-key", req.apiKey], + ["Content-Type", "application/json"], + ], + body: { + contents: conversation.map((m) => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })), + ...(systemInstruction ? { systemInstruction } : {}), + generationConfig: { + temperature: req.temperature ?? 0.7, + maxOutputTokens: req.maxTokens ?? 2048, + }, + }, + }; + } + } +} + +function validateRequest(req: CloudChatRequest): CloudChatResult | null { if (req.signal?.aborted) return { ok: false, text: "", error: "aborted" }; if (!req.apiKey) { return { @@ -48,24 +190,40 @@ export async function cloudChat(req: CloudChatRequest): Promise error: `No model selected for ${req.provider}`, }; } + return null; +} + +/** + * Single-shot non-streaming chat for cloud providers. Kept for callers + * that need the full reply atomically (e.g. background tool-summary + * jobs). For interactive chat use `streamCloudChat`, which threads + * tokens into the UI as they arrive. + */ +export async function cloudChat(req: CloudChatRequest): Promise { + const invalid = validateRequest(req); + if (invalid) return invalid; try { - switch (req.provider) { - case "openai": - return await chatOpenAICompatible( - "https://api.openai.com/v1/chat/completions", - req, - ); - case "openrouter": - return await chatOpenAICompatible( - "https://openrouter.ai/api/v1/chat/completions", - req, - ); - case "anthropic": - return await chatAnthropic(req); - case "gemini": - return await chatGemini(req); + const config = buildProviderRequest(req, false); + const result = await invoke( + "cloud_proxy", + { + method: "POST", + url: config.url, + headers: config.headers, + body: config.body, + }, + { silent: true }, + ); + if (result.status < 200 || result.status >= 300) { + return { + ok: false, + text: "", + error: `${req.provider} HTTP ${result.status}: ${truncate(result.body, 240)}`, + }; } + const text = extractFullResponse(req.provider, result.body); + return { ok: text.length > 0, text }; } catch (err) { if (req.signal?.aborted) return { ok: false, text: "", error: "aborted" }; logger.warn(`[providers/${req.provider}] chat failed:`, err); @@ -74,174 +232,371 @@ export async function cloudChat(req: CloudChatRequest): Promise } /** - * OpenAI / OpenRouter share the `/v1/chat/completions` shape. OpenRouter - * also requires the standard `Authorization: Bearer KEY` header — the - * `HTTP-Referer` and `X-Title` headers are optional metadata that - * surface the app name in OpenRouter's dashboards. + * One-shot agent turn for cloud providers. Sends a tool-aware request + * and returns the model's reply along with any unified tool calls so + * the renderer's existing agent loop (the one used for Ollama) can + * execute the tools and re-enter on the next turn with the results + * folded into the canonical history. + * + * Streaming with tool-call deltas is intentionally out of scope — + * each provider streams partial-arguments differently (OpenAI ships + * `delta.tool_calls[].function.arguments` chunks, Anthropic streams + * `input_json_delta` blocks, Gemini doesn't really stream tool calls + * at all). Doing it well needs four bespoke parsers; doing it badly + * means broken arguments. Single-shot per turn is the predictable + * baseline; the next iteration can layer streaming on without + * changing the renderer's loop shape. */ -async function chatOpenAICompatible( - url: string, - req: CloudChatRequest, -): Promise { - const headers: Array<[string, string]> = [ - ["Authorization", `Bearer ${req.apiKey}`], - ["Content-Type", "application/json"], - ]; - if (req.provider === "openrouter") { - headers.push(["HTTP-Referer", "https://github.com/TrixtyAI/ide"]); - headers.push(["X-Title", "Trixty IDE"]); - } - const result = await invoke( - "cloud_proxy", - { - method: "POST", - url, - headers, - body: { - model: req.model, - messages: req.messages, - temperature: req.temperature ?? 0.7, - max_tokens: req.maxTokens ?? 2048, +export async function cloudAgentChat( + req: CloudAgentRequest, +): Promise { + const invalid = validateAgentRequest(req); + if (invalid) return invalid; + + try { + const config = buildAgentProviderRequest(req); + const result = await invoke( + "cloud_proxy", + { + method: "POST", + url: config.url, + headers: config.headers, + body: config.body, }, - }, - { silent: true }, - ); - if (result.status < 200 || result.status >= 300) { + { silent: true }, + ); + if (result.status < 200 || result.status >= 300) { + return { + ok: false, + text: "", + toolCalls: [], + error: `${req.provider} HTTP ${result.status}: ${truncate(result.body, 240)}`, + }; + } + const text = extractFullResponse(req.provider, result.body); + const toolCalls = extractToolCallsFromBody(req.provider, result.body); + return { + ok: text.length > 0 || toolCalls.length > 0, + text, + toolCalls, + }; + } catch (err) { + if (req.signal?.aborted) { + return { + ok: false, + text: "", + toolCalls: [], + error: "aborted", + }; + } + logger.warn(`[providers/${req.provider}] agent chat failed:`, err); return { ok: false, text: "", - error: `${req.provider} HTTP ${result.status}: ${truncate(result.body, 240)}`, + toolCalls: [], + error: String(err), }; } - const parsed = JSON.parse(result.body) as { - choices?: { message?: { content?: string } }[]; - }; - const text = parsed.choices?.[0]?.message?.content ?? ""; - return { ok: text.length > 0, text }; -} - -async function chatAnthropic(req: CloudChatRequest): Promise { - // Anthropic separates the system prompt from the messages array. Pull - // any leading system message into the dedicated `system` field. - const systemMessages = req.messages.filter((m) => m.role === "system"); - const conversation = req.messages.filter((m) => m.role !== "system"); - const system = systemMessages.map((m) => m.content).join("\n\n").trim(); - const result = await invoke( - "cloud_proxy", - { - method: "POST", - url: "https://api.anthropic.com/v1/messages", - headers: [ - ["x-api-key", req.apiKey], - ["anthropic-version", "2023-06-01"], - ["Content-Type", "application/json"], - ], - body: { - model: req.model, - max_tokens: req.maxTokens ?? 2048, - temperature: req.temperature ?? 0.7, - system: system || undefined, - messages: conversation.map((m) => ({ - role: m.role === "assistant" ? "assistant" : "user", - content: m.content, - })), - }, - }, - { silent: true }, - ); - if (result.status < 200 || result.status >= 300) { +} + +function validateAgentRequest(req: CloudAgentRequest): CloudAgentResult | null { + if (req.signal?.aborted) + return { ok: false, text: "", toolCalls: [], error: "aborted" }; + if (!req.apiKey) { return { ok: false, text: "", - error: `anthropic HTTP ${result.status}: ${truncate(result.body, 240)}`, + toolCalls: [], + error: `Missing API key for ${req.provider}`, }; } - const parsed = JSON.parse(result.body) as { - content?: { type: string; text?: string }[]; - }; - const text = (parsed.content ?? []) - .filter((b) => b.type === "text") - .map((b) => b.text ?? "") - .join(""); - return { ok: text.length > 0, text }; -} - -async function chatGemini(req: CloudChatRequest): Promise { - // Gemini supports the API key either as a query param or the - // `x-goog-api-key` header. We use the header form so the key never - // shows up in URL logs (Tauri's `e.to_string()` on a transport - // failure echoes the URL, OS-level proxies log query strings, etc.). - // Body uses Gemini's `contents` shape with `user` / `model` roles. - const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent( - req.model, - )}:generateContent`; - const systemMessages = req.messages.filter((m) => m.role === "system"); - const conversation = req.messages.filter((m) => m.role !== "system"); - const systemInstruction = systemMessages.length - ? { - role: "user", - parts: [{ text: systemMessages.map((m) => m.content).join("\n\n") }], - } - : undefined; - const result = await invoke( - "cloud_proxy", - { - method: "POST", - url, - headers: [ - ["x-goog-api-key", req.apiKey], - ["Content-Type", "application/json"], - ], - body: { - contents: conversation.map((m) => ({ - role: m.role === "assistant" ? "model" : "user", - parts: [{ text: m.content }], - })), - ...(systemInstruction ? { systemInstruction } : {}), - generationConfig: { - temperature: req.temperature ?? 0.7, - maxOutputTokens: req.maxTokens ?? 2048, - }, - }, - }, - { silent: true }, - ); - if (result.status < 200 || result.status >= 300) { + if (!req.model) { return { ok: false, text: "", - error: `gemini HTTP ${result.status}: ${truncate(result.body, 240)}`, + toolCalls: [], + error: `No model selected for ${req.provider}`, }; } - const parsed = JSON.parse(result.body) as { - candidates?: { content?: { parts?: { text?: string }[] } }[]; + return null; +} + +function buildAgentProviderRequest(req: CloudAgentRequest): ProviderRequest { + const translated = translateHistoryForProvider(req.provider, req.history); + const tools = translateToolsForProvider(req.provider, req.tools); + switch (req.provider) { + case "openai": + return { + url: "https://api.openai.com/v1/chat/completions", + headers: [ + ["Authorization", `Bearer ${req.apiKey}`], + ["Content-Type", "application/json"], + ], + body: { + model: req.model, + messages: translated.messages, + temperature: req.temperature ?? 0.7, + max_tokens: req.maxTokens ?? 2048, + tools, + }, + }; + case "openrouter": + return { + url: "https://openrouter.ai/api/v1/chat/completions", + headers: [ + ["Authorization", `Bearer ${req.apiKey}`], + ["Content-Type", "application/json"], + ["HTTP-Referer", "https://github.com/TrixtyAI/ide"], + ["X-Title", "Trixty IDE"], + ], + body: { + model: req.model, + messages: translated.messages, + temperature: req.temperature ?? 0.7, + max_tokens: req.maxTokens ?? 2048, + tools, + }, + }; + case "anthropic": + return { + url: "https://api.anthropic.com/v1/messages", + headers: [ + ["x-api-key", req.apiKey], + ["anthropic-version", "2023-06-01"], + ["Content-Type", "application/json"], + ], + body: { + model: req.model, + max_tokens: req.maxTokens ?? 2048, + temperature: req.temperature ?? 0.7, + system: translated.system, + messages: translated.messages, + tools, + }, + }; + case "gemini": { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent( + req.model, + )}:generateContent`; + return { + url, + headers: [ + ["x-goog-api-key", req.apiKey], + ["Content-Type", "application/json"], + ], + body: { + contents: translated.contents, + ...(translated.systemInstruction + ? { systemInstruction: translated.systemInstruction } + : {}), + generationConfig: { + temperature: req.temperature ?? 0.7, + maxOutputTokens: req.maxTokens ?? 2048, + }, + ...(tools ? { tools } : {}), + }, + }; + } + } +} + +/** + * Streaming chat for cloud providers. Emits each token to `onDelta` as + * it arrives and resolves with the full concatenated text on completion. + * The Rust `cloud_proxy_stream` command pumps SSE events back through + * the `cloud-stream` Tauri event keyed by a UUID `streamId`. + * + * Cancellation: if `req.signal` aborts mid-stream the helper fires + * `cloud_proxy_cancel` so the tokio task tears down before more chunks + * arrive, then re-throws an `AbortError` so callers can branch the same + * way they do for `streamOllamaChat`. + */ +export async function streamCloudChat( + req: CloudChatRequest, + onDelta: (text: string) => void, +): Promise { + const invalid = validateRequest(req); + if (invalid) return invalid; + + const config = buildProviderRequest(req, true); + const streamId = crypto.randomUUID(); + + let fullText = ""; + let errorText: string | undefined; + let unlisten: UnlistenFn | undefined; + let aborted = false; + + const settled = new Promise((resolve, reject) => { + listen("cloud-stream", (event) => { + const payload = event.payload; + if (payload.streamId !== streamId) return; + if (payload.kind === "data") { + const delta = extractStreamDelta(req.provider, payload.data ?? ""); + if (delta) { + fullText += delta; + onDelta(delta); + } + return; + } + if (payload.kind === "done") { + resolve(); + return; + } + if (payload.kind === "error") { + errorText = payload.error ?? "Unknown streaming error"; + reject(new Error(errorText)); + } + }).then((u) => { + unlisten = u; + }); + }); + + const onAbort = () => { + aborted = true; + tauriInvoke("cloud_proxy_cancel", { streamId }).catch((err) => { + logger.debug("[providers/cloud] cancel failed:", err); + }); }; - const text = (parsed.candidates ?? []) - .flatMap((c) => c.content?.parts ?? []) - .map((p) => p.text ?? "") - .join(""); - return { ok: text.length > 0, text }; + if (req.signal?.aborted) { + onAbort(); + } else if (req.signal) { + req.signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + await tauriInvoke("cloud_proxy_stream", { + streamId, + method: "POST", + url: config.url, + headers: config.headers, + body: config.body, + }); + await settled; + return { ok: fullText.length > 0, text: fullText }; + } catch (err) { + if (aborted) { + const abortError = new Error("Aborted"); + abortError.name = "AbortError"; + throw abortError; + } + return { + ok: false, + text: fullText, + error: errorText ?? (err instanceof Error ? err.message : String(err)), + }; + } finally { + if (unlisten) unlisten(); + if (req.signal) req.signal.removeEventListener("abort", onAbort); + } } -function truncate(s: string, n: number): string { - return s.length > n ? `${s.slice(0, n)}…` : s; +interface CloudStreamEvent { + streamId: string; + kind: "data" | "done" | "error"; + data?: string; + error?: string; } -/** Resolve the right key for a cloud provider, returning `""` for Ollama. */ -export function keyForProvider( - keys: ProviderKeys, - provider: ProviderId, +/** + * Per-provider parse of one SSE `data:` payload into the delta text to + * append. Returns `""` for keep-alive / housekeeping events (Anthropic + * `ping`, message_start / stop, etc.) so the caller can ignore them. + */ +export function extractStreamDelta( + provider: Exclude, + raw: string, ): string { + if (!raw || raw === "[DONE]") return ""; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return ""; + } + if (typeof parsed !== "object" || parsed === null) return ""; + const obj = parsed as Record; + switch (provider) { case "openai": - return keys.openai; - case "anthropic": - return keys.anthropic; - case "gemini": - return keys.gemini; - case "openrouter": - return keys.openrouter; - default: - return ""; + case "openrouter": { + const choices = obj.choices as + | Array<{ delta?: { content?: string } }> + | undefined; + return choices?.[0]?.delta?.content ?? ""; + } + case "anthropic": { + if (obj.type !== "content_block_delta") return ""; + const delta = obj.delta as + | { type?: string; text?: string } + | undefined; + if (delta?.type !== "text_delta") return ""; + return delta.text ?? ""; + } + case "gemini": { + const candidates = obj.candidates as + | Array<{ content?: { parts?: Array<{ text?: string }> } }> + | undefined; + return ( + candidates + ?.flatMap((c) => c.content?.parts ?? []) + .map((p) => p.text ?? "") + .join("") ?? "" + ); + } } } + +/** + * Per-provider parse of a non-streaming JSON body. Mirror of + * `extractStreamDelta`'s switch but for the full-response shape. Kept + * exported so unit tests can hit each branch without the proxy bridge. + */ +export function extractFullResponse( + provider: Exclude, + body: string, +): string { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return ""; + } + if (typeof parsed !== "object" || parsed === null) return ""; + const obj = parsed as Record; + + switch (provider) { + case "openai": + case "openrouter": { + const choices = obj.choices as + | Array<{ message?: { content?: string } }> + | undefined; + return choices?.[0]?.message?.content ?? ""; + } + case "anthropic": { + const content = obj.content as + | Array<{ type: string; text?: string }> + | undefined; + return ( + content + ?.filter((b) => b.type === "text") + .map((b) => b.text ?? "") + .join("") ?? "" + ); + } + case "gemini": { + const candidates = obj.candidates as + | Array<{ content?: { parts?: Array<{ text?: string }> } }> + | undefined; + return ( + candidates + ?.flatMap((c) => c.content?.parts ?? []) + .map((p) => p.text ?? "") + .join("") ?? "" + ); + } + } +} + +function truncate(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n)}…` : s; +} diff --git a/apps/desktop/src/api/providers/cloudTools.test.ts b/apps/desktop/src/api/providers/cloudTools.test.ts new file mode 100644 index 00000000..14cab1d1 --- /dev/null +++ b/apps/desktop/src/api/providers/cloudTools.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; +import { + type CanonicalHistoryEntry, + type ToolDefinition, + extractToolCallsFromBody, + translateHistoryForProvider, + translateToolsForProvider, +} from "./cloudTools"; + +const SAMPLE_TOOLS: ToolDefinition[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, +]; + +describe("translateToolsForProvider", () => { + it("passes OpenAI / OpenRouter tools through unchanged", () => { + expect(translateToolsForProvider("openai", SAMPLE_TOOLS)).toEqual(SAMPLE_TOOLS); + expect(translateToolsForProvider("openrouter", SAMPLE_TOOLS)).toEqual(SAMPLE_TOOLS); + }); + + it("flattens the function envelope into the Anthropic shape", () => { + expect(translateToolsForProvider("anthropic", SAMPLE_TOOLS)).toEqual([ + { + name: "read_file", + description: "Read a file", + input_schema: SAMPLE_TOOLS[0].function.parameters, + }, + ]); + }); + + it("buckets all declarations under one Gemini outer entry", () => { + expect(translateToolsForProvider("gemini", SAMPLE_TOOLS)).toEqual([ + { + functionDeclarations: [ + { + name: "read_file", + description: "Read a file", + parameters: SAMPLE_TOOLS[0].function.parameters, + }, + ], + }, + ]); + }); + + it("returns undefined when no tools are provided", () => { + expect(translateToolsForProvider("openai", undefined)).toBeUndefined(); + expect(translateToolsForProvider("anthropic", [])).toBeUndefined(); + }); +}); + +describe("extractToolCallsFromBody", () => { + it("reads OpenAI / OpenRouter tool_calls", () => { + const body = JSON.stringify({ + choices: [ + { + message: { + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "read_file", arguments: '{"path":"a.ts"}' }, + }, + ], + }, + }, + ], + }); + expect(extractToolCallsFromBody("openai", body)).toEqual([ + { + id: "call_1", + type: "function", + function: { name: "read_file", arguments: '{"path":"a.ts"}' }, + }, + ]); + }); + + it("reads Anthropic tool_use blocks and re-encodes input as JSON", () => { + const body = JSON.stringify({ + content: [ + { type: "text", text: "Reading file…" }, + { + type: "tool_use", + id: "toolu_1", + name: "read_file", + input: { path: "a.ts" }, + }, + ], + }); + expect(extractToolCallsFromBody("anthropic", body)).toEqual([ + { + id: "toolu_1", + type: "function", + function: { name: "read_file", arguments: '{"path":"a.ts"}' }, + }, + ]); + }); + + it("synthesises ids for Gemini functionCall parts", () => { + const body = JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "read_file", args: { path: "a.ts" } }, + }, + ], + }, + }, + ], + }); + const calls = extractToolCallsFromBody("gemini", body); + expect(calls).toHaveLength(1); + expect(calls[0].function).toEqual({ + name: "read_file", + arguments: '{"path":"a.ts"}', + }); + expect(calls[0].id).toMatch(/^call_/); + }); + + it("returns an empty array for text-only or malformed bodies", () => { + for (const provider of [ + "openai", + "anthropic", + "gemini", + "openrouter", + ] as const) { + expect(extractToolCallsFromBody(provider, "{}")).toEqual([]); + expect(extractToolCallsFromBody(provider, "")).toEqual([]); + expect(extractToolCallsFromBody(provider, "garbage")).toEqual([]); + } + }); +}); + +describe("translateHistoryForProvider", () => { + const history: CanonicalHistoryEntry[] = [ + { role: "system", content: "You are a helpful agent." }, + { role: "user", content: "List the workspace." }, + { + role: "assistant_with_tools", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "list_directory", arguments: '{"path":"."}' }, + }, + ], + }, + { + role: "tool_result", + tool_call_id: "call_1", + tool_name: "list_directory", + content: "[\"src\",\"package.json\"]", + }, + { role: "assistant", content: "Two entries: src and package.json." }, + ]; + + it("emits the OpenAI message ladder with system inlined and tool roles", () => { + const out = translateHistoryForProvider("openai", history); + expect(out.messages).toEqual([ + { role: "system", content: "You are a helpful agent." }, + { role: "user", content: "List the workspace." }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "list_directory", arguments: '{"path":"."}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + content: "[\"src\",\"package.json\"]", + }, + { role: "assistant", content: "Two entries: src and package.json." }, + ]); + }); + + it("emits the Anthropic shape with system separate and tool blocks", () => { + const out = translateHistoryForProvider("anthropic", history); + expect(out.system).toBe("You are a helpful agent."); + expect(out.messages).toEqual([ + { role: "user", content: "List the workspace." }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_1", + name: "list_directory", + input: { path: "." }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "[\"src\",\"package.json\"]", + }, + ], + }, + { role: "assistant", content: "Two entries: src and package.json." }, + ]); + }); + + it("emits the Gemini shape with systemInstruction separate", () => { + const out = translateHistoryForProvider("gemini", history); + expect(out.systemInstruction).toEqual({ + role: "user", + parts: [{ text: "You are a helpful agent." }], + }); + expect(out.contents).toEqual([ + { role: "user", parts: [{ text: "List the workspace." }] }, + { + role: "model", + parts: [ + { + functionCall: { name: "list_directory", args: { path: "." } }, + }, + ], + }, + { + role: "user", + parts: [ + { + functionResponse: { + name: "list_directory", + response: ["src", "package.json"], + }, + }, + ], + }, + { + role: "model", + parts: [{ text: "Two entries: src and package.json." }], + }, + ]); + }); + + it("wraps non-object Gemini tool responses under `result`", () => { + const out = translateHistoryForProvider("gemini", [ + { + role: "tool_result", + tool_call_id: "x", + tool_name: "echo", + content: "plain string", + }, + ]); + expect(out.contents).toEqual([ + { + role: "user", + parts: [ + { + functionResponse: { + name: "echo", + response: { result: "plain string" }, + }, + }, + ], + }, + ]); + }); +}); diff --git a/apps/desktop/src/api/providers/cloudTools.ts b/apps/desktop/src/api/providers/cloudTools.ts new file mode 100644 index 00000000..569ad82b --- /dev/null +++ b/apps/desktop/src/api/providers/cloudTools.ts @@ -0,0 +1,372 @@ +"use client"; + +import type { ProviderId } from "@/context/SettingsContext"; + +/** + * Tool definitions follow OpenAI's canonical shape — same one IDE_TOOLS + * uses today. Other providers (Anthropic, Gemini) have their own + * envelopes; we translate per-provider in `translateToolsForProvider`. + */ +export interface ToolDefinition { + type: "function"; + function: { + name: string; + description?: string; + parameters?: Record; + }; +} + +/** + * Unified tool-call shape returned by every cloud provider's adapter. + * Mirrors Ollama's `OllamaStreamFinalMessage.tool_calls` so the agent + * loop in `AiChatComponent` can consume both Ollama and cloud results + * with the same downstream code path. `arguments` is a JSON-encoded + * string regardless of how the provider transports it on the wire + * (OpenAI = string, Anthropic = object, Gemini = object). + */ +export interface UnifiedToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +type CloudProvider = Exclude; + +/** + * Translate the OpenAI-canonical tool list into each provider's + * native shape. Returns `undefined` so the caller can fold it into + * the request body without an extra branch. + */ +export function translateToolsForProvider( + provider: CloudProvider, + tools: ToolDefinition[] | undefined, +): unknown | undefined { + if (!tools || tools.length === 0) return undefined; + switch (provider) { + case "openai": + case "openrouter": + return tools; + case "anthropic": + // Anthropic flattens the function envelope — `name`, `description`, + // `input_schema` (= our `parameters`) at the top level. Strip the + // OpenAI wrapper. + return tools.map((t) => ({ + name: t.function.name, + description: t.function.description ?? "", + input_schema: t.function.parameters ?? { + type: "object", + properties: {}, + }, + })); + case "gemini": + // Gemini buckets every declaration under one outer `tools[]` entry + // with a `functionDeclarations` array. Same field names as + // OpenAI's `function` block once you strip the wrapper. + return [ + { + functionDeclarations: tools.map((t) => ({ + name: t.function.name, + description: t.function.description ?? "", + parameters: t.function.parameters ?? { + type: "object", + properties: {}, + }, + })), + }, + ]; + } +} + +/** + * Per-provider parse of a non-streaming chat completion body into the + * unified tool-call list. Returns an empty array when the model + * answered with text only. + */ +export function extractToolCallsFromBody( + provider: CloudProvider, + body: string, +): UnifiedToolCall[] { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) return []; + const obj = parsed as Record; + + switch (provider) { + case "openai": + case "openrouter": { + const choices = obj.choices as + | Array<{ + message?: { + tool_calls?: Array<{ + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + }> + | undefined; + const calls = choices?.[0]?.message?.tool_calls ?? []; + return calls + .filter((c) => c.function?.name) + .map((c) => ({ + id: c.id ?? randomToolCallId(), + type: "function" as const, + function: { + name: c.function!.name!, + arguments: c.function!.arguments ?? "", + }, + })); + } + case "anthropic": { + const content = obj.content as + | Array<{ + type: string; + id?: string; + name?: string; + input?: unknown; + }> + | undefined; + const calls = (content ?? []).filter((b) => b.type === "tool_use"); + return calls + .filter((c) => c.name) + .map((c) => ({ + id: c.id ?? randomToolCallId(), + type: "function" as const, + function: { + name: c.name!, + arguments: JSON.stringify(c.input ?? {}), + }, + })); + } + case "gemini": { + const candidates = obj.candidates as + | Array<{ + content?: { + parts?: Array<{ + functionCall?: { name?: string; args?: unknown }; + }>; + }; + }> + | undefined; + const calls = + candidates + ?.flatMap((c) => c.content?.parts ?? []) + .filter((p) => p.functionCall?.name) ?? []; + return calls.map((p) => ({ + // Gemini doesn't mint ids — synthesise one. The same id is + // echoed in the tool-result message so the model can tie the + // call to its result. + id: randomToolCallId(), + type: "function" as const, + function: { + name: p.functionCall!.name!, + arguments: JSON.stringify(p.functionCall!.args ?? {}), + }, + })); + } + } +} + +/** + * Internal canonical history entry the renderer maintains across + * agent turns. `assistant_with_tools` is the assistant turn that + * carries one or more tool_calls; `tool_result` is the user-side + * response carrying the tool's output. Each cloud provider gets its + * own translation in `translateHistoryForProvider`. + */ +export type CanonicalHistoryEntry = + | { role: "system"; content: string } + | { role: "user"; content: string } + | { role: "assistant"; content: string } + | { + role: "assistant_with_tools"; + content: string; + tool_calls: UnifiedToolCall[]; + } + | { + role: "tool_result"; + tool_call_id: string; + tool_name: string; + content: string; + }; + +/** + * Translate the renderer's canonical history into the provider's + * expected `messages` / `contents` shape. Returns the bundle the + * `cloudChat` request needs (Anthropic separates `system`, Gemini + * uses `systemInstruction`, the OpenAI-likes inline system rows). + */ +export interface TranslatedHistory { + system?: string; + messages?: unknown[]; // OpenAI / Anthropic + contents?: unknown[]; // Gemini + systemInstruction?: unknown; // Gemini +} + +export function translateHistoryForProvider( + provider: CloudProvider, + history: CanonicalHistoryEntry[], +): TranslatedHistory { + const systemEntries = history + .filter((e): e is { role: "system"; content: string } => e.role === "system") + .map((e) => e.content) + .join("\n\n") + .trim(); + + switch (provider) { + case "openai": + case "openrouter": { + const messages: unknown[] = []; + if (systemEntries) { + messages.push({ role: "system", content: systemEntries }); + } + for (const entry of history) { + if (entry.role === "system") continue; + if (entry.role === "user") { + messages.push({ role: "user", content: entry.content }); + } else if (entry.role === "assistant") { + messages.push({ role: "assistant", content: entry.content }); + } else if (entry.role === "assistant_with_tools") { + messages.push({ + role: "assistant", + content: entry.content || null, + tool_calls: entry.tool_calls, + }); + } else if (entry.role === "tool_result") { + messages.push({ + role: "tool", + tool_call_id: entry.tool_call_id, + content: entry.content, + }); + } + } + return { messages }; + } + case "anthropic": { + const messages: unknown[] = []; + // Anthropic refuses leading consecutive user messages mixed with + // tool_results — group them strictly. We emit each canonical + // entry as one message in order; the spec allows alternating + // `user` / `assistant` so a tool_result lives in a `user` turn + // immediately after the `assistant` tool_use turn. + let pendingToolResults: unknown[] = []; + const flushToolResults = () => { + if (pendingToolResults.length === 0) return; + messages.push({ role: "user", content: pendingToolResults }); + pendingToolResults = []; + }; + for (const entry of history) { + if (entry.role === "system") continue; + if (entry.role === "user") { + flushToolResults(); + messages.push({ role: "user", content: entry.content }); + } else if (entry.role === "assistant") { + flushToolResults(); + messages.push({ role: "assistant", content: entry.content }); + } else if (entry.role === "assistant_with_tools") { + flushToolResults(); + const blocks: unknown[] = []; + if (entry.content) { + blocks.push({ type: "text", text: entry.content }); + } + for (const call of entry.tool_calls) { + let input: unknown = {}; + try { + input = JSON.parse(call.function.arguments || "{}"); + } catch { + // Tolerate malformed JSON the model emitted — Anthropic + // expects an object, so we send `{}` rather than failing + // the whole turn. + input = {}; + } + blocks.push({ + type: "tool_use", + id: call.id, + name: call.function.name, + input, + }); + } + messages.push({ role: "assistant", content: blocks }); + } else if (entry.role === "tool_result") { + pendingToolResults.push({ + type: "tool_result", + tool_use_id: entry.tool_call_id, + content: entry.content, + }); + } + } + flushToolResults(); + return { system: systemEntries || undefined, messages }; + } + case "gemini": { + const contents: unknown[] = []; + for (const entry of history) { + if (entry.role === "system") continue; + if (entry.role === "user") { + contents.push({ + role: "user", + parts: [{ text: entry.content }], + }); + } else if (entry.role === "assistant") { + contents.push({ + role: "model", + parts: [{ text: entry.content }], + }); + } else if (entry.role === "assistant_with_tools") { + const parts: unknown[] = []; + if (entry.content) parts.push({ text: entry.content }); + for (const call of entry.tool_calls) { + let args: unknown = {}; + try { + args = JSON.parse(call.function.arguments || "{}"); + } catch { + args = {}; + } + parts.push({ + functionCall: { name: call.function.name, args }, + }); + } + contents.push({ role: "model", parts }); + } else if (entry.role === "tool_result") { + let response: unknown; + try { + response = JSON.parse(entry.content); + } catch { + // Gemini's `functionResponse.response` field requires an + // object; wrap plain strings so they survive the round-trip. + response = { result: entry.content }; + } + if (typeof response !== "object" || response === null) { + response = { result: entry.content }; + } + contents.push({ + role: "user", + parts: [ + { + functionResponse: { + name: entry.tool_name, + response, + }, + }, + ], + }); + } + } + const systemInstruction = systemEntries + ? { role: "user", parts: [{ text: systemEntries }] } + : undefined; + return { contents, systemInstruction }; + } + } +} + +function randomToolCallId(): string { + return `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; +} diff --git a/apps/desktop/src/api/tauri.ts b/apps/desktop/src/api/tauri.ts index f35ad30f..5cb3c4cb 100644 --- a/apps/desktop/src/api/tauri.ts +++ b/apps/desktop/src/api/tauri.ts @@ -107,6 +107,28 @@ export interface TauriInvokeMap { }; return: { status: number; body: string }; }; + "cloud_proxy_stream": { + args: { + streamId: string; + method: string; + url: string; + headers?: Array<[string, string]>; + body?: unknown; + }; + return: void; + }; + "cloud_proxy_cancel": { args: { streamId: string }; return: void }; + // OS keychain-backed AI provider secrets. `provider` must be on the + // Rust-side allow-list (`openai` / `anthropic` / `gemini` / `openrouter`). + "set_provider_secret": { args: { provider: string; secret: string }; return: void }; + "get_provider_secret": { args: { provider: string }; return: string | null }; + "clear_provider_secret": { args: { provider: string }; return: void }; + "has_provider_secret": { args: { provider: string }; return: boolean }; + /** Spawn a new TrixtyIDE process pointing at the given workspace + * folder. Each instance gets its own JS realm and its own Rust + * state. The path must be an absolute, existing directory; the + * Rust side canonicalises and rejects invalid inputs. */ + "spawn_workspace_instance": { args: { path: string }; return: void }; "check_update": { args: { channel?: "stable" | "pre-release" }; return: { version: string; body?: string | null } | null }; "install_update": { args: { channel?: "stable" | "pre-release" }; return: void }; "spawn_pty": { args: { sessionId: string; cwd?: string; rows?: number; cols?: number }; return: void }; @@ -178,24 +200,73 @@ export const isTauri = (): boolean => { /** * A safe wrapper around Tauri's 'invoke' command. - * - * If running in a browser environment where Tauri internals are missing, - * it will log a warning and return a meaningful default or reject gracefully, + * + * If running in a browser environment where Tauri internals are missing, + * it will log a warning and return a meaningful default or reject gracefully, * preventing the application from crashing. */ export async function safeInvoke( - cmd: K, + cmd: K, args?: TauriInvokeMap[K]["args"], options: { silent?: boolean } = {} ): Promise { - const payload = args as Record | undefined; + let payload = (args as Record | undefined) || {}; + const startTime = performance.now(); + + // Inject Sentry tracing context for distributed tracing + try { + const Sentry = await import("@sentry/nextjs"); + const span = Sentry.getActiveSpan(); + if (span) { + const traceData = Sentry.getTraceData(); + payload = { + ...payload, + _sentry_context: { + sentry_trace: traceData["sentry-trace"], + baggage: traceData["baggage"], + }, + }; + } + + // Track command invocation + Sentry.metrics.count('tauri_command_call', 1, { + attributes: { command: cmd } + }); + } catch { + // Sentry not initialized or failed to load, ignore + } + if (isTauri()) { try { - return await tauriInvoke(cmd, payload); + const result = await tauriInvoke(cmd, payload); + + // Success metrics + try { + const Sentry = await import("@sentry/nextjs"); + Sentry.metrics.distribution('tauri_command_duration', performance.now() - startTime, { + unit: 'millisecond', + attributes: { command: cmd, status: 'success' } + }); + } catch {} + + return result; } catch (error) { if (!options.silent) { logger.error(`[Tauri Invoke Error] ${cmd}:`, error); } + + // Error metrics + try { + const Sentry = await import("@sentry/nextjs"); + Sentry.metrics.count('tauri_command_error', 1, { + attributes: { command: cmd } + }); + Sentry.metrics.distribution('tauri_command_duration', performance.now() - startTime, { + unit: 'millisecond', + attributes: { command: cmd, status: 'error' } + }); + } catch {} + throw error; } } diff --git a/apps/desktop/src/api/trixty.ts b/apps/desktop/src/api/trixty.ts index 476bf3bc..d2ee890f 100644 --- a/apps/desktop/src/api/trixty.ts +++ b/apps/desktop/src/api/trixty.ts @@ -106,13 +106,13 @@ class L10nRegistry { return this.currentLocale; } - t(key: string, params?: Record): string { + t(key: string, params?: Record): string { const bundle = this.bundles.get(this.currentLocale) || this.bundles.get('en') || {}; let text = bundle[key] || key; if (params) { Object.entries(params).forEach(([k, v]) => { - text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); }); } return text; diff --git a/apps/desktop/src/app/floating/page.tsx b/apps/desktop/src/app/floating/page.tsx index 52595801..a29ac8b2 100644 --- a/apps/desktop/src/app/floating/page.tsx +++ b/apps/desktop/src/app/floating/page.tsx @@ -4,10 +4,13 @@ import React, { Suspense, useEffect, useState, useSyncExternalStore } from "reac import { useSearchParams } from "next/navigation"; import { trixty, type WebviewView } from "@/api/trixty"; import FloatingTitleBar from "@/components/FloatingTitleBar"; +import BottomPanel from "@/components/BottomPanel"; +import { Terminal as TerminalIcon } from "lucide-react"; import { useL10n } from "@/hooks/useL10n"; import { logger } from "@/lib/logger"; import { isTauri } from "@/api/tauri"; import { PluginManager } from "@/api/PluginManager"; +import { BOTTOM_PANEL_VIEW_ID } from "@/api/floatingWindowRegistry"; // Each Tauri WebviewWindow has its own JS realm, so the `trixty.window` // registry in this window starts empty. Without bootstrapping built-ins @@ -162,6 +165,25 @@ function FloatingViewBody() { ); } + // The bottom panel isn't part of the regular WebviewView registry — + // it's a hardcoded shell component. Render it directly when the + // floating window was spawned for the reserved viewId. + if (viewId === BOTTOM_PANEL_VIEW_ID) { + const title = t("panel.bottom.terminal_tabs", { defaultValue: "Terminal" }); + return ( +
    + } + /> +
    + +
    +
    + ); + } + if (!view) { return (
    diff --git a/apps/desktop/src/app/global-error.tsx b/apps/desktop/src/app/global-error.tsx new file mode 100644 index 00000000..4f9c8a9d --- /dev/null +++ b/apps/desktop/src/app/global-error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/apps/desktop/src/app/globals.css b/apps/desktop/src/app/globals.css index 2ada00dd..5632a8c5 100644 --- a/apps/desktop/src/app/globals.css +++ b/apps/desktop/src/app/globals.css @@ -144,3 +144,34 @@ body { scroll-behavior: auto !important; } } + +/* Yjs Remote Cursors & Selections */ +.yRemoteSelection { + background-color: var(--yjs-color, rgba(250, 129, 0, 0.35)); +} + +.yRemoteSelectionHead { + position: absolute; + border-left: var(--yjs-color, orange) solid 2px; + border-top: var(--yjs-color, orange) solid 2px; + border-bottom: var(--yjs-color, orange) solid 2px; + height: 100%; + box-sizing: border-box; +} + +.yRemoteSelectionHead::after { + position: absolute; + content: attr(data-user-name); + border: 1px solid var(--yjs-color, orange); + border-radius: 2px; + padding: 1px 4px; + color: #fff; + font-size: 10px; + font-weight: bold; + white-space: nowrap; + top: -16px; + left: -1px; + background-color: var(--yjs-color, orange); + pointer-events: none; + z-index: 10; +} diff --git a/apps/desktop/src/app/layout.tsx b/apps/desktop/src/app/layout.tsx index 577aeee9..9952c35c 100644 --- a/apps/desktop/src/app/layout.tsx +++ b/apps/desktop/src/app/layout.tsx @@ -7,6 +7,8 @@ import { ReviewProvider } from "@/context/ReviewContext"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import HtmlLangSync from "@/components/HtmlLangSync"; +import { Toaster } from "sonner"; + export default function RootLayout({ children, }: Readonly<{ @@ -21,6 +23,7 @@ export default function RootLayout({ + {children} diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index 01ac1a72..d36f1152 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useSyncExternalStore } from "react"; import dynamic from "next/dynamic"; import ActivityBar from "@/components/ActivityBar"; import LeftSidebarSlot from "@/components/slots/LeftSidebarSlot"; @@ -17,6 +17,13 @@ import { useReview, isReviewerEligible } from "@/context/ReviewContext"; import { useMediaQuery } from "@/hooks/useMediaQuery"; import { useFloatingDockTracker } from "@/hooks/useFloatingDockTracker"; import { PluginManager } from "@/api/PluginManager"; +import { + BOTTOM_PANEL_VIEW_ID, + floatingWindowRegistry, +} from "@/api/floatingWindowRegistry"; +import { Terminal as TerminalIcon } from "lucide-react"; +import { useL10n } from "@/hooks/useL10n"; +import { useDiscordRPC } from "@/hooks/useDiscordRPC"; import { ResizablePanelGroup, ResizablePanel, @@ -66,6 +73,9 @@ export default function Home() { setZenMode, toggleZenMode, } = useUI(); + + useDiscordRPC(); + const { openFiles, openFile, @@ -78,6 +88,42 @@ export default function Home() { } = useFiles(); const { handleOpenFolder } = useWorkspace(); + // Cmd / Ctrl+Shift+N — open another workspace in a new TrixtyIDE + // process. Two windows = two separate processes, so each gets its + // own Rust state, terminals, AI sessions, and store file. We don't + // try to share anything between the instances on purpose; the + // existing `--path` CLI flag is the contract. + const openInNewWindow = React.useCallback(async () => { + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + directory: true, + multiple: false, + title: "Open Folder in New Window", + }); + if (selected && typeof selected === "string") { + const { safeInvoke } = await import("@/api/tauri"); + await safeInvoke("spawn_workspace_instance", { path: selected }); + } + } catch (err) { + // Best-effort logging; failing to spawn is recoverable (the + // current window stays usable). + const { logger } = await import("@/lib/logger"); + logger.warn("[multi-instance] spawn failed:", err); + } + }, []); + + // Subscribe to the floating-window registry so the bottom panel + // re-renders when it detaches / re-docks. We can't gate on it + // earlier (e.g. by hiding the whole ``) without + // breaking the layout-preset flow that depends on the panel's + // sizing slot existing — so we keep the slot and swap its content. + const bottomPanelDetached = useSyncExternalStore( + floatingWindowRegistry.subscribe, + () => floatingWindowRegistry.isDetached(BOTTOM_PANEL_VIEW_ID), + () => false, + ); + // Tracks a pending `Ctrl+K` leader for two-step chords (`Ctrl+K U`, // `Ctrl+K W`). The TTL matches the VS Code default: if the second key // doesn't arrive in time, treat it as a mistyped `Ctrl+K` and let any @@ -280,6 +326,15 @@ export default function Home() { return; } + // Ctrl+Shift+N — Open another workspace in a new window. Each + // window is a fresh process with its own state, so two repos + // can be open side-by-side without context switching. + if (ctrl && shift && key === "n") { + e.preventDefault(); + void openInNewWindow(); + return; + } + // Ctrl+, — Open settings if (ctrl && key === ",") { e.preventDefault(); @@ -300,6 +355,7 @@ export default function Home() { setActiveSidebarTab, saveCurrentFile, handleOpenFolder, + openInNewWindow, openFile, setSettingsOpen, isSettingsOpen, @@ -389,10 +445,11 @@ export default function Home() { return ; } - // Zen Mode: hide everything except editor + status bar. Esc exits. + // Zen Mode: hide everything except editor + status bar + title bar. Esc exits. if (isZenMode) { return (
    +
    {openFiles.length > 0 ? : } @@ -492,7 +549,11 @@ export default function Home() { >
    - + {bottomPanelDetached ? ( + + ) : ( + + )}
    @@ -547,6 +608,39 @@ export default function Home() { ); } +// Rendered in the bottom panel slot while the panel is detached into a +// floating window. Mirrors the placeholder pattern the right-panel +// slots use — keeps the column reserved so re-dock fills back into the +// same place, and offers a one-click "Dock back" affordance that +// drives the registry directly. +function BottomPanelDetachedPlaceholder() { + const { t } = useL10n(); + return ( +
    + + + {t("panel.view.in_floating_window", { + name: t("panel.bottom.terminal_tabs", { defaultValue: "Terminal" }), + })} + +
    + + +
    +
    + ); +} + // Factored out so the `useReview` / `useMediaQuery` subscriptions only drive // re-renders of this column, not the whole page tree. Render-noops when there // is no destructive tool to approve, or when the viewport is too narrow to diff --git a/apps/desktop/src/components/ActivityBar.tsx b/apps/desktop/src/components/ActivityBar.tsx index 84bf84a8..a9aa6470 100644 --- a/apps/desktop/src/components/ActivityBar.tsx +++ b/apps/desktop/src/components/ActivityBar.tsx @@ -6,6 +6,7 @@ import { trixty, WebviewView } from "@/api/trixty"; import { useL10n } from "@/hooks/useL10n"; import Tooltip from "@/components/ui/Tooltip"; import { cn } from "@/lib/utils"; +import * as Sentry from "@sentry/nextjs"; const ActivityBar: React.FC = () => { const { activeSidebarTab, setActiveSidebarTab, isSidebarOpen, setSidebarOpen, setSettingsOpen } = useUI(); @@ -27,6 +28,11 @@ const ActivityBar: React.FC = () => { }, [activeSidebarTab, setActiveSidebarTab]); const handleTabClick = (id: string) => { + // Track navigation metric + Sentry.metrics.count('navigation_sidebar_click', 1, { + attributes: { tab_id: id } + }); + // Extensions opens a virtual tab directly, not a sidebar panel if (id === "extensions") { openFile("virtual://extensions", t('extensions.title'), "", "virtual"); diff --git a/apps/desktop/src/components/BottomPanel.tsx b/apps/desktop/src/components/BottomPanel.tsx index e37e6810..2ee1ecc7 100644 --- a/apps/desktop/src/components/BottomPanel.tsx +++ b/apps/desktop/src/components/BottomPanel.tsx @@ -2,10 +2,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Terminal from "./Terminal"; -import { Plus, X, Terminal as TerminalIcon } from "lucide-react"; +import { Plus, X, Terminal as TerminalIcon, ExternalLink } from "lucide-react"; import { useUI } from "@/context/UIContext"; import { useWorkspace } from "@/context/WorkspaceContext"; import { useL10n } from "@/hooks/useL10n"; +import { useDetachableHeader } from "@/hooks/useDetachableHeader"; +import { BOTTOM_PANEL_VIEW_ID } from "@/api/floatingWindowRegistry"; interface TerminalTab { id: string; @@ -17,7 +19,15 @@ interface TerminalTab { const newSessionId = (): string => `term-${crypto.randomUUID()}`; -const BottomPanel: React.FC = () => { +interface BottomPanelProps { + /** When true, the panel is being rendered inside a detached + * WebviewWindow — the close button collapses the panel host (which + * in the floating case sends a redock-request) instead of toggling + * the main shell's bottom strip, and the pop-out trigger is hidden. */ + isFloating?: boolean; +} + +const BottomPanel: React.FC = ({ isFloating = false }) => { const { setBottomPanelOpen, terminalPath } = useUI(); const { rootPath } = useWorkspace(); const { t } = useL10n(); @@ -42,7 +52,6 @@ const BottomPanel: React.FC = () => { // explicitly picks a folder — not on every render — so a cascade is // impossible. The ref guard also prevents a double-fire during StrictMode // re-invocation of the effect on the same `terminalPath` value. - /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (terminalPath && terminalPath !== lastHandledTerminalPath.current) { lastHandledTerminalPath.current = terminalPath; @@ -51,7 +60,6 @@ const BottomPanel: React.FC = () => { setActiveId(id); } }, [terminalPath]); - /* eslint-enable react-hooks/set-state-in-effect */ const addTab = useCallback(() => { const id = newSessionId(); @@ -95,6 +103,43 @@ const BottomPanel: React.FC = () => { [t], ); + // Detach mechanic. The header acts as the drag handle for pop-out; + // the explicit button beside the close X gives a click affordance + // for users who don't discover the drag. + const headerRef = useRef(null); + const { onMouseDown: onHeaderMouseDown, popOutButtonProps } = useDetachableHeader({ + viewId: BOTTOM_PANEL_VIEW_ID, + panel: "bottom", + slotElementRef: headerRef, + windowTitle: t("panel.bottom.terminal_tabs", { defaultValue: "Terminal" }), + popOutLabel: t("panel.view.popout", { defaultValue: "Open in floating window" }), + }); + + const closeOrRedock = useCallback(async () => { + if (isFloating) { + // Inside a floating window — fire the redock-request event the + // registry listens for. The registry closes the window and removes + // the entry; the main shell re-renders the inline panel. + try { + const { emit } = await import("@tauri-apps/api/event"); + await emit("floating-window:redock-request", { + viewId: BOTTOM_PANEL_VIEW_ID, + }); + } catch { + // Fallback: just close ourselves so the registry's + // `floating-window:closed` listener cleans up. + try { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + await getCurrentWindow().close(); + } catch { + /* ignore */ + } + } + return; + } + setBottomPanelOpen(false); + }, [isFloating, setBottomPanelOpen]); + // Memoize the mounted Terminal elements so switching tabs doesn't // re-render every xterm instance — each is expensive to mount. const terminalElements = useMemo( @@ -119,7 +164,11 @@ const BottomPanel: React.FC = () => { return (
    -
    +
    {
    + {!isFloating ? ( + + ) : null}
    ); + }; export default EditorArea; @@ -418,17 +468,24 @@ const FileViewSurface: React.FC = ({ onContentChange, monacoElement, }) => { - const visual = useMemo(() => getVisualEditor(file), [file]); - const [modeByPath, setModeByPath] = useState>( + const { t } = useL10n(); + const visuals = useMemo(() => getVisualEditor(file), [file]); + // Mode tracks "source" or one of the visual entry ids. Per-path so + // switching files of the same kind preserves the user's last choice. + const [modeByPath, setModeByPath] = useState>( () => new Map(), ); - if (!visual) return <>{monacoElement}; + if (visuals.length === 0) return <>{monacoElement}; - // Default to "source" when the path has no remembered choice yet — - // computed inline so we don't need an effect to seed the map. - const mode = modeByPath.get(file.path) ?? "source"; - const setMode = (next: "source" | "visual") => { + // Default to the first visual mode if available, otherwise "source". + // This ensures that .json, .env, etc., open directly in their visual view. + const defaultMode = visuals[0]?.id ?? "source"; + const mode = modeByPath.get(file.path) ?? defaultMode; + const setMode = (next: string) => { + Sentry.metrics.count('editor_mode_switch', 1, { + attributes: { to_mode: next, file_type: file.language } + }); setModeByPath((prev) => { const map = new Map(prev); map.set(file.path, next); @@ -436,6 +493,8 @@ const FileViewSurface: React.FC = ({ }); }; + const activeVisual = visuals.find((v) => v.id === mode); + return (
    @@ -443,17 +502,20 @@ const FileViewSurface: React.FC = ({ active={mode === "source"} onClick={() => setMode("source")} icon={} - label="Source" - /> - setMode("visual")} - icon={} - label={visual.label} + label={t('editor.source')} /> + {visuals.map((v) => ( + setMode(v.id)} + icon={} + label={v.label} + /> + ))}
    - {mode === "source" ? ( + {mode === "source" || !activeVisual ? ( monacoElement ) : ( = ({ } > - + )} diff --git a/apps/desktop/src/components/ExtensionApprovalModal.tsx b/apps/desktop/src/components/ExtensionApprovalModal.tsx index 2f6ba8d6..3ad8a787 100644 --- a/apps/desktop/src/components/ExtensionApprovalModal.tsx +++ b/apps/desktop/src/components/ExtensionApprovalModal.tsx @@ -1,51 +1,24 @@ "use client"; -/** - * First-install capability approval modal. Shown exactly once per - * unseen capability — the decision is persisted via - * `persistDecision`, so the user only sees this surface when: - * - * - The extension is being loaded for the first time, or - * - The extension's manifest now requests a capability the user has - * never explicitly approved or denied. - * - * The UX is deliberately conservative: - * - "Approve all" is possible but not the default — the user ticks - * specific rows. - * - A legacy extension (no `trixty.capabilities` block) shows a bold - * warning banner because approving it grants the whole surface. - * - "Deny" persists the denial, so we don't bug the user on every - * launch of an extension they never want to run. - */ - import React, { useMemo, useState } from "react"; import { AlertTriangle, Shield, X } from "lucide-react"; import type { Capability } from "@/api/sandbox/types"; import { CAPABILITY_DESCRIPTIONS } from "@/api/sandbox/capabilities"; +import { useL10n } from "@/hooks/useL10n"; export interface ApprovalRequest { extensionId: string; displayName: string; description?: string; - /** The fresh capability set the manifest is asking for (unified view of - * pending + already-granted, so the modal can show "you already - * approved X" for context). */ requested: Capability[]; - /** Capabilities the user has already granted before; pre-ticked. */ alreadyGranted: Capability[]; - /** Capabilities the user has already denied before; pre-unticked and - * surfaced so they can reverse themselves. */ alreadyDenied: Capability[]; - /** True when the manifest omits `trixty.capabilities` entirely. */ legacy: boolean; } export interface ApprovalDecision { approved: Capability[]; denied: Capability[]; - /** True when the user hit the top-right "Cancel" — treat as a - * temporary decline; the manifest's pending capabilities stay - * pending for next launch. */ cancelled: boolean; } @@ -55,6 +28,7 @@ interface Props { } export default function ExtensionApprovalModal({ request, onDecide }: Props) { + const { t } = useL10n(); const [selected, setSelected] = useState>(() => { const initial = new Set(); for (const cap of request.alreadyGranted) initial.add(cap); @@ -88,8 +62,6 @@ export default function ExtensionApprovalModal({ request, onDecide }: Props) { const selectNone = () => setSelected(new Set()); const hasChanges = useMemo(() => { - // Any capability the user is being asked about that isn't already - // approved → they need to decide before we proceed. return request.requested.some( (cap) => !request.alreadyGranted.includes(cap) && @@ -116,18 +88,17 @@ export default function ExtensionApprovalModal({ request, onDecide }: Props) { id="extension-approval-title" className="text-sm font-semibold text-white" > - Grant capabilities to {request.displayName} + {t('extension.approval.title', { name: request.displayName })}

    - {request.description ?? - "This extension is asking to use the following parts of Trixty. Grant only what you trust."} + {request.description ?? t('extension.approval.desc')}

    @@ -138,30 +109,26 @@ export default function ExtensionApprovalModal({ request, onDecide }: Props) {

    - This extension does not declare a capability list. Approving it - grants access to every sandbox capability. Prefer extensions whose - package.json - includes a trixty.capabilities - array. + {t('extension.approval.legacy')}

    )} {/* Quick select */}
    - Quick select: + {t('extension.approval.quick_select')} |
    @@ -169,8 +136,7 @@ export default function ExtensionApprovalModal({ request, onDecide }: Props) {
    {request.requested.length === 0 ? (

    - This extension requested no capabilities. It will run with no host - access. + {t('extension.approval.no_caps')}

    ) : (
      @@ -195,12 +161,12 @@ export default function ExtensionApprovalModal({ request, onDecide }: Props) { {wasGranted && ( - previously approved + {t('extension.approval.prev_approved')} )} {wasDenied && ( - previously denied + {t('extension.approval.prev_denied')} )}
    @@ -225,21 +191,21 @@ export default function ExtensionApprovalModal({ request, onDecide }: Props) { onClick={handleDenyAll} className="text-[11px] text-[#888] hover:text-red-400 transition-colors" > - Deny all and disable + {t('extension.approval.deny_all')}
    diff --git a/apps/desktop/src/components/MarketplaceView.tsx b/apps/desktop/src/components/MarketplaceView.tsx index 36ebd3c9..2916d501 100644 --- a/apps/desktop/src/components/MarketplaceView.tsx +++ b/apps/desktop/src/components/MarketplaceView.tsx @@ -6,6 +6,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useExtensions, MarketplaceEntry } from "@/context/ExtensionContext"; import { useL10n } from "@/hooks/useL10n"; +import * as Sentry from "@sentry/nextjs"; function resolveIconUrl(entry: MarketplaceEntry): string | null { const icon = entry.manifest?.icon?.trim(); @@ -86,7 +87,14 @@ const DetailsView: React.FC<{ const handleInstall = async () => { setLoadingAction("install"); - try { await installExtension(entry); } catch (e) { alert(e); } + try { + await installExtension(entry); + Sentry.metrics.count('extension_install_success', 1, { attributes: { extension_id: entry.id } }); + Sentry.logger.info(`Extension installed: ${entry.id}`); + } catch (e) { + Sentry.metrics.count('extension_install_error', 1, { attributes: { extension_id: entry.id } }); + alert(e); + } setLoadingAction(null); }; @@ -105,7 +113,12 @@ const DetailsView: React.FC<{ const handleToggleActive = async () => { setLoadingAction("toggle"); - try { await toggleActive(entry.id, !isActive); } catch (e) { alert(e); } + try { + await toggleActive(entry.id, !isActive); + Sentry.metrics.count('extension_toggle_active', 1, { + attributes: { extension_id: entry.id, active: (!isActive).toString() } + }); + } catch (e) { alert(e); } setLoadingAction(null); }; @@ -256,6 +269,15 @@ const MarketplaceView: React.FC = () => { const [search, setSearch] = useState(""); const [selectedEntry, setSelectedEntry] = useState(null); + useEffect(() => { + if (search.length > 2) { + const timer = setTimeout(() => { + Sentry.metrics.count('marketplace_search', 1, { attributes: { query_length: search.length.toString() } }); + }, 1000); + return () => clearTimeout(timer); + } + }, [search]); + // Load the remote catalog lazily: the marketplace used to fetch registry + // per-entry manifests + GitHub stars on every boot even when the user never // opened it. We now defer that work to the first mount of this view. @@ -336,7 +358,10 @@ const MarketplaceView: React.FC = () => {