diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74725a050..a7f047c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,22 @@ jobs: - name: Check formatting run: npx oxfmt --check . + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint + typecheck: runs-on: ubuntu-latest steps: diff --git a/.oxlintrc.json b/.oxlintrc.json index 31f765836..7bc16962b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -2,63 +2,88 @@ "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"], "categories": { - "correctness": "warn", - "suspicious": "warn", - "perf": "warn" + "correctness": "error", + "suspicious": "error", + "perf": "error" }, "rules": { "react/react-in-jsx-scope": "off", - "no-unused-expressions": "warn", - "no-useless-catch": "warn", - "preserve-caught-error": "warn", - "require-await": "warn", - "no-async-promise-executor": "warn", - "no-useless-escape": "warn", - "no-empty-pattern": "warn", - "no-self-assign": "warn", - "no-shadow": "warn", - "no-use-before-define": ["warn", { "functions": false, "classes": false, "variables": true }], + "no-await-in-loop": "off", + + "no-unused-expressions": "error", + "no-useless-catch": "error", + "preserve-caught-error": "error", "require-await": "off", + "no-async-promise-executor": "error", + "no-useless-escape": "error", + "no-empty-pattern": "error", + "no-self-assign": "error", + "no-shadow": "error", "unicorn/consistent-function-scoping": "off", "unicorn/no-array-sort": "off", - "unicorn/no-useless-spread": "warn", - "unicorn/no-useless-fallback-in-spread": "warn", - "unicorn/no-new-array": "warn", - "unicorn/no-empty-file": "warn", + "unicorn/no-useless-spread": "error", + "unicorn/no-useless-fallback-in-spread": "error", + "unicorn/no-new-array": "error", + "unicorn/no-empty-file": "error", - "promise/always-return": "warn", - "promise/no-multiple-resolved": "warn", + "promise/always-return": "error", + "promise/no-multiple-resolved": "error", - "react/no-array-index-key": "warn", - "react/jsx-no-useless-fragment": "warn", - "react/jsx-no-constructed-context-values": "warn", - "react/no-unescaped-entities": "warn", - "react/button-has-type": "warn", - "react/jsx-max-depth": ["warn", { "max": 6 }], + "react/no-array-index-key": "error", + "react/jsx-no-useless-fragment": "error", + "react/jsx-no-constructed-context-values": "error", + "react/no-unescaped-entities": "error", + "react/button-has-type": "error", + "react/jsx-max-depth": ["error", { "max": 6 }], - "react-hooks/rules-of-hooks": "warn", - "react-hooks/exhaustive-deps": "warn", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", - "react-perf/jsx-no-new-array-as-prop": "warn", - "react-perf/jsx-no-new-function-as-prop": "warn", - "react-perf/jsx-no-new-object-as-prop": "warn", - "react-perf/jsx-no-jsx-as-prop": "warn", + "react-perf/jsx-no-new-array-as-prop": "error", + "react-perf/jsx-no-new-function-as-prop": "error", + "react-perf/jsx-no-new-object-as-prop": "error", + "react-perf/jsx-no-jsx-as-prop": "error", - "oxc/no-map-spread": "warn", - "oxc/no-async-endpoint-handlers": "warn", - "oxc/only-used-in-recursion": "warn", + "oxc/no-map-spread": "error", + "oxc/no-async-endpoint-handlers": "error", + "oxc/only-used-in-recursion": "error", - "typescript/no-explicit-any": "warn", - "typescript/prefer-as-const": "warn", - "typescript/no-this-alias": "warn", + "typescript/no-explicit-any": "error", + "typescript/prefer-as-const": "error", + "typescript/no-this-alias": "error", + "typescript/consistent-type-definitions": ["error", "interface"], - "no-nested-ternary": "warn", - "no-unneeded-ternary": "warn", + "import/no-unassigned-import": [ + "error", + { + "allow": [ + "**/*.css", + "**/expo-router/entry", + "**/event-target-polyfill", + "**/dotenv/config", + "**/react-native", + "**/@/styles/unistyles", + "**/src/styles/unistyles", + "**/@/test/window-local-storage" + ] + } + ], - "complexity": ["warn", { "max": 20 }], - "max-depth": ["warn", { "max": 4 }], - "max-nested-callbacks": ["warn", { "max": 3 }] - } + "no-nested-ternary": "error", + "no-unneeded-ternary": "error", + + "complexity": ["error", { "max": 20 }], + "max-depth": ["error", { "max": 4 }], + "max-nested-callbacks": ["error", { "max": 3 }] + }, + "overrides": [ + { + "files": ["**/e2e/fixtures.ts"], + "rules": { + "no-empty-pattern": "off" + } + } + ] } diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 000000000..ca5e0e337 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,9 @@ +pre-commit: + parallel: true + commands: + format: + run: npm run format:check + lint: + run: npm run lint + typecheck: + run: npm run typecheck diff --git a/nix/package.nix b/nix/package.nix index c3045568b..2e2def3a8 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-Y2yfCV2x5mwRp3uTURqTi2b9c5n2LKXtJH2eswKsvFQ="; + npmDepsHash = "sha256-O5AxXbwGJO52gvmVTzJqgy7nb2dxoVAaikBni3Gtu/s="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index 8118baa68..e81cacb17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,11 @@ ], "devDependencies": { "@types/ws": "^8.5.14", + "@typescript/native-preview": "7.0.0-dev.20260423.1", "concurrently": "^9.2.1", "get-port-cli": "^3.0.0", "knip": "^5.82.1", + "lefthook": "^2.1.6", "oxfmt": "0.46.0", "oxlint": "1.61.0", "patch-package": "^8.0.1", @@ -14261,6 +14263,147 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-9WD7TJJlGvt9PQqJI/+44dVP4oqGQFIkYrpXt7nlQ0WgNIErN52x/XhxmJ4nWft06qejgPiUbPo4aYRNOmIHXg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260423.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260423.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260423.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260423.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260423.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260423.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260423.1" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-wbLr6o5fROaCYt6cOpFhbe92FJAOdhAHwm/s8I/IyN5HbL1ULgel/wHaZiR+ws+27rgruNUiCENzTUg9vSz2bA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-13MpNT+4MgkgrfiW2u03rnER5aB3yz9fA0bWEYh6IH3rIqA2AR3Dntp3QXW4sQrZf0SriXqHe2R7X3HCT5xmqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-CxUA15qbPQRvz2nanBpiv1h4tgXTCJJwqOtgKMSdIuPkow8dyYW3ba5oLoH/jZhS4792XislX659hlFrfiU6CQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-ICIkJDTqmn0R4Vs811+Ht2RYTk1OCrAhHCu0JthmhR216T1Tqgi5DWRoCprp3RL1qU6fLnxxrIpEbNlNN7XFYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-cWLFS4R8dOU1YuUJ/2VLeGMVIjgI3GGb/f9rRY5MbWHq5l3NNZh8y1QwAOrTh3+g3q6+znArfxVnD2hZHUz8Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-OWaGUI4+dHqYZv+k6sITx9Y27FNy3XzNFk4OrOiYtBkIO/xrb9TPMP4A5XI4n5zwRLIv3xne9g039xgRbaeyoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20260423.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260423.1.tgz", + "integrity": "sha512-5MQjO/qdLwXpjW7Dy/1lNv7Vtpvo6bhCkbjan4PoRN5/eeyqEqDWxdf8AGE4btLmHqyIjEHRuYf7kp2tlAr6lQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -27222,6 +27365,169 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, + "node_modules/lefthook": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.6.tgz", + "integrity": "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "lefthook": "bin/index.js" + }, + "optionalDependencies": { + "lefthook-darwin-arm64": "2.1.6", + "lefthook-darwin-x64": "2.1.6", + "lefthook-freebsd-arm64": "2.1.6", + "lefthook-freebsd-x64": "2.1.6", + "lefthook-linux-arm64": "2.1.6", + "lefthook-linux-x64": "2.1.6", + "lefthook-openbsd-arm64": "2.1.6", + "lefthook-openbsd-x64": "2.1.6", + "lefthook-windows-arm64": "2.1.6", + "lefthook-windows-x64": "2.1.6" + } + }, + "node_modules/lefthook-darwin-arm64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.6.tgz", + "integrity": "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/lefthook-darwin-x64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.6.tgz", + "integrity": "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/lefthook-freebsd-arm64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.6.tgz", + "integrity": "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/lefthook-freebsd-x64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.6.tgz", + "integrity": "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/lefthook-linux-arm64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.6.tgz", + "integrity": "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/lefthook-linux-x64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.6.tgz", + "integrity": "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/lefthook-openbsd-arm64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.6.tgz", + "integrity": "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/lefthook-openbsd-x64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.6.tgz", + "integrity": "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/lefthook-windows-arm64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.6.tgz", + "integrity": "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/lefthook-windows-x64": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.6.tgz", + "integrity": "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index 2f2ffeed9..06a75fbba 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dev:app": "npm run start --workspace=@getpaseo/app", "dev:website": "npm run dev --workspace=@getpaseo/website", "postinstall": "node scripts/postinstall-patches.mjs", + "prepare": "lefthook install --force", "build": "npm run build --workspaces --if-present", "build:highlight": "npm run build --workspace=@getpaseo/highlight", "build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", @@ -89,9 +90,11 @@ }, "devDependencies": { "@types/ws": "^8.5.14", + "@typescript/native-preview": "7.0.0-dev.20260423.1", "concurrently": "^9.2.1", "get-port-cli": "^3.0.0", "knip": "^5.82.1", + "lefthook": "^2.1.6", "oxfmt": "0.46.0", "oxlint": "1.61.0", "patch-package": "^8.0.1", diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index e9d9453cc..0433333b9 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -3,12 +3,12 @@ import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-r // Extend base test to provide dynamic baseURL from global-setup const test = base.extend({ - baseURL: async ({}, use) => { + baseURL: async ({}, provide) => { const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) { throw new Error("E2E_METRO_PORT not set - globalSetup must run first"); } - await use(`http://localhost:${metroPort}`); + await provide(`http://localhost:${metroPort}`); }, }); @@ -67,7 +67,7 @@ test.beforeEach(async ({ page }) => { const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId); await page.addInitScript( - ({ daemon, preferences, seedNonce }) => { + ({ daemon, preferences, seedNonce: nonce }) => { // `addInitScript` runs on every navigation (including reloads). Some tests intentionally // override storage and reload; they can opt out of seeding for the *next* navigation by // setting this flag before the reload. @@ -75,13 +75,13 @@ test.beforeEach(async ({ page }) => { const disableValue = localStorage.getItem(disableOnceKey); if (disableValue) { localStorage.removeItem(disableOnceKey); - if (disableValue === seedNonce) { + if (disableValue === nonce) { return; } } localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce); + localStorage.setItem("@paseo:e2e-seed-nonce", nonce); // Hard-reset anything that could point to a developer's real daemon. localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 0151718b7..554835e32 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -8,13 +8,13 @@ import { Buffer } from "node:buffer"; import dotenv from "dotenv"; import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork"; -type WaitForServerOptions = { +interface WaitForServerOptions { host?: string; timeoutMs?: number; label: string; childProcess?: ChildProcess | null; getRecentOutput?: () => string; -}; +} async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { @@ -125,16 +125,21 @@ async function stopProcess(child: ChildProcess | null): Promise { } child.kill("SIGTERM"); await new Promise((resolve) => { + let pendingResolve: (() => void) | null = resolve; + const settle = () => { + if (!pendingResolve) return; + const fn = pendingResolve; + pendingResolve = null; + clearTimeout(timeout); + fn(); + }; const timeout = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); } - resolve(); + settle(); }, 5000); - child.once("exit", () => { - clearTimeout(timeout); - resolve(); - }); + child.once("exit", settle); }); } @@ -197,12 +202,12 @@ function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null { return resolvePaseoHomePath(trimmed); } -type OfferPayload = { +interface OfferPayload { v: 2; serverId: string; daemonPublicKeyB64: string; relay: { endpoint: string }; -}; +} async function createFakeGhBin(): Promise { const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-")); @@ -250,8 +255,10 @@ process.exit(1); return binDir; } +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); + function stripAnsi(input: string): string { - return input.replace(/\u001b\[[0-9;]*m/g, ""); + return input.replace(ANSI_PATTERN, ""); } function ensureRelayBuildArtifact(repoRoot: string): void { @@ -328,68 +335,44 @@ async function waitForPairingOfferFromCli(args: { ); } -export default async function globalSetup() { - const repoRoot = path.resolve(__dirname, "../../.."); - ensureRelayBuildArtifact(repoRoot); +interface DictationConfig { + openAiUsable: boolean; + localModelsDir: string | null; +} + +async function loadEnvTestFile(repoRoot: string): Promise { const envTestPath = path.join(repoRoot, ".env.test"); if (existsSync(envTestPath)) { dotenv.config({ path: envTestPath }); } +} - const port = await getAvailablePort(); - let relayPort = 0; - const metroPort = await getAvailablePort(); - const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME); - const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; - paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); - fakeGhBinDir = await createFakeGhBin(); - let relayLineBuffer = createLineBuffer(); - const metroLineBuffer = createLineBuffer(); - const daemonLineBuffer = createLineBuffer(); - +async function applyPaseoHomeFork(targetHome: string): Promise { const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM); - if (forkSourceHome) { - const forkResult = await forkPaseoHomeMetadata({ - sourceHome: forkSourceHome, - targetHome: paseoHome, - }); - process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome; - process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome; - process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles); - process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes); - console.log( - `[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` + - `(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` + - `${forkResult.copiedBytes} bytes)`, - ); - if (forkResult.skippedMissing.length > 0) { - console.warn( - `[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`, - ); - } + if (!forkSourceHome) { + return; } + const forkResult = await forkPaseoHomeMetadata({ + sourceHome: forkSourceHome, + targetHome, + }); + process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome; + process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome; + process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles); + process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes); + console.log( + `[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` + + `(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` + + `${forkResult.copiedBytes} bytes)`, + ); + if (forkResult.skippedMissing.length > 0) { + console.warn( + `[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`, + ); + } +} - const cleanup = async () => { - await Promise.all([ - stopProcess(daemonProcess), - stopProcess(metroProcess), - stopProcess(relayProcess), - ]); - daemonProcess = null; - metroProcess = null; - relayProcess = null; - if (paseoHome && shouldRemovePaseoHome) { - await rm(paseoHome, { recursive: true, force: true }); - paseoHome = null; - } else if (paseoHome) { - console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); - } - if (fakeGhBinDir) { - await rm(fakeGhBinDir, { recursive: true, force: true }); - fakeGhBinDir = null; - } - }; - +async function resolveDictationConfig(): Promise { const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY); const defaultLocalModelsDir = path.join( process.env.HOME ?? "", @@ -412,213 +395,291 @@ export default async function globalSetup() { console.log( `[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`, ); + return { openAiUsable, localModelsDir }; +} + +interface RelayStreamState { + failureLine: string | null; + readyForSelectedPort: boolean; +} + +function attachRelayStreamHandlers( + child: ChildProcess, + relayPort: number, + buffer: ReturnType, + state: RelayStreamState, +): void { + function handleChunk(data: Buffer, streamTag: "stdout" | "stderr") { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); + for (const line of lines) { + buffer.add(`[${streamTag}] ${line}`); + const failure = parseRelayStartupFailure(line); + if (failure) { + state.failureLine = failure; + } + const clean = stripAnsi(line); + const readyMatch = clean.match(/Ready on .*:(\d+)\b/i); + if (readyMatch && Number(readyMatch[1]) === relayPort) { + state.readyForSelectedPort = true; + } + if (streamTag === "stdout") { + console.log(`[relay] ${line}`); + } else { + console.error(`[relay] ${line}`); + } + } + } + + child.stdout?.on("data", (data: Buffer) => handleChunk(data, "stdout")); + child.stderr?.on("data", (data: Buffer) => handleChunk(data, "stderr")); +} + +async function awaitRelayReady( + child: ChildProcess, + relayPort: number, + state: RelayStreamState, + buffer: ReturnType, +): Promise { + await waitForServer(relayPort, { + label: "Relay dev server", + timeoutMs: 30000, + childProcess: child, + getRecentOutput: buffer.dump, + }); + + const readyDeadline = Date.now() + 5000; + function isRelayReadyCheckPending(): boolean { + if (state.readyForSelectedPort) return false; + if (state.failureLine !== null) return false; + if (child.exitCode !== null) return false; + if (child.signalCode !== null) return false; + if (Date.now() >= readyDeadline) return false; + return true; + } + while (isRelayReadyCheckPending()) await sleep(100); + + if (state.failureLine) { + throw new Error(`Relay startup failed: ${state.failureLine}`); + } + if (!state.readyForSelectedPort) { + throw new Error( + `Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput( + buffer.dump, + )}`, + ); + } + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Relay process exited before startup completed (exit code ${child.exitCode}, signal ${child.signalCode}).${formatRecentOutput( + buffer.dump, + )}`, + ); + } +} + +async function startRelay(): Promise { + const relayDir = path.resolve(__dirname, "..", "..", "relay"); + const maxRelayStartupAttempts = 5; + let lastRelayStartupError: unknown = null; + + for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) { + const relayPort = await getAvailablePort(); + const buffer = createLineBuffer(); + const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false }; + + relayProcess = spawn( + "npx", + ["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)], + { + cwd: relayDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }, + ); + attachRelayStreamHandlers(relayProcess, relayPort, buffer, state); + + try { + await awaitRelayReady(relayProcess, relayPort, state, buffer); + return relayPort; + } catch (error) { + lastRelayStartupError = error; + await stopProcess(relayProcess); + relayProcess = null; + } + } + + const message = + lastRelayStartupError instanceof Error + ? lastRelayStartupError.message + : String(lastRelayStartupError); + throw new Error( + `Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`, + ); +} + +function startMetro(metroPort: number, buffer: ReturnType): ChildProcess { + const appDir = path.resolve(__dirname, ".."); + const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], { + cwd: appDir, + env: { + ...process.env, + BROWSER: "none", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + child.stdout?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); + for (const line of lines) { + buffer.add(`[stdout] ${line}`); + console.log(`[metro] ${line}`); + } + }); + + child.stderr?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); + for (const line of lines) { + buffer.add(`[stderr] ${line}`); + console.error(`[metro] ${line}`); + } + }); + + return child; +} + +interface DaemonSpawnArgs { + port: number; + relayPort: number; + metroPort: number; + paseoHome: string; + fakeGhBinDir: string; + dictation: DictationConfig; + buffer: ReturnType; +} + +function startDaemon(args: DaemonSpawnArgs): ChildProcess { + const serverDir = path.resolve(__dirname, "../../..", "packages/server"); + const tsxBin = execSync("which tsx").toString().trim(); + const { openAiUsable, localModelsDir } = args.dictation; + + const child = spawn(tsxBin, ["src/server/index.ts"], { + cwd: serverDir, + env: { + ...process.env, + PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + PASEO_HOME: args.paseoHome, + PASEO_SERVER_ID: "srv_e2e_test_daemon", + PASEO_LISTEN: `0.0.0.0:${args.port}`, + PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, + PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`, + PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0", + PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0", + ...(openAiUsable + ? { + PASEO_DICTATION_STT_PROVIDER: "openai", + PASEO_VOICE_STT_PROVIDER: "openai", + PASEO_VOICE_TTS_PROVIDER: "openai", + } + : {}), + ...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}), + NODE_ENV: "development", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + let stdoutBuffer = ""; + child.stdout?.on("data", (data: Buffer) => { + stdoutBuffer += data.toString("utf8"); + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() ?? ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + args.buffer.add(`[stdout] ${trimmed}`); + console.log(`[daemon] ${trimmed}`); + } + }); + + child.stderr?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); + for (const line of lines) { + args.buffer.add(`[stderr] ${line}`); + console.error(`[daemon] ${line}`); + } + }); + + return child; +} + +async function performCleanup(shouldRemovePaseoHome: boolean): Promise { + await Promise.all([ + stopProcess(daemonProcess), + stopProcess(metroProcess), + stopProcess(relayProcess), + ]); + daemonProcess = null; + metroProcess = null; + relayProcess = null; + if (paseoHome && shouldRemovePaseoHome) { + await rm(paseoHome, { recursive: true, force: true }); + paseoHome = null; + } else if (paseoHome) { + console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); + } + if (fakeGhBinDir) { + await rm(fakeGhBinDir, { recursive: true, force: true }); + fakeGhBinDir = null; + } +} + +export default async function globalSetup() { + const repoRoot = path.resolve(__dirname, "../../.."); + ensureRelayBuildArtifact(repoRoot); + await loadEnvTestFile(repoRoot); + + const port = await getAvailablePort(); + const metroPort = await getAvailablePort(); + const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME); + const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; + paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); + fakeGhBinDir = await createFakeGhBin(); + const metroLineBuffer = createLineBuffer(); + const daemonLineBuffer = createLineBuffer(); + + await applyPaseoHomeFork(paseoHome); + + const cleanup = () => performCleanup(shouldRemovePaseoHome); + + const dictation = await resolveDictationConfig(); try { - const relayDir = path.resolve(__dirname, "..", "..", "relay"); - const maxRelayStartupAttempts = 5; - let relayStarted = false; - let lastRelayStartupError: unknown = null; - - for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) { - relayPort = await getAvailablePort(); - relayLineBuffer = createLineBuffer(); - let relayStartupFailureLine: string | null = null; - let relayReadyForSelectedPort = false; - - relayProcess = spawn( - "npx", - ["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)], - { - cwd: relayDir, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }, - ); - - relayProcess.stdout?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - relayLineBuffer.add(`[stdout] ${line}`); - const failure = parseRelayStartupFailure(line); - if (failure) { - relayStartupFailureLine = failure; - } - const clean = stripAnsi(line); - const readyMatch = clean.match(/Ready on .*:(\d+)\b/i); - if (readyMatch && Number(readyMatch[1]) === relayPort) { - relayReadyForSelectedPort = true; - } - console.log(`[relay] ${line}`); - } - }); - relayProcess.stderr?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - relayLineBuffer.add(`[stderr] ${line}`); - const failure = parseRelayStartupFailure(line); - if (failure) { - relayStartupFailureLine = failure; - } - const clean = stripAnsi(line); - const readyMatch = clean.match(/Ready on .*:(\d+)\b/i); - if (readyMatch && Number(readyMatch[1]) === relayPort) { - relayReadyForSelectedPort = true; - } - console.error(`[relay] ${line}`); - } - }); - - try { - await waitForServer(relayPort, { - label: "Relay dev server", - timeoutMs: 30000, - childProcess: relayProcess, - getRecentOutput: relayLineBuffer.dump, - }); - - const readyDeadline = Date.now() + 5000; - while ( - !relayReadyForSelectedPort && - relayStartupFailureLine === null && - relayProcess?.exitCode === null && - relayProcess?.signalCode === null && - Date.now() < readyDeadline - ) { - await sleep(100); - } - - if (relayStartupFailureLine) { - throw new Error(`Relay startup failed: ${relayStartupFailureLine}`); - } - if (!relayReadyForSelectedPort) { - throw new Error( - `Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput( - relayLineBuffer.dump, - )}`, - ); - } - if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) { - throw new Error( - `Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput( - relayLineBuffer.dump, - )}`, - ); - } - - relayStarted = true; - break; - } catch (error) { - lastRelayStartupError = error; - await stopProcess(relayProcess); - relayProcess = null; - } - } - - if (!relayStarted) { - const message = - lastRelayStartupError instanceof Error - ? lastRelayStartupError.message - : String(lastRelayStartupError); - throw new Error( - `Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`, - ); - } - - // Start Metro bundler on dynamic port - const appDir = path.resolve(__dirname, ".."); - metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], { - cwd: appDir, - env: { - ...process.env, - BROWSER: "none", // Don't auto-open browser - }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, + const relayPort = await startRelay(); + metroProcess = startMetro(metroPort, metroLineBuffer); + daemonProcess = startDaemon({ + port, + relayPort, + metroPort, + paseoHome, + fakeGhBinDir, + dictation, + buffer: daemonLineBuffer, }); - metroProcess.stdout?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - metroLineBuffer.add(`[stdout] ${line}`); - console.log(`[metro] ${line}`); - } - }); - - metroProcess.stderr?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - metroLineBuffer.add(`[stderr] ${line}`); - console.error(`[metro] ${line}`); - } - }); - - const serverDir = path.resolve(__dirname, "../../..", "packages/server"); - const tsxBin = execSync("which tsx").toString().trim(); - - daemonProcess = spawn(tsxBin, ["src/server/index.ts"], { - cwd: serverDir, - env: { - ...process.env, - PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`, - PASEO_HOME: paseoHome, - PASEO_SERVER_ID: "srv_e2e_test_daemon", - PASEO_LISTEN: `0.0.0.0:${port}`, - PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, - PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, - PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0", - PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0", - ...(openAiUsable - ? { - PASEO_DICTATION_STT_PROVIDER: "openai", - PASEO_VOICE_STT_PROVIDER: "openai", - PASEO_VOICE_TTS_PROVIDER: "openai", - } - : {}), - ...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}), - NODE_ENV: "development", - }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); - - let stdoutBuffer = ""; - daemonProcess.stdout?.on("data", (data: Buffer) => { - stdoutBuffer += data.toString("utf8"); - const lines = stdoutBuffer.split("\n"); - stdoutBuffer = lines.pop() ?? ""; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - daemonLineBuffer.add(`[stdout] ${trimmed}`); - console.log(`[daemon] ${trimmed}`); - } - }); - - daemonProcess.stderr?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - daemonLineBuffer.add(`[stderr] ${line}`); - console.error(`[daemon] ${line}`); - } - }); - - // Wait for both daemon and Metro to be ready await Promise.all([ waitForServer(port, { label: "Paseo daemon", @@ -627,7 +688,7 @@ export default async function globalSetup() { }), waitForServer(metroPort, { label: "Metro web server", - timeoutMs: 120000, // Metro can take longer to start + timeoutMs: 120000, childProcess: metroProcess, getRecentOutput: metroLineBuffer.dump, }), diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index c33c99d68..6a7f99e20 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -7,22 +7,22 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; const NEAR_BOTTOM_THRESHOLD_PX = 72; -export type ScrollMetrics = { +export interface ScrollMetrics { offsetY: number; contentHeight: number; viewportHeight: number; distanceFromBottom: number; -}; +} -export type SeededAgent = { +export interface SeededAgent { id: string; title: string; expectedTailText: string; url: string; workspaceUrl: string; -}; +} -export type DaemonClientInstance = { +export interface DaemonClientInstance { connect(): Promise; close(): Promise; createAgent(options: { @@ -36,7 +36,7 @@ export type DaemonClientInstance = { }): Promise<{ id: string }>; sendAgentMessage(agentId: string, text: string): Promise; waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>; -}; +} function getDaemonWsUrl(): string { const daemonPort = process.env.E2E_DAEMON_PORT; @@ -84,12 +84,12 @@ export function createReplyTurn(label: string): { }; } -type DaemonClientConfig = { +interface DaemonClientConfig { url: string; clientId: string; clientType: "cli"; webSocketFactory?: NodeWebSocketFactory; -}; +} async function loadDaemonClientConstructor(): Promise< new (config: DaemonClientConfig) => DaemonClientInstance diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index ab65c8524..f9268c5fe 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -27,19 +27,19 @@ async function ensureE2EStorageSeeded(page: Page): Promise { } const needsReset = await page.evaluate( - ({ expectedEndpoint, expectedServerId }) => { + ({ expectedEndpoint: endpoint, expectedServerId: serverId }) => { const raw = localStorage.getItem("@paseo:daemon-registry"); if (!raw) return true; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed) || parsed.length !== 1) return true; - const entry = parsed[0] as any; - if (entry?.serverId !== expectedServerId) return true; + const entry = parsed[0] as { serverId?: string; connections?: unknown }; + if (entry?.serverId !== serverId) return true; const connections = entry?.connections; if (!Array.isArray(connections)) return true; if ( connections.some( - (c: any) => + (c: { type?: string; endpoint?: string }) => c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint), @@ -47,7 +47,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise { ) return true; return !connections.some( - (c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint, + (c: { type?: string; endpoint?: string }) => + c?.type === "directTcp" && c?.endpoint === endpoint, ); } catch { return true; @@ -68,10 +69,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise { }); const preferences = buildCreateAgentPreferences(expectedServerId); await page.evaluate( - ({ daemon, preferences }) => { + ({ daemon: seededDaemon, preferences: seededPreferences }) => { localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences)); localStorage.removeItem("@paseo:settings"); }, { daemon, preferences }, @@ -80,6 +81,68 @@ async function ensureE2EStorageSeeded(page: Page): Promise { await page.reload(); } +function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } { + let registry: unknown; + try { + registry = JSON.parse(registryRaw); + } catch { + throw new Error("E2E expected @paseo:daemon-registry to be valid JSON."); + } + if (!Array.isArray(registry) || registry.length !== 1) { + throw new Error( + `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`, + ); + } + const daemon = registry[0] as { serverId?: string; connections?: unknown }; + if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) { + throw new Error( + `E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`, + ); + } + return { serverId: daemon.serverId, connections: daemon.connections }; +} + +function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void { + if ( + !Array.isArray(connections) || + !connections.some( + (c: { type?: string; endpoint?: string }) => + c?.type === "directTcp" && c?.endpoint === expectedEndpoint, + ) + ) { + throw new Error( + `E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`, + ); + } + if ( + Array.isArray(connections) && + connections.some( + (c: { type?: string; endpoint?: string }) => + c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint), + ) + ) { + throw new Error( + `E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`, + ); + } +} + +function assertPreferencesMatch(prefsRaw: string, serverId: string): void { + try { + const prefs = JSON.parse(prefsRaw) as { serverId?: string }; + if (prefs?.serverId !== serverId) { + throw new Error( + `E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`, + ); + } + } catch (error) { + if (error instanceof Error) throw error; + throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", { + cause: error, + }); + } +} + async function assertE2EUsesSeededTestDaemon(page: Page): Promise { const port = getE2EDaemonPort(); const expectedEndpoint = `127.0.0.1:${port}`; @@ -98,68 +161,18 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise { throw new Error("E2E expected @paseo:daemon-registry to be set before app load."); } - let registry: any; - try { - registry = JSON.parse(snapshot.registryRaw); - } catch { - throw new Error("E2E expected @paseo:daemon-registry to be valid JSON."); - } - - if (!Array.isArray(registry) || registry.length !== 1) { + const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw); + if (serverId !== expectedServerId) { throw new Error( - `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`, - ); - } - - const daemon = registry[0]; - if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) { - throw new Error( - `E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`, - ); - } - if (daemon.serverId !== expectedServerId) { - throw new Error( - `E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`, - ); - } - - const connections: unknown = daemon?.connections; - if ( - !Array.isArray(connections) || - !connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint) - ) { - throw new Error( - `E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`, - ); - } - if ( - Array.isArray(connections) && - connections.some( - (c: any) => - c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint), - ) - ) { - throw new Error( - `E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`, + `E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`, ); } + assertDaemonConnections(connections, expectedEndpoint); if (!snapshot.prefsRaw) { throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load."); } - try { - const prefs = JSON.parse(snapshot.prefsRaw) as any; - if (prefs?.serverId !== daemon.serverId) { - throw new Error( - `E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`, - ); - } - } catch (error) { - if (error instanceof Error) throw error; - throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", { - cause: error, - }); - } + assertPreferencesMatch(snapshot.prefsRaw, serverId); } export const gotoAppShell = async (page: Page) => { @@ -310,7 +323,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { if (trimmedDirectory.startsWith("/private/var/")) { directoryCandidates.add(trimmedDirectory.replace(/^\/private/, "")); } - const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory; + const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory; await expect .poll( @@ -345,8 +358,8 @@ export const ensureHostSelected = async (page: Page) => { const registryRaw = localStorage.getItem("@paseo:daemon-registry"); const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences"); if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const; - const registry = JSON.parse(registryRaw) as any[]; - const prefs = JSON.parse(prefsRaw) as any; + const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>; + const prefs = JSON.parse(prefsRaw) as { serverId?: string }; if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: "registry shape" } as const; const serverId = registry[0]?.serverId; diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index fb5798ad0..b3f4de8bc 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -11,13 +11,13 @@ import { buildHostWorkspaceRoute, } from "@/utils/host-routes"; -export type ArchiveTabAgent = { +export interface ArchiveTabAgent { id: string; title: string; cwd: string; -}; +} -type ArchiveTabDaemonClient = { +interface ArchiveTabDaemonClient { connect(): Promise; close(): Promise; createAgent(options: { @@ -36,7 +36,7 @@ type ArchiveTabDaemonClient = { predicate: (snapshot: { status: string }) => boolean, timeout?: number, ): Promise<{ status: string }>; -}; +} function getDaemonPort(): string { const daemonPort = process.env.E2E_DAEMON_PORT; @@ -73,12 +73,12 @@ function buildSeededStoragePayload() { }; } -type ArchiveTabDaemonClientConfig = { +interface ArchiveTabDaemonClientConfig { url: string; clientId: string; clientType: "cli"; webSocketFactory?: NodeWebSocketFactory; -}; +} async function loadDaemonClientConstructor(): Promise< new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient @@ -148,21 +148,21 @@ export async function primeAdditionalPage(page: Page): Promise { await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." }); }); await page.addInitScript( - ({ daemon, preferences, seedNonce }) => { + ({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => { const disableOnceKey = "@paseo:e2e-disable-default-seed-once"; const disableValue = localStorage.getItem(disableOnceKey); if (disableValue) { localStorage.removeItem(disableOnceKey); - if (disableValue === seedNonce) { + if (disableValue === nonce) { return; } } localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); + localStorage.setItem("@paseo:e2e-seed-nonce", nonce); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon])); localStorage.removeItem("@paseo:settings"); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences)); }, { daemon, preferences, seedNonce }, ); @@ -173,11 +173,11 @@ export async function resetSeededPageState(page: Page): Promise { const { daemon, preferences } = buildSeededStoragePayload(); await page.goto("/"); await page.evaluate( - ({ daemon, preferences }) => { + ({ daemon: seededDaemon, preferences: seededPreferences }) => { localStorage.clear(); localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences)); localStorage.removeItem("@paseo:settings"); }, { daemon, preferences }, diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 8de74e6f6..241055b9e 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -91,7 +91,7 @@ export async function pressNewTabShortcut(page: Page): Promise { // ─── Tab bar assertions ─────────────────────────────────────────────────── /** @deprecated The launcher panel was removed. Actions go directly to their target. */ -export async function waitForLauncherPanel(page: Page): Promise { +export async function waitForLauncherPanel(_page: Page): Promise { // No-op: the launcher panel no longer exists. } diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 0471496ea..4bd698b57 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -17,21 +17,21 @@ type NewWorkspaceDaemonClient = Pick< | "openProject" >; -type NewWorkspaceDaemonClientConfig = { +interface NewWorkspaceDaemonClientConfig { url: string; clientId: string; clientType: "cli"; webSocketFactory?: NodeWebSocketFactory; -}; +} type OpenProjectPayload = Awaited>; -export type OpenedProject = { +export interface OpenedProject { workspaceId: string; projectKey: string; projectDisplayName: string; workspaceName: string; -}; +} function getDaemonPort(): string { const daemonPort = process.env.E2E_DAEMON_PORT; diff --git a/packages/app/e2e/helpers/node-ws-factory.ts b/packages/app/e2e/helpers/node-ws-factory.ts index fed9af2a6..70d393127 100644 --- a/packages/app/e2e/helpers/node-ws-factory.ts +++ b/packages/app/e2e/helpers/node-ws-factory.ts @@ -1,20 +1,20 @@ -import WebSocket from "ws"; +import { WebSocket } from "ws"; -type WebSocketLike = { +interface WebSocketLike { readyState: number; send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; binaryType?: string; - on?: (event: string, listener: (...args: any[]) => void) => void; - off?: (event: string, listener: (...args: any[]) => void) => void; - removeListener?: (event: string, listener: (...args: any[]) => void) => void; - addEventListener?: (event: string, listener: (event: any) => void) => void; - removeEventListener?: (event: string, listener: (event: any) => void) => void; - onopen?: ((event: any) => void) | null; - onclose?: ((event: any) => void) | null; - onerror?: ((event: any) => void) | null; - onmessage?: ((event: any) => void) | null; -}; + on?: (event: string, listener: (...args: unknown[]) => void) => void; + off?: (event: string, listener: (...args: unknown[]) => void) => void; + removeListener?: (event: string, listener: (...args: unknown[]) => void) => void; + addEventListener?: (event: string, listener: (event: unknown) => void) => void; + removeEventListener?: (event: string, listener: (event: unknown) => void) => void; + onopen?: ((event: unknown) => void) | null; + onclose?: ((event: unknown) => void) | null; + onerror?: ((event: unknown) => void) | null; + onmessage?: ((event: unknown) => void) | null; +} export type NodeWebSocketFactory = ( url: string, diff --git a/packages/app/e2e/helpers/paseo-home-fork.ts b/packages/app/e2e/helpers/paseo-home-fork.ts index 0b4f2049e..5526096ae 100644 --- a/packages/app/e2e/helpers/paseo-home-fork.ts +++ b/packages/app/e2e/helpers/paseo-home-fork.ts @@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; -export type PaseoHomeMetadataForkResult = { +export interface PaseoHomeMetadataForkResult { sourceHome: string; targetHome: string; agentFiles: number; @@ -13,13 +13,13 @@ export type PaseoHomeMetadataForkResult = { copiedFiles: number; copiedBytes: number; skippedMissing: string[]; -}; +} -type CopyStats = { +interface CopyStats { files: number; bytes: number; skippedMissing: string[]; -}; +} export function resolvePaseoHomePath(value: string): string { if (value === "~") { diff --git a/packages/app/e2e/helpers/startup-dsl.ts b/packages/app/e2e/helpers/startup-dsl.ts index 67702e20f..9c84cbf23 100644 --- a/packages/app/e2e/helpers/startup-dsl.ts +++ b/packages/app/e2e/helpers/startup-dsl.ts @@ -6,11 +6,11 @@ const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; const REGISTRY_KEY = "@paseo:daemon-registry"; const E2E_KEY = "@paseo:e2e"; -type SavedHostInput = { +interface SavedHostInput { serverId: string; label: string; endpoint: string; -}; +} export function startupScenario(page: Page) { return new StartupScenario(page); @@ -106,18 +106,15 @@ class StartupScenario { const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId); await this.page.evaluate( - ({ keys, registry, createAgentPreferences }) => { + ({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => { const nonce = localStorage.getItem(keys.seedNonce); if (!nonce) { throw new Error("Expected e2e seed nonce before overriding startup registry."); } localStorage.setItem(keys.e2e, "1"); - localStorage.setItem(keys.registry, JSON.stringify(registry)); - localStorage.setItem( - "@paseo:create-agent-preferences", - JSON.stringify(createAgentPreferences), - ); + localStorage.setItem(keys.registry, JSON.stringify(storedRegistry)); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences)); localStorage.setItem(keys.disableDefaultSeedOnce, nonce); }, { diff --git a/packages/app/e2e/helpers/terminal-dsl.ts b/packages/app/e2e/helpers/terminal-dsl.ts index 8c12547ce..cf0b42a86 100644 --- a/packages/app/e2e/helpers/terminal-dsl.ts +++ b/packages/app/e2e/helpers/terminal-dsl.ts @@ -7,16 +7,16 @@ import { type TerminalPerfDaemonClient, } from "./terminal-perf"; -type TempRepo = { +interface TempRepo { path: string; cleanup: () => Promise; -}; +} -export type TerminalInstance = { +export interface TerminalInstance { id: string; name: string; cwd: string; -}; +} export class TerminalE2EHarness { readonly client: TerminalPerfDaemonClient; diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 36de7aa41..8b11d55e6 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; -export type TerminalPerfDaemonClient = { +export interface TerminalPerfDaemonClient { connect(): Promise; close(): Promise; openProject(cwd: string): Promise<{ @@ -37,7 +37,7 @@ export type TerminalPerfDaemonClient = { handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void, ): () => void; killTerminal(terminalId: string): Promise<{ error: string | null }>; -}; +} function getDaemonWsUrl(): string { const daemonPort = process.env.E2E_DAEMON_PORT; @@ -55,12 +55,12 @@ function getServerId(): string { return serverId; } -type TerminalPerfDaemonClientConfig = { +interface TerminalPerfDaemonClientConfig { url: string; clientId: string; clientType: "cli"; webSocketFactory?: NodeWebSocketFactory; -}; +} async function loadDaemonClientConstructor(): Promise< new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient @@ -100,7 +100,19 @@ function buildWorkspaceUrl(workspaceId: string): string { export async function getTerminalBufferText(page: Page): Promise { return page.evaluate(() => { - const term = (window as any).__paseoTerminal; + const term = ( + window as Window & { + __paseoTerminal?: { + buffer: { + active: { + length: number; + getLine: (i: number) => { translateToString: (trim: boolean) => string } | null; + }; + }; + onWriteParsed: (cb: () => void) => { dispose: () => void }; + }; + } + ).__paseoTerminal; if (!term) { return ""; } @@ -191,10 +203,10 @@ export async function setupDeterministicPrompt(page: Page, sentinel?: string): P await page.waitForTimeout(300); } -export type LatencySample = { +export interface LatencySample { char: string; latencyMs: number; -}; +} /** * Measures keystroke echo round-trip latency. @@ -205,12 +217,16 @@ export type LatencySample = { */ export async function measureKeystrokeLatency(page: Page, char: string): Promise { await page.evaluate(() => { - const term = (window as any).__paseoTerminal; - if (!term) { + const win = window as Window & { + __paseoTerminal?: { onWriteParsed: (cb: () => void) => { dispose: () => void } }; + __perfKeystroke?: { promise: Promise | null }; + }; + if (!win.__paseoTerminal) { throw new Error("__paseoTerminal not available"); } + const term = win.__paseoTerminal; - const state = ((window as any).__perfKeystroke = { + const state = (win.__perfKeystroke = { promise: null as Promise | null, }); @@ -236,7 +252,11 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise await page.keyboard.press(char); - return page.evaluate(() => (window as any).__perfKeystroke.promise); + return page.evaluate( + () => + (window as unknown as { __perfKeystroke: { promise: Promise } }).__perfKeystroke + .promise, + ); } export function computePercentile(samples: number[], p: number): number { diff --git a/packages/app/e2e/helpers/terminal-probes.ts b/packages/app/e2e/helpers/terminal-probes.ts index 0e91ebf97..389a20419 100644 --- a/packages/app/e2e/helpers/terminal-probes.ts +++ b/packages/app/e2e/helpers/terminal-probes.ts @@ -1,6 +1,6 @@ import type { Page } from "@playwright/test"; -export type TerminalRenderProbeSnapshot = { +export interface TerminalRenderProbeSnapshot { setCount: number; unsetCount: number; writeCount: number; @@ -10,28 +10,28 @@ export type TerminalRenderProbeSnapshot = { altExitWrites: number; events: TerminalRenderProbeEvent[]; frames: TerminalFrame[]; -}; +} -export type TerminalRenderProbeEvent = { +export interface TerminalRenderProbeEvent { at: number; type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write"; preview?: string; -}; +} -export type TerminalFrame = { +export interface TerminalFrame { at: number; rowCount: number; nonEmptyRows: number; firstNonEmptyRow: number | null; text: string; topText: string; -}; +} export type TerminalRenderProbeSummary = Omit & { frameCount: number; }; -export type TerminalKeystrokeStressReport = { +export interface TerminalKeystrokeStressReport { inputTextLength: number; keydownCount: number; inputFrameCount: number; @@ -70,21 +70,25 @@ export type TerminalKeystrokeStressReport = { keydownToXtermCommitMs: LatencyStats | null; firstKeydownAt: number | null; lastXtermCommitAt: number | null; -}; +} -export type LatencyStats = { +export interface LatencyStats { count: number; minMs: number; p50Ms: number; p95Ms: number; maxMs: number; avgMs: number; -}; +} export async function installTerminalRenderProbe(page: Page): Promise { await page.addInitScript(() => { - type ProbeState = { - term: any; + interface ProbeTerm { + write?: (data: string | Uint8Array, callback?: () => void) => void; + __paseoRenderProbeWriteWrapped?: boolean; + } + interface ProbeState { + term: ProbeTerm | undefined; setCount: number; unsetCount: number; writeCount: number; @@ -99,9 +103,12 @@ export async function installTerminalRenderProbe(page: Page): Promise { reset: () => void; snapshot: () => TerminalRenderProbeSnapshot; startSampling: (durationMs: number) => void; - }; + } - const win = window as any; + const win = window as unknown as Record & { + __terminalRenderProbe?: ProbeState; + __paseoTerminal?: ProbeTerm; + }; const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal"); const getExisting = () => existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value; @@ -182,7 +189,7 @@ export async function installTerminalRenderProbe(page: Page): Promise { get() { return probe.term; }, - set(next: any) { + set(next: ProbeTerm | undefined) { if (next === undefined) { probe.unsetCount += 1; probe.events.push({ at: performance.now(), type: "unset" }); @@ -201,7 +208,7 @@ export async function installTerminalRenderProbe(page: Page): Promise { typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array); probe.writeCount += 1; const preview = text - .replace(/\u001b/g, "\\x1b") + .replaceAll("\u001b", "\\x1b") .replace(/\r/g, "\\r") .replace(/\n/g, "\\n") .slice(0, 160); @@ -230,20 +237,30 @@ export async function installTerminalRenderProbe(page: Page): Promise { }); } +interface TerminalRenderProbeWindow { + __terminalRenderProbe: { + reset: () => void; + startSampling: (durationMs: number) => void; + snapshot: () => TerminalRenderProbeSnapshot; + }; +} + export async function resetTerminalRenderProbe(page: Page): Promise { await page.evaluate(() => { - (window as any).__terminalRenderProbe.reset(); + (window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.reset(); }); } export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise { await page.evaluate((ms) => { - (window as any).__terminalRenderProbe.startSampling(ms); + (window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.startSampling(ms); }, durationMs); } export async function readTerminalRenderProbe(page: Page): Promise { - return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot()); + return page.evaluate(() => + (window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.snapshot(), + ); } export async function terminalVisibleText(page: Page): Promise { @@ -272,29 +289,29 @@ export function summarizeTerminalRenderProbe( export async function installTerminalKeystrokeStressProbe(page: Page): Promise { await page.addInitScript(() => { - type TimedTextEvent = { + interface TimedTextEvent { at: number; text: string; bytes: number; - }; - type TimedTextMessageEvent = { + } + interface TimedTextMessageEvent { at: number; bytes: number; kind: string | null; - }; - type XtermWriteEvent = { + } + interface XtermWriteEvent { at: number; committedAt: number | null; text: string; bytes: number; - }; - type AppProbeEvent = { + } + interface AppProbeEvent { type: string; at: number; bytes?: number; queueDepth?: number; - }; - type StressProbeState = { + } + interface StressProbeState { keydowns: Array<{ at: number; key: string }>; inputFrames: TimedTextEvent[]; outputFrames: TimedTextEvent[]; @@ -303,7 +320,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise void; report: (inputText: string) => TerminalKeystrokeStressReport; - }; + } const INPUT_OPCODE = 0x02; const OUTPUT_OPCODE = 0x01; @@ -553,8 +570,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise write.committedAt) - .filter((at): at is number => typeof at === "number") - .at(-1) ?? null, + .findLast((at): at is number => typeof at === "number") ?? null, }; }, }; @@ -592,6 +608,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise { if (text === null) { @@ -602,11 +619,12 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise[0]): void { const bytes = bytesFrom(data); if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) { probe.inputFrames.push({ @@ -637,7 +655,10 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise void) => void; + __paseoKeystrokeProbeWriteWrapped?: boolean; + }) { terminal = next; if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) { const originalWrite = next.write.bind(next); @@ -663,9 +684,18 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise void; + report: (text: string) => TerminalKeystrokeStressReport; + }; +} + export async function resetTerminalKeystrokeStressProbe(page: Page): Promise { await page.evaluate(() => { - (window as any).__terminalKeystrokeStressProbe.reset(); + ( + window as unknown as TerminalKeystrokeStressProbeWindow + ).__terminalKeystrokeStressProbe.reset(); }); } @@ -674,7 +704,10 @@ export async function readTerminalKeystrokeStressReport( inputText: string, ): Promise { return page.evaluate( - (text) => (window as any).__terminalKeystrokeStressProbe.report(text), + (text) => + ( + window as unknown as TerminalKeystrokeStressProbeWindow + ).__terminalKeystrokeStressProbe.report(text), inputText, ); } diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 37dd71406..d821057bb 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -8,7 +8,7 @@ import { gotoAppShell } from "./app"; import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; import type { SessionOutboundMessage } from "@server/shared/messages"; -type WorkspaceSetupDaemonClient = { +interface WorkspaceSetupDaemonClient { connect(): Promise; close(): Promise; openProject(cwd: string): Promise<{ @@ -52,7 +52,7 @@ type WorkspaceSetupDaemonClient = { error?: string | null; }>; subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; -}; +} export type WorkspaceSetupProgressPayload = Extract< SessionOutboundMessage, @@ -116,7 +116,7 @@ export async function seedProjectForWorkspaceSetup( } export function projectNameFromPath(repoPath: string): string { - return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath; + return repoPath.replace(/\/+$/, "").split("/").findLast(Boolean) ?? repoPath; } export async function openHomeWithProject(page: Page, repoPath: string): Promise { diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index 4b6600367..467ecd300 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -3,11 +3,11 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -type TempRepo = { +interface TempRepo { path: string; branchHeads: Record; cleanup: () => Promise; -}; +} export const createTempGitRepo = async ( prefix = "paseo-e2e-", diff --git a/packages/app/e2e/startup-wire-metrics.spec.ts b/packages/app/e2e/startup-wire-metrics.spec.ts index fca4e8dc2..184d0a458 100644 --- a/packages/app/e2e/startup-wire-metrics.spec.ts +++ b/packages/app/e2e/startup-wire-metrics.spec.ts @@ -7,13 +7,13 @@ import { waitForSidebarHydration } from "./helpers/workspace-ui"; type WireDirection = "sent" | "received"; type WirePhase = "startup" | "workspace_clicks"; -type ParsedWireMessage = { +interface ParsedWireMessage { type: string | null; requestId: string | null; entryCount: number | null; hasMore: boolean | null; providerEntries: ProviderSnapshotWireEntry[] | null; -}; +} type WireFrameRecord = ParsedWireMessage & { phase: WirePhase; @@ -21,23 +21,23 @@ type WireFrameRecord = ParsedWireMessage & { bytes: number; }; -type ProviderSnapshotWireEntry = { +interface ProviderSnapshotWireEntry { provider: string; status: string | null; modelCount: number; modeCount: number; bytes: number; -}; +} -type WebSocketFrameEvent = { +interface WebSocketFrameEvent { requestId: string; response: { opcode: number; payloadData: string; }; -}; +} -type WireSummary = { +interface WireSummary { totalFrames: number; totalBytes: number; byDirection: Record; @@ -84,7 +84,18 @@ type WireSummary = { copiedFiles: number | null; copiedBytes: number | null; }; -}; +} + +function extractWorkspaceTestIds(elements: Element[]): string[] { + const result: string[] = []; + for (const element of elements.slice(0, 3)) { + const value = element.getAttribute("data-testid"); + if (value) { + result.push(value); + } + } + return result; +} class WireMonitor { private phase: WirePhase = "startup"; @@ -499,12 +510,7 @@ test.describe("ad hoc startup wire metrics", () => { const workspaceTestIds = await page .locator('[data-testid^="sidebar-workspace-row-"]:visible') - .evaluateAll((elements) => - elements - .slice(0, 3) - .map((element) => element.getAttribute("data-testid")) - .filter((value): value is string => Boolean(value)), - ); + .evaluateAll(extractWorkspaceTestIds); monitor.setPhase("workspace_clicks"); const clickedWorkspaces: WireSummary["clickedWorkspaces"] = []; diff --git a/packages/app/e2e/terminal-alternate-screen.spec.ts b/packages/app/e2e/terminal-alternate-screen.spec.ts index 5bd502590..d6048ce33 100644 --- a/packages/app/e2e/terminal-alternate-screen.spec.ts +++ b/packages/app/e2e/terminal-alternate-screen.spec.ts @@ -79,7 +79,10 @@ test.describe("Terminal alternate-screen transitions", () => { `for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`, { delay: 0 }, ); - await waitForTerminalContent(page, (text) => text.includes(historyReady), 10_000); + function hasHistoryReady(text: string): boolean { + return text.includes(historyReady); + } + await waitForTerminalContent(page, hasHistoryReady, 10_000); await resetTerminalRenderProbe(page); await page.waitForTimeout(500); @@ -118,14 +121,16 @@ test.describe("Terminal alternate-screen transitions", () => { expect(finalBufferText).toContain(historyReady); expect(finalBufferText).toContain(afterAlt); - const suspiciousFrames = probe.frames.filter( - (frame) => + function isSuspiciousFrame(frame: (typeof probe.frames)[number]): boolean { + return ( frame.text.includes("$") && !frame.text.includes(historyReady) && !frame.text.includes(afterAlt) && frame.nonEmptyRows <= 2 && - (frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1, - ); + (frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1 + ); + } + const suspiciousFrames = probe.frames.filter(isSuspiciousFrame); expect( suspiciousFrames, diff --git a/packages/app/e2e/terminal-keystroke-stress.spec.ts b/packages/app/e2e/terminal-keystroke-stress.spec.ts index 6c56d4921..da7ae0191 100644 --- a/packages/app/e2e/terminal-keystroke-stress.spec.ts +++ b/packages/app/e2e/terminal-keystroke-stress.spec.ts @@ -17,7 +17,7 @@ const STRESS_TIMEOUT_MS = 15_000; const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1"; const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip; -type DaemonEchoReport = { +interface DaemonEchoReport { inputTextLength: number; inputFrameCount: number; outputEventCount: number; @@ -25,7 +25,7 @@ type DaemonEchoReport = { sendToOutputMs: LatencyStats; firstSendAt: number; lastOutputAt: number; -}; +} terminalPerfDescribe("Terminal keystroke stress", () => { let harness: TerminalE2EHarness; diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index d83895236..33de88b32 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -140,12 +140,12 @@ test.describe("Workspace navigation regression", () => { await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2); await page.evaluate( - ({ agentId, serverId }) => { + ({ agentId, serverId: targetServerId }) => { globalThis.dispatchEvent( new CustomEvent("paseo:web-notification-click", { detail: { data: { - serverId, + serverId: targetServerId, agentId, reason: "finished", }, diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts index 329edbffa..54d122830 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -19,7 +19,7 @@ function getServerId(): string { return serverId; } -type WorkspaceScriptStarter = { +interface WorkspaceScriptStarter { startWorkspaceScript( workspaceId: string, scriptName: string, @@ -29,7 +29,7 @@ type WorkspaceScriptStarter = { terminalId: string | null; error: string | null; }>; -}; +} /** Click the sidebar row for a workspace (by ID) and wait for navigation. */ async function navigateToWorkspaceViaSidebar( @@ -353,10 +353,11 @@ test.describe("Workspace setup streaming", () => { error: null, }); + const findEditorTerminal = (terminal: { name: string }) => terminal.name === "editor"; await expect .poll(async () => { const terminals = await client.listTerminals(workspaceDir); - return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null; + return terminals.terminals.find(findEditorTerminal) ?? null; }) .toMatchObject({ id: expect.any(String), diff --git a/packages/app/global.d.ts b/packages/app/global.d.ts new file mode 100644 index 000000000..cbe652dbe --- /dev/null +++ b/packages/app/global.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/packages/app/package.json b/packages/app/package.json index ce1f27c80..713705e48 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,7 +17,7 @@ "ios:release": "expo run:ios --configuration Release", "web": "expo start --web", "lint": "expo lint", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "test": "vitest run", "test:browser": "vitest run --project browser", "test:e2e": "playwright test", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5b3200a46..8268a31a8 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -98,26 +98,10 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; polyfillCrypto(); -export type HostRuntimeBootstrapState = { +export interface HostRuntimeBootstrapState { phase: "starting-daemon" | "connecting" | "online" | "error"; error: string | null; retry: () => void; -}; - -function getRouteParamValue(value: string | string[] | undefined): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - if (Array.isArray(value)) { - const firstValue = value[0]; - if (typeof firstValue !== "string") { - return undefined; - } - const trimmed = firstValue.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - return undefined; } const HostRuntimeBootstrapContext = createContext({ @@ -191,6 +175,7 @@ function PushNotificationRouter() { return; } removeDesktopNotificationListener = unlisten; + return; }); } @@ -240,6 +225,7 @@ function PushNotificationRouter() { if (response) { openFromResponse(response); } + return; }); return () => { @@ -678,9 +664,10 @@ function OfferLinkListener({ void upsertDaemonFromOfferUrl(url) .then((profile) => { if (cancelled) return; - const serverId = (profile as any)?.serverId; + const serverId = (profile as { serverId?: unknown } | null)?.serverId; if (typeof serverId !== "string" || !serverId) return; router.replace(buildHostRootRoute(serverId)); + return; }) .catch((error) => { if (cancelled) return; @@ -752,6 +739,7 @@ function OpenProjectListener() { if (!disposed && pending) { maybeOpenProject(pending); } + return; }) .catch(() => undefined); @@ -769,6 +757,7 @@ function OpenProjectListener() { return; } unlisten = dispose; + return; }) .catch(() => undefined); @@ -834,19 +823,23 @@ function FaviconStatusSync() { return null; } +const AGENT_SCREEN_OPTIONS = { gestureEnabled: false }; + function RootStack() { const storeReady = useStoreReady(); const { theme } = useUnistyles(); + const stackScreenOptions = useMemo( + () => ({ + headerShown: false, + animation: "none" as const, + contentStyle: { + backgroundColor: theme.colors.surface0, + }, + }), + [theme.colors.surface0], + ); return ( - + @@ -862,7 +855,7 @@ function RootStack() { outside this route-level native-stack API. */} - + @@ -894,37 +887,59 @@ function NavigationActiveWorkspaceObserver() { return null; } +function AppShell() { + return ( + + + + + + + + + ); +} + +function RuntimeProviders({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + + + ); +} + +function RootProviders({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} + export default function RootLayout() { const { theme } = useUnistyles(); + const gestureRootStyle = useMemo( + () => ({ flex: 1, backgroundColor: theme.colors.surface0 }), + [theme.colors.surface0], + ); return ( - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + ); } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 343466715..c32788dbc 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter, type Href } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { useSessionStore } from "@/stores/session-store"; import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks"; @@ -44,7 +44,7 @@ function HostAgentReadyRouteContent() { } if (!serverId || !agentId) { redirectedRef.current = true; - router.replace("/" as any); + router.replace("/" as Href); return; } @@ -55,7 +55,7 @@ function HostAgentReadyRouteContent() { serverId, workspaceId: resolvedWorkspaceId, target: { kind: "agent", agentId }, - }) as any, + }) as Href, ); } }, [agentId, resolvedWorkspaceId, router, serverId]); @@ -107,11 +107,12 @@ function HostAgentReadyRouteContent() { serverId, workspaceId, target: { kind: "agent", agentId }, - }) as any, + }) as Href, ); return; } router.replace(buildHostRootRoute(serverId)); + return; }) .catch(() => { if (cancelled || redirectedRef.current) { diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 153d50432..00c707758 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -65,8 +65,8 @@ function stripOpenSearchParamFromBrowserUrl() { } function clearConsumedOpenIntent(input: { - navigation: { setParams: (...args: any[]) => void }; - router: { replace: (...args: any[]) => void }; + navigation: { setParams: (params: { open?: string | undefined }) => void }; + router: ReturnType; serverId: string; workspaceId: string; }) { @@ -137,7 +137,9 @@ function HostWorkspaceLayoutContent() { const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; if (consumedIntentRef.current === consumptionKey) { clearConsumedOpenIntent({ - navigation, + navigation: navigation as unknown as { + setParams: (params: { open?: string | undefined }) => void; + }, router, serverId, workspaceId, @@ -161,7 +163,9 @@ function HostWorkspaceLayoutContent() { // skips search params). Strip ?open from the browser URL directly so the // address bar reflects the clean workspace route. clearConsumedOpenIntent({ - navigation, + navigation: navigation as unknown as { + setParams: (params: { open?: string | undefined }) => void; + }, router, serverId, workspaceId, diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index 562658082..da5924974 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Pressable, Text, View } from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter, type Href } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; -import type { BarcodeScanningResult } from "expo-camera"; +import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; import { useHostMutations } from "@/runtime/host-runtime"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { connectToDaemon } from "@/utils/test-daemon-connection"; @@ -147,7 +147,7 @@ export default function PairScanScreen() { try { router.back(); } catch { - router.replace("/" as any); + router.replace("/" as Href); } }, [router]); @@ -198,15 +198,29 @@ export default function PairScanScreen() { [isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl], ); + const handleRouterBack = useCallback(() => router.back(), [router]); + const handleRequestPermission = useCallback(() => { + void requestPermission(); + }, [requestPermission]); + + const bodyStyle = useMemo( + () => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }], + [insets.bottom, theme.spacing], + ); + const helperTextStyle = useMemo( + () => [styles.helperText, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + if (isWeb) { return ( - router.back()} /> - + + Not available on web - QR scanning isn't supported in the web build. Use "Paste link" instead. + {`QR scanning isn't supported in the web build. Use "Paste link" instead.`} Back to Settings @@ -223,14 +237,14 @@ export default function PairScanScreen() { - + {!granted ? ( Camera permission Allow camera access to scan the pairing QR code from your daemon. - void requestPermission()}> + Grant permission @@ -239,21 +253,17 @@ export default function PairScanScreen() { - - - - + + + + - {isPairing ? ( - - Pairing… - - ) : null} + {isPairing ? Pairing… : null} )} @@ -261,3 +271,9 @@ export default function PairScanScreen() { ); } + +const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] }; +const CORNER_TL_STYLE = [styles.corner, styles.cornerTL]; +const CORNER_TR_STYLE = [styles.corner, styles.cornerTR]; +const CORNER_BL_STYLE = [styles.corner, styles.cornerBL]; +const CORNER_BR_STYLE = [styles.corner, styles.cornerBR]; diff --git a/packages/app/src/app/settings/[section].tsx b/packages/app/src/app/settings/[section].tsx index 8afddf1c0..e84d71434 100644 --- a/packages/app/src/app/settings/[section].tsx +++ b/packages/app/src/app/settings/[section].tsx @@ -1,4 +1,5 @@ import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; import SettingsScreen from "@/screens/settings-screen"; import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes"; @@ -6,6 +7,7 @@ export default function SettingsSectionRoute() { const params = useLocalSearchParams<{ section?: string }>(); const rawSection = typeof params.section === "string" ? params.section : ""; const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general"; + const view = useMemo(() => ({ kind: "section" as const, section }), [section]); - return ; + return ; } diff --git a/packages/app/src/app/settings/hosts/[serverId].tsx b/packages/app/src/app/settings/hosts/[serverId].tsx index 02260acaa..9a3058c45 100644 --- a/packages/app/src/app/settings/hosts/[serverId].tsx +++ b/packages/app/src/app/settings/hosts/[serverId].tsx @@ -1,14 +1,16 @@ import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import SettingsScreen from "@/screens/settings-screen"; export default function SettingsHostRoute() { const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; + const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]); return ( - + ); } diff --git a/packages/app/src/app/settings/index.tsx b/packages/app/src/app/settings/index.tsx index 00e212c2a..09280b5c5 100644 --- a/packages/app/src/app/settings/index.tsx +++ b/packages/app/src/app/settings/index.tsx @@ -3,6 +3,8 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import SettingsScreen from "@/screens/settings-screen"; import { buildSettingsSectionRoute } from "@/utils/host-routes"; +const ROOT_VIEW = { kind: "root" as const }; + export default function SettingsIndexRoute() { const isCompactLayout = useIsCompactFormFactor(); @@ -10,5 +12,5 @@ export default function SettingsIndexRoute() { return ; } - return ; + return ; } diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index 181771eaf..98111d812 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -114,12 +114,14 @@ export function createLocalFileAttachmentStore(params: { await ensureDirectory(baseDirectory); const id = input.id ?? generateAttachmentId(); - const mimeTypeFromSource = - input.source.kind === "data_url" - ? parseDataUrl(input.source.dataUrl).mimeType - : input.source.kind === "blob" - ? input.source.blob.type - : undefined; + let mimeTypeFromSource: string | undefined; + if (input.source.kind === "data_url") { + mimeTypeFromSource = parseDataUrl(input.source.dataUrl).mimeType; + } else if (input.source.kind === "blob") { + mimeTypeFromSource = input.source.blob.type; + } else { + mimeTypeFromSource = undefined; + } const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource); const fileName = input.fileName ?? null; const extension = extensionForAttachment({ fileName, mimeType }); diff --git a/packages/app/src/attachments/use-attachment-preview-url.ts b/packages/app/src/attachments/use-attachment-preview-url.ts index 553ebfea1..d54db6149 100644 --- a/packages/app/src/attachments/use-attachment-preview-url.ts +++ b/packages/app/src/attachments/use-attachment-preview-url.ts @@ -7,29 +7,37 @@ export function useAttachmentPreviewUrl( ): string | null { const [url, setUrl] = useState(null); const activeAttachmentRef = useRef(null); + const attachmentRef = useRef(attachment); + attachmentRef.current = attachment; + + const id = attachment?.id; + const storageType = attachment?.storageType; + const storageKey = attachment?.storageKey; + const mimeType = attachment?.mimeType; useEffect(() => { let disposed = false; let currentUrl: string | null = null; + const current = attachmentRef.current; - activeAttachmentRef.current = attachment ?? null; - if (!attachment) { + activeAttachmentRef.current = current ?? null; + if (!current) { setUrl(null); return; } void (async () => { try { - const resolved = await resolveAttachmentPreviewUrl(attachment); + const resolved = await resolveAttachmentPreviewUrl(current); if (disposed) { - await releaseAttachmentPreviewUrl({ attachment, url: resolved }); + await releaseAttachmentPreviewUrl({ attachment: current, url: resolved }); return; } currentUrl = resolved; setUrl(resolved); } catch (error) { console.error("[attachments] Failed to resolve preview URL", { - attachmentId: attachment.id, + attachmentId: current.id, error, }); if (!disposed) { @@ -49,7 +57,7 @@ export function useAttachmentPreviewUrl( url: currentUrl, }); }; - }, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]); + }, [id, storageType, storageKey, mimeType]); return url; } diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index f6a78f6a0..397a67f65 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -98,7 +98,7 @@ export function createPreviewAttachmentId(input: { export async function blobToBase64(blob: Blob): Promise { return await new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => { + reader.addEventListener("load", () => { if (typeof reader.result !== "string") { reject(new Error("Unexpected FileReader result while encoding attachment.")); return; @@ -109,10 +109,10 @@ export async function blobToBase64(blob: Blob): Promise { return; } resolve(payload); - }; - reader.onerror = () => { + }); + reader.addEventListener("error", () => { reject(reader.error ?? new Error("Failed to read attachment blob.")); - }; + }); reader.readAsDataURL(blob); }); } diff --git a/packages/app/src/attachments/web/indexeddb-attachment-store.ts b/packages/app/src/attachments/web/indexeddb-attachment-store.ts index c807b7bb8..7d5ee4483 100644 --- a/packages/app/src/attachments/web/indexeddb-attachment-store.ts +++ b/packages/app/src/attachments/web/indexeddb-attachment-store.ts @@ -10,12 +10,12 @@ import { parseDataUrl, } from "@/attachments/utils"; -type StoredBlobRecord = { +interface StoredBlobRecord { id: string; blob: Blob; createdAt: number; fileName: string | null; -}; +} const DB_NAME = "paseo-attachment-bytes"; const STORE_NAME = "attachments"; @@ -40,13 +40,13 @@ function openAttachmentDb(): Promise { } }; - request.onsuccess = () => { + request.addEventListener("success", () => { resolve(request.result); - }; + }); - request.onerror = () => { + request.addEventListener("error", () => { reject(request.error ?? new Error("Failed to open attachment IndexedDB.")); - }; + }); }); } @@ -60,17 +60,17 @@ function runTx( const store = transaction.objectStore(STORE_NAME); const request = run(store); - request.onsuccess = () => { + request.addEventListener("success", () => { resolve(request.result as T); - }; + }); - request.onerror = () => { + request.addEventListener("error", () => { reject(request.error ?? new Error("IndexedDB transaction request failed.")); - }; + }); - transaction.onerror = () => { + transaction.addEventListener("error", () => { reject(transaction.error ?? new Error("IndexedDB transaction failed.")); - }; + }); }); } @@ -203,13 +203,13 @@ export function createIndexedDbAttachmentStore(): AttachmentStore { const store = tx.objectStore(STORE_NAME); const cursorRequest = store.openCursor(); - cursorRequest.onerror = () => { + cursorRequest.addEventListener("error", () => { reject( cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."), ); - }; + }); - cursorRequest.onsuccess = () => { + cursorRequest.addEventListener("success", () => { const cursor = cursorRequest.result; if (!cursor) { resolve(); @@ -221,11 +221,11 @@ export function createIndexedDbAttachmentStore(): AttachmentStore { cursor.delete(); } cursor.continue(); - }; + }); - tx.onerror = () => { + tx.addEventListener("error", () => { reject(tx.error ?? new Error("Failed to garbage collect IndexedDB attachments.")); - }; + }); }); } finally { db.close(); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index fd6e131bc..0618a1fd7 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,5 +1,5 @@ import { forwardRef, useCallback, useEffect, useMemo } from "react"; -import type { ReactNode } from "react"; +import type { ReactNode, Ref } from "react"; import { createPortal } from "react-dom"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import type { TextInputProps } from "react-native"; @@ -24,6 +24,7 @@ import { isWeb } from "@/constants/platform"; type EscHandler = () => void; const escStack: EscHandler[] = []; let escListenerAttached = false; +const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; function handleEscKeyDown(event: KeyboardEvent) { if (event.key !== "Escape") return; @@ -143,18 +144,18 @@ const styles = StyleSheet.create((theme) => ({ function SheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); - return ( - + const combinedStyle = useMemo( + () => [ + style, + { + backgroundColor: theme.colors.surface1, + borderTopLeftRadius: theme.borderRadius.xl, + borderTopRightRadius: theme.borderRadius.xl, + }, + ], + [style, theme.colors.surface1, theme.borderRadius.xl], ); + return ; } export interface AdaptiveModalSheetProps { @@ -191,6 +192,10 @@ export function AdaptiveModalSheet({ const isMobile = useIsCompactFormFactor(); const titleColor = theme.colors.foreground; const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); + const handleIndicatorStyle = useMemo( + () => ({ backgroundColor: theme.colors.surface2 }), + [theme.colors.surface2], + ); const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ visible, isEnabled: isMobile, @@ -204,6 +209,12 @@ export function AdaptiveModalSheet({ [], ); + const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]); + const desktopCardStyle = useMemo( + () => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }], + [desktopMaxWidth], + ); + useEffect(() => { if (!isWeb || isMobile || !visible) return; return pushEscHandler(onClose); @@ -220,14 +231,14 @@ export function AdaptiveModalSheet({ backdropComponent={renderBackdrop} enablePanDownToClose backgroundComponent={SheetBackground} - handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }} + handleIndicatorStyle={handleIndicatorStyle} keyboardBehavior="extend" keyboardBlurBehavior="restore" accessible={false} > - + {title} {subtitle} @@ -256,7 +267,7 @@ export function AdaptiveModalSheet({ <> - + {title} {subtitle} @@ -283,12 +294,8 @@ export function AdaptiveModalSheet({ const desktopContent = ( - - + + {onFilesDropped ? ( {cardInner} ) : ( @@ -326,7 +333,7 @@ export const AdaptiveTextInput = forwardRef( const isMobile = useIsCompactFormFactor(); if (isMobile) { - return ; + return } {...props} />; } return ; diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 5877a8d02..5c81e79ba 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Text, TextInput, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -10,6 +10,8 @@ import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon- import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; +const FLEX_ONE_STYLE = { flex: 1 } as const; + const styles = StyleSheet.create((theme) => ({ field: { gap: theme.spacing[2], @@ -158,6 +160,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod hostInputRef.current?.clear(); }, []); + const connectIcon = useMemo( + () => , + [theme.colors.palette.white], + ); + const handleClose = useCallback(() => { if (isSaving) return; clearInput(); @@ -214,13 +221,15 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); } catch (error) { - const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error); - const combined = - raw && detail && raw !== detail - ? `${title}\n${detail}\nDetails: ${raw}` - : detail - ? `${title}\n${detail}` - : title; + const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error); + let combined: string; + if (rawDetail && detail && rawDetail !== detail) { + combined = `${title}\n${detail}\nDetails: ${rawDetail}`; + } else if (detail) { + combined = `${title}\n${detail}`; + } else { + combined = title; + } setErrorMessage(combined); if (!isMobile) { // Desktop/web: also surface it as a dialog for quick visibility. @@ -231,6 +240,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod } }, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]); + const handleChangeEndpoint = useCallback((next: string) => { + endpointRawRef.current = next; + }, []); + + const handleSubmitEditing = useCallback(() => { + void handleSave(); + }, [handleSave]); + + const handleSavePress = useCallback(() => { + void handleSave(); + }, [handleSave]); + return ( { - endpointRawRef.current = next; - }} + onChangeText={handleChangeEndpoint} placeholder="hostname:port" placeholderTextColor={theme.colors.foregroundMuted} style={styles.input} @@ -258,21 +277,26 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod keyboardType="url" editable={!isSaving} returnKeyType="done" - onSubmitEditing={() => void handleSave()} + onSubmitEditing={handleSubmitEditing} /> {errorMessage ? {errorMessage} : null} - ), - TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => children, })); vi.mock("@/components/ui/dropdown-menu", () => { @@ -441,9 +441,8 @@ vi.mock("@/components/ui/dropdown-menu", () => { return { DropdownMenu: ({ children }: { children: React.ReactNode }) => { const [open, setOpen] = React.useState(false); - return ( - {children} - ); + const contextValue = React.useMemo(() => ({ open, setOpen }), [open]); + return {children}; }, DropdownMenuTrigger: ({ children, @@ -459,13 +458,14 @@ vi.mock("@/components/ui/dropdown-menu", () => { disabled?: boolean; }) => { const menu = React.useContext(DropdownContext); + const handleClick = React.useCallback(() => menu?.setOpen(true), [menu]); return ( ), - TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => children, })); vi.mock("@/components/ui/dropdown-menu", () => ({ - DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenu: ({ children }: { children: React.ReactNode }) => children, DropdownMenuTrigger: ({ children, testID, @@ -251,10 +255,10 @@ function renderMessageInput( value={value} onChangeText={vi.fn()} onSubmit={vi.fn()} - attachments={[]} + attachments={EMPTY_ATTACHMENTS} cwd="/repo" attachmentMenuItems={menuItems} - client={{ isConnected: true } as never} + client={FAKE_CONNECTED_CLIENT} isAgentRunning={false} submitIcon={submitIcon} onQueue={vi.fn()} @@ -346,10 +350,10 @@ describe("MessageInput dictation shortcuts", () => { value="" onChangeText={vi.fn()} onSubmit={vi.fn()} - attachments={[]} + attachments={EMPTY_ATTACHMENTS} cwd="/repo" - attachmentMenuItems={[]} - client={{ isConnected: true } as never} + attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS} + client={FAKE_CONNECTED_CLIENT} isAgentRunning={false} isReadyForDictation={false} onQueue={vi.fn()} @@ -370,10 +374,10 @@ describe("MessageInput dictation shortcuts", () => { value="" onChangeText={vi.fn()} onSubmit={vi.fn()} - attachments={[]} + attachments={EMPTY_ATTACHMENTS} cwd="/repo" - attachmentMenuItems={[]} - client={{ isConnected: true } as never} + attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS} + client={FAKE_CONNECTED_CLIENT} isAgentRunning={false} isReadyForDictation onQueue={vi.fn()} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 65a5bbfb5..bf29c908f 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -15,9 +15,11 @@ import { useEffect, useLayoutEffect, useImperativeHandle, + useMemo, forwardRef, } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useDictation } from "@/hooks/use-dictation"; @@ -152,7 +154,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< } >; -type TextAreaHandle = { +interface TextAreaHandle { scrollHeight?: number; clientHeight?: number; offsetHeight?: number; @@ -163,7 +165,7 @@ type TextAreaHandle = { height?: string; overflowY?: string; } & Record; -}; +} function logWebStickyBottom(_event: string, _details: Record): void { // Intentionally disabled: this path is too noisy during voice debugging. @@ -182,548 +184,385 @@ function getElementDescriptor(element: HTMLElement | null): string | null { const id = element.id ? `#${element.id}` : ""; const testId = element.getAttribute?.("data-testid"); const label = element.getAttribute?.("aria-label"); - const suffix = testId ? `[data-testid="${testId}"]` : label ? `[aria-label="${label}"]` : ""; + let suffix: string; + if (testId) suffix = `[data-testid="${testId}"]`; + else if (label) suffix = `[aria-label="${label}"]`; + else suffix = ""; return `${tag}${id}${suffix}`; } -function getScrollableAncestorChain(element: HTMLElement | null): string[] { - if (!element || typeof window === "undefined") { - return []; - } - const results: string[] = []; - let current = element.parentElement; - while (current) { - const style = window.getComputedStyle(current); - const overflowY = style.overflowY; - const canScroll = - (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && - current.scrollHeight > current.clientHeight; - if (canScroll) { - results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase()); - } - current = current.parentElement; - } - return results; +function AttachButtonIcon({ + hovered, + onAttachButtonRef, + buttonIconSize, + foreground, + foregroundMuted, +}: { + hovered: boolean; + onAttachButtonRef: ((node: View | null) => void) | undefined; + buttonIconSize: number; + foreground: string; + foregroundMuted: string; +}) { + return ( + + + + ); } -export const MessageInput = forwardRef(function MessageInput( - { - value, - onChangeText, - onSubmit, - hasExternalContent = false, - allowEmptySubmit = false, - submitButtonAccessibilityLabel, - submitIcon = "arrow", - isSubmitDisabled = false, - isSubmitLoading = false, - attachments, - cwd, - attachmentMenuItems, - onAttachButtonRef, - onAddImages, - client, - isReadyForDictation, - placeholder = "Message...", - autoFocus = false, - autoFocusKey, - disabled = false, - isPaneFocused = true, - leftContent, - beforeVoiceContent, - rightContent, - voiceServerId, - voiceAgentId, - isAgentRunning = false, - defaultSendBehavior = "interrupt", - onQueue, - onSubmitLoadingPress, - onKeyPress: onKeyPressCallback, - onSelectionChange: onSelectionChangeCallback, - onFocusChange, - onHeightChange, - inputWrapperStyle, - }, - ref, -) { - const { theme } = useUnistyles(); - const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; - const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; - markScrollInvestigationRender(investigationComponentId); - const toast = useToast(); - const voice = useVoiceOptional(); - const sendKeys = useShortcutKeys("message-input-send"); - const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); - const dictationToggleKeys = useShortcutKeys("dictation-toggle"); - const focusInputKeys = useShortcutKeys("focus-message-input"); - const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); - const [isInputFocused, setIsInputFocused] = useState(false); - const rootRef = useRef(null); - const inputWrapperRef = useRef(null); - const textInputRef = useRef unknown }) | null>( - null, +function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) { + return ( + <> + {items.map((item) => ( + + {item.label} + + ))} + ); - const isInputFocusedRef = useRef(false); +} - useImperativeHandle(ref, () => ({ - focus: () => { - textInputRef.current?.focus(); - }, - blur: () => { - textInputRef.current?.blur?.(); - }, - runKeyboardAction: (action) => { - if (action === "focus") { - textInputRef.current?.focus(); - return true; - } - - if (action === "send" || action === "dictation-confirm") { - if (isDictatingRef.current) { - sendAfterTranscriptRef.current = true; - confirmDictation(); - return true; - } - return false; - } - - if (action === "voice-toggle") { - handleToggleRealtimeVoiceShortcut(); - return true; - } - - if (action === "voice-mute-toggle") { - if (isRealtimeVoiceForCurrentAgent) { - voice?.toggleMute(); - } - return true; - } - - if (action === "dictation-cancel") { - if (isDictatingRef.current) { - cancelDictation(); - return true; - } - return false; - } - - if (action === "dictation-toggle") { - if (isDictatingRef.current) { - sendAfterTranscriptRef.current = true; - confirmDictation(); - } else { - void startDictationIfAvailable(); - } - return true; - } - - return false; - }, - getNativeElement: () => { - if (!isWeb) return null; - const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null; - const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; - return native instanceof HTMLElement ? native : null; - }, - })); - const inputHeightRef = useRef(MIN_INPUT_HEIGHT); - const overlayTransition = useSharedValue(0); - const sendAfterTranscriptRef = useRef(false); - const valueRef = useRef(value); - const serverInfo = useSessionStore( - useCallback( - (state) => { - if (!voiceServerId) { - return null; - } - return state.sessions[voiceServerId]?.serverInfo ?? null; - }, - [voiceServerId], - ), +function AttachmentDropdown({ + isConnected, + disabled, + attachButtonStyle, + renderAttachButtonIcon, + attachmentMenuItems, +}: { + isConnected: boolean; + disabled: boolean; + attachButtonStyle: React.ComponentProps["style"]; + renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; + attachmentMenuItems: AttachmentMenuItem[]; +}) { + return ( + + + + + {renderAttachButtonIcon} + + + + Add attachment + + + + + + ); +} - useEffect(() => { - valueRef.current = value; - }, [value]); +function VoiceButtonIcon({ + hovered, + isDictating, + isMutedRealtime, + buttonIconSize, + foreground, + foregroundMuted, +}: { + hovered: boolean; + isDictating: boolean; + isMutedRealtime: boolean; + buttonIconSize: number; + foreground: string; + foregroundMuted: string; +}) { + if (isDictating) { + return ; + } + if (isMutedRealtime) { + return ; + } + return ; +} - useEffect(() => { - return () => { - onFocusChange?.(false); - }; - }, [onFocusChange]); +type ShortcutChord = NonNullable["chord"]>; - // Autofocus on web when autoFocus is true, and re-run when focus key changes. - useEffect(() => { - if (!isWeb || !autoFocus) return; - return focusWithRetries({ - focus: () => textInputRef.current?.focus(), - isFocused: () => { - const current = textInputRef.current as - | (TextInput & { getNativeRef?: () => unknown }) - | null; - const native = - typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; - const element = native instanceof HTMLElement ? native : null; - const active = typeof document !== "undefined" ? document.activeElement : null; - return Boolean(element) && active === element; - }, - }); - }, [autoFocus, autoFocusKey]); - - const handleDictationTranscript = useCallback( - (text: string, _meta: { requestId: string }) => { - if (!text) return; - const current = valueRef.current; - const shouldPad = current.length > 0 && !/\s$/.test(current); - const nextValue = `${current}${shouldPad ? " " : ""}${text}`; - - const shouldAutoSend = sendAfterTranscriptRef.current; - sendAfterTranscriptRef.current = false; - - if (shouldAutoSend) { - // Respect send behavior setting: when "queue", dictation queues too. - if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { - onQueue({ text: nextValue, attachments, cwd }); - onChangeText(""); - } else { - onSubmit({ - text: nextValue, - attachments, - cwd, - forceSend: isAgentRunning || undefined, - }); - } - } else { - onChangeText(nextValue); - } - }, - [onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior], +function VoiceTooltipBody({ + voiceTooltipText, + shortcut, +}: { + voiceTooltipText: string; + shortcut: ShortcutChord | null | undefined; +}) { + return ( + + {voiceTooltipText} + {shortcut ? : null} + ); +} - const handleDictationError = useCallback( - (error: Error) => { - console.error("[MessageInput] Dictation error:", error); - toast.error(error.message); - }, - [toast], +function SendTooltipBody({ + label, + sendKeys, +}: { + label: string; + sendKeys: ShortcutChord | null | undefined; +}) { + return ( + + {label} + {sendKeys ? : null} + ); +} - const dictationUnavailableMessage = resolveVoiceUnavailableMessage({ - serverInfo, - mode: "dictation", - }); +function SendButtonContent({ + isSubmitLoading, + submitIcon, + buttonIconSize, +}: { + isSubmitLoading: boolean; + submitIcon: "arrow" | "return"; + buttonIconSize: number; +}) { + if (isSubmitLoading) { + return ; + } + if (submitIcon === "return") { + return ; + } + return ; +} - const canStartDictation = useCallback(() => { - const socketConnected = client?.isConnected ?? false; - const readyForDictation = isReadyForDictation ?? socketConnected; - return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage; - }, [client, disabled, dictationUnavailableMessage, isReadyForDictation]); +function resolveSubmitAccessibilityLabel(input: { + submitButtonAccessibilityLabel: string | undefined; + canPressLoadingButton: boolean; + defaultActionQueues: boolean; + isAgentRunning: boolean; +}): string { + if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; + if (input.canPressLoadingButton) return "Interrupt agent"; + if (input.defaultActionQueues) return "Queue message"; + if (input.isAgentRunning) return "Send and interrupt"; + return "Send message"; +} - const canConfirmDictation = useCallback(() => { - const socketConnected = client?.isConnected ?? false; - return socketConnected; - }, [client]); - const isConnected = client?.isConnected ?? false; - const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled; +function resolveVoiceAccessibilityLabel(input: { + isRealtimeVoiceForCurrentAgent: boolean; + isMuted: boolean; + isDictating: boolean; +}): string { + if (input.isRealtimeVoiceForCurrentAgent) { + return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode"; + } + if (input.isDictating) return "Stop dictation"; + return "Start dictation"; +} - const { - isRecording: isDictating, - isProcessing: isDictationProcessing, - partialTranscript: dictationPartialTranscript, - volume: dictationVolume, - duration: dictationDuration, - error: dictationError, - status: dictationStatus, - startDictation, - cancelDictation, - confirmDictation, - retryFailedDictation, - discardFailedDictation, - } = useDictation({ - client, - onTranscript: handleDictationTranscript, - onError: handleDictationError, - canStart: canStartDictation, - canConfirm: canConfirmDictation, - autoStopWhenHidden: { isVisible: isPaneFocused }, - enableDuration: true, - }); +function resolveVoiceTooltipText(input: { + isRealtimeVoiceForCurrentAgent: boolean; + isMuted: boolean; +}): string { + if (input.isRealtimeVoiceForCurrentAgent) { + return input.isMuted ? "Unmute voice" : "Mute voice"; + } + return "Dictation"; +} - const isDictatingRef = useRef(isDictating); - useEffect(() => { - isDictatingRef.current = isDictating; - }, [isDictating]); +function resolveSendTooltipLabel(input: { + submitButtonAccessibilityLabel: string | undefined; + defaultActionQueues: boolean; +}): string { + if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; + return input.defaultActionQueues ? "Queue" : "Send"; +} - const isRealtimeVoiceForCurrentAgent = - !!voice && - !!voiceServerId && - !!voiceAgentId && - voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); - const showDictationOverlay = isDictating || isDictationProcessing || dictationStatus === "failed"; - const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; - const showOverlay = showDictationOverlay || showRealtimeOverlay; +interface DesktopKeyPressContext { + investigationComponentId: string; + onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; + isSubmitDisabled: boolean; + isSubmitLoading: boolean; + disabled: boolean; + handleAlternateSendAction: () => void; + handleDefaultSendAction: () => void; +} - useEffect(() => { - if (isDictating || isDictationProcessing) { - return; - } - sendAfterTranscriptRef.current = false; - }, [dictationStatus, isDictating, isDictationProcessing]); +function handleDesktopKeyPressImpl( + event: WebTextInputKeyPressEvent, + ctx: DesktopKeyPressContext, +): void { + markScrollInvestigationEvent(ctx.investigationComponentId, "keyPress"); - const startDictationIfAvailable = useCallback(async () => { - if (dictationUnavailableMessage) { - isDictatingRef.current = false; - toast.error(dictationUnavailableMessage); - return; - } - if (!canStartDictation()) { - isDictatingRef.current = false; - return; - } - // Keep hotkey toggling deterministic between the async start call and the - // state-ref sync effect, so a rapid second toggle routes to confirm. - isDictatingRef.current = true; - await startDictation(); - }, [canStartDictation, dictationUnavailableMessage, startDictation, toast]); + if (isImeComposingKeyboardEvent(event.nativeEvent)) return; - // Animate overlay - useEffect(() => { - overlayTransition.value = withTiming(showOverlay ? 1 : 0, { - duration: 200, + if (ctx.onKeyPressCallback) { + const handled = ctx.onKeyPressCallback({ + key: event.nativeEvent.key, + preventDefault: () => event.preventDefault(), }); - }, [overlayTransition, showOverlay]); - - const overlayAnimatedStyle = useAnimatedStyle(() => ({ - opacity: overlayTransition.value, - pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none", - })); - - const inputAnimatedStyle = useAnimatedStyle(() => ({ - opacity: 1 - overlayTransition.value, - })); - - const handleVoicePress = useCallback(async () => { - if (isRealtimeVoiceForCurrentAgent && voice) { - voice.toggleMute(); - return; - } - - if (isDictating) { - await cancelDictation(); - } else { - await startDictationIfAvailable(); - } - }, [ - cancelDictation, - isDictating, - isRealtimeVoiceForCurrentAgent, - startDictationIfAvailable, - voice, - ]); - - const handleCancelRecording = useCallback(async () => { - await cancelDictation(); - }, [cancelDictation]); - - const handleAcceptRecording = useCallback(async () => { - sendAfterTranscriptRef.current = false; - await confirmDictation(); - }, [confirmDictation]); - - const handleAcceptAndSendRecording = useCallback(async () => { - sendAfterTranscriptRef.current = true; - await confirmDictation(); - }, [confirmDictation]); - - const handleRetryFailedRecording = useCallback(() => { - void retryFailedDictation(); - }, [retryFailedDictation]); - - const handleDiscardFailedRecording = useCallback(() => { - discardFailedDictation(); - }, [discardFailedDictation]); - - const handleStopRealtimeVoice = useCallback(async () => { - if (!voice || !isRealtimeVoiceForCurrentAgent) { - return; - } - - const tasks: Promise[] = []; - if (isAgentRunning && client && voiceAgentId) { - tasks.push(client.cancelAgent(voiceAgentId)); - } - tasks.push(voice.stopVoice()); - - const results = await Promise.allSettled(tasks); - results.forEach((result) => { - if (result.status === "rejected") { - console.error("[MessageInput] Failed to stop realtime voice", result.reason); - } - }); - }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]); - - const handleToggleRealtimeVoiceShortcut = useCallback(() => { - if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) { - return; - } - if (voice.isVoiceSwitching) { - return; - } - if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) { - void handleStopRealtimeVoice(); - return; - } - void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => { - console.error("[MessageInput] Failed to start realtime voice", error); - const message = - error instanceof Error ? error.message : typeof error === "string" ? error : null; - if (message && message.trim().length > 0) { - toast.error(message); - } - }); - }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]); - - const handleSendMessage = useCallback(() => { - const trimmed = value.trim(); - if (!trimmed && attachments.length === 0 && !hasExternalContent && !allowEmptySubmit) return; - const payload = { - text: trimmed, - attachments, - cwd, - forceSend: isAgentRunning || undefined, - }; - onSubmit(payload); - inputHeightRef.current = MIN_INPUT_HEIGHT; - setInputHeight(MIN_INPUT_HEIGHT); - onHeightChange?.(MIN_INPUT_HEIGHT); - }, [ - allowEmptySubmit, - value, - attachments, - cwd, - onSubmit, - isAgentRunning, - onHeightChange, - hasExternalContent, - ]); - - const handleQueueMessage = useCallback(() => { - if (!onQueue) return; - const trimmed = value.trim(); - if (!trimmed && attachments.length === 0) return; - const payload = { - text: trimmed, - attachments, - cwd, - }; - onQueue(payload); - onChangeText(""); - inputHeightRef.current = MIN_INPUT_HEIGHT; - setInputHeight(MIN_INPUT_HEIGHT); - onHeightChange?.(MIN_INPUT_HEIGHT); - }, [value, attachments, cwd, onQueue, onChangeText, onHeightChange]); - - // Default send action: respects the sendBehavior setting. - // When "interrupt" (default), primary action sends immediately (interrupts). - // When "queue", primary action queues when agent is running. - const handleDefaultSendAction = useCallback(() => { - if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { - handleQueueMessage(); - } else { - handleSendMessage(); - } - }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); - - // Alternate send action: always the opposite of the default. - const handleAlternateSendAction = useCallback(() => { - if (defaultSendBehavior === "queue") { - handleSendMessage(); // interrupt - } else if (onQueue) { - handleQueueMessage(); // queue - } - }, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]); - - // Web input height measurement - function isTextAreaLike(v: unknown): v is TextAreaHandle { - return typeof v === "object" && v !== null && "scrollHeight" in v; + if (handled) return; } - const getWebTextArea = useCallback((): TextAreaHandle | null => { - const ref = textInputRef.current; - if (!ref) return null; - if (typeof (ref as any).getNativeRef === "function") { - const native = (ref as any).getNativeRef(); - if (isTextAreaLike(native)) return native; + const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; + + if (event.nativeEvent.key !== "Enter") return; + if (shiftKey) return; + + if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { + if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; + event.preventDefault(); + ctx.handleAlternateSendAction(); + return; + } + + if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; + event.preventDefault(); + ctx.handleDefaultSendAction(); +} + +interface KeyboardActionHandlers { + textInputRef: React.MutableRefObject< + TextInput | (TextInput & { getNativeRef?: () => unknown }) | null + >; + isDictatingRef: React.MutableRefObject; + sendAfterTranscriptRef: React.MutableRefObject; + confirmDictation: () => void | Promise; + cancelDictation: () => void | Promise; + startDictationIfAvailable: () => Promise; + handleToggleRealtimeVoiceShortcut: () => void; + isRealtimeVoiceForCurrentAgent: boolean; + voice: { toggleMute: () => void } | null | undefined; +} + +function runKeyboardActionImpl( + action: MessageInputKeyboardActionKind, + h: KeyboardActionHandlers, +): boolean { + if (action === "focus") { + h.textInputRef.current?.focus(); + return true; + } + if (action === "send" || action === "dictation-confirm") { + if (h.isDictatingRef.current) { + h.sendAfterTranscriptRef.current = true; + void h.confirmDictation(); + return true; } - if (isTextAreaLike(ref)) return ref; - return null; - }, []); - - const webTextareaRef = useRef(null); - - useLayoutEffect(() => { - if (isWeb) { - webTextareaRef.current = getWebTextArea() as HTMLElement | null; + return false; + } + if (action === "voice-toggle") { + h.handleToggleRealtimeVoiceShortcut(); + return true; + } + if (action === "voice-mute-toggle") { + if (h.isRealtimeVoiceForCurrentAgent) { + h.voice?.toggleMute(); } - }, [getWebTextArea]); + return true; + } + if (action === "dictation-cancel") { + if (h.isDictatingRef.current) { + void h.cancelDictation(); + return true; + } + return false; + } + if (action === "dictation-toggle") { + if (h.isDictatingRef.current) { + h.sendAfterTranscriptRef.current = true; + void h.confirmDictation(); + } else { + void h.startDictationIfAvailable(); + } + return true; + } + return false; +} - const inputScrollbar = useWebElementScrollbar(webTextareaRef, { - enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, - }); +function getTextInputNativeElement( + current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null, +): HTMLElement | null { + if (!current) return null; + const handle = current as TextInput & { getNativeRef?: () => unknown }; + const native = typeof handle.getNativeRef === "function" ? handle.getNativeRef() : current; + return native instanceof HTMLElement ? native : null; +} - const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { - const ref = target === "root" ? rootRef.current : inputWrapperRef.current; - if (!ref) return null; - return ref instanceof HTMLElement - ? ref - : (ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect - ? (ref as unknown as HTMLElement) - : null; - }, []); +interface PasteImagesEffectArgs { + getWebTextArea: () => TextAreaHandle | null; + isConnected: boolean; + disabled: boolean; + isDictating: boolean; + isRealtimeVoiceForCurrentAgent: boolean; + onAddImages: ((images: ImageAttachment[]) => void) | undefined; +} + +function usePasteImagesEffect(args: PasteImagesEffectArgs): void { + const { + getWebTextArea, + isConnected, + disabled, + isDictating, + isRealtimeVoiceForCurrentAgent, + onAddImages, + } = args; useEffect(() => { - if (!isWeb || !onAddImages) { - return; - } + if (!isWeb || !onAddImages) return; - const textarea = getWebTextArea(); + const textarea = getWebTextArea() as + | (TextAreaHandle & { + addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; + removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; + }) + | null; if ( !textarea || - typeof (textarea as any).addEventListener !== "function" || - typeof (textarea as any).removeEventListener !== "function" + typeof textarea.addEventListener !== "function" || + typeof textarea.removeEventListener !== "function" ) { return; } let disposed = false; const handlePaste = (event: ClipboardEvent) => { - if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) { - return; - } + if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return; const imageFiles = collectImageFilesFromClipboardData(event.clipboardData); - if (imageFiles.length === 0) { - return; - } + if (imageFiles.length === 0) return; event.preventDefault(); void filesToImageAttachments(imageFiles) - .then((attachments) => { - if (disposed || attachments.length === 0) { - return; - } - onAddImages(attachments); + .then((pastedAttachments) => { + if (disposed || pastedAttachments.length === 0) return; + onAddImages(pastedAttachments); + return; }) .catch((error) => { console.error("[MessageInput] Failed to process pasted images:", error); }); }; - (textarea as any).addEventListener("paste", handlePaste); + textarea.addEventListener("paste", handlePaste); return () => { disposed = true; - (textarea as any).removeEventListener("paste", handlePaste); + textarea.removeEventListener?.("paste", handlePaste); }; }, [ disabled, @@ -733,11 +572,19 @@ export const MessageInput = forwardRef(funct isRealtimeVoiceForCurrentAgent, onAddImages, ]); +} + +interface ResizeObserverEffectArgs { + getWebTextArea: () => TextAreaHandle | null; + getWebElement: (target: "root" | "wrapper") => HTMLElement | null; + valueRef: React.MutableRefObject; +} + +function useComposerResizeObserverEffect(args: ResizeObserverEffectArgs): void { + const { getWebTextArea, getWebElement, valueRef } = args; useEffect(() => { - if (!isWeb || typeof ResizeObserver === "undefined") { - return; - } + if (!isWeb || typeof ResizeObserver === "undefined") return; const textarea = getWebTextArea(); const root = getWebElement("root"); @@ -751,17 +598,13 @@ export const MessageInput = forwardRef(funct entry.element instanceof HTMLElement, ); - if (observed.length === 0) { - return; - } + if (observed.length === 0) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { const target = entry.target as HTMLElement; const match = observed.find((item) => item.element === target); - if (!match) { - continue; - } + if (!match) continue; const textareaNode = getWebTextArea(); logWebStickyBottom("composer_element_resized", { target: match.name, @@ -786,16 +629,21 @@ export const MessageInput = forwardRef(funct return () => { observer.disconnect(); }; - }, [getWebElement, getWebTextArea]); + }, [getWebElement, getWebTextArea, valueRef]); +} + +interface ScrollLogEffectArgs { + getWebTextArea: () => TextAreaHandle | null; + valueRef: React.MutableRefObject; +} + +function useComposerScrollLogEffect(args: ScrollLogEffectArgs): void { + const { getWebTextArea, valueRef } = args; useEffect(() => { - if (!isWeb) { - return; - } + if (!isWeb) return; const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; - if (!textarea || typeof textarea.addEventListener !== "function") { - return; - } + if (!textarea || typeof textarea.addEventListener !== "function") return; const handleScroll = () => { const textareaElement = textarea as unknown as HTMLElement; @@ -824,361 +672,1358 @@ export const MessageInput = forwardRef(funct return () => { textarea.removeEventListener("scroll", handleScroll); }; - }, [getWebTextArea]); + }, [getWebTextArea, valueRef]); +} - function setBoundedInputHeight(nextHeight: number) { - const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); - if (Math.abs(inputHeightRef.current - bounded) < 1) return; - inputHeightRef.current = bounded; - setInputHeight(bounded); - onHeightChange?.(bounded); +function useAutoFocusOnWebEffect( + textInputRef: React.MutableRefObject< + TextInput | (TextInput & { getNativeRef?: () => unknown }) | null + >, + autoFocus: boolean, + autoFocusKey: string | undefined, +): void { + useEffect(() => { + if (!isWeb || !autoFocus) return; + return focusWithRetries({ + focus: () => textInputRef.current?.focus(), + isFocused: () => { + const element = getTextInputNativeElement(textInputRef.current); + const active = typeof document !== "undefined" ? document.activeElement : null; + return Boolean(element) && active === element; + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoFocus, autoFocusKey]); +} + +function MessageInputOverlay({ + showDictationOverlay, + showRealtimeOverlay, + voice, + dictationVolume, + dictationDuration, + isDictating, + isDictationProcessing, + dictationStatus, + dictationError, + onCancelRecording, + onAcceptRecording, + onAcceptAndSendRecording, + onRetryFailedRecording, + onDiscardFailedRecording, + onRealtimeVoiceStop, +}: { + showDictationOverlay: boolean; + showRealtimeOverlay: boolean; + voice: + | { + isMuted: boolean; + isVoiceSwitching: boolean; + toggleMute: () => void; + } + | null + | undefined; + dictationVolume: number; + dictationDuration: number; + isDictating: boolean; + isDictationProcessing: boolean; + dictationStatus: React.ComponentProps["status"]; + dictationError: string | null; + onCancelRecording: () => Promise; + onAcceptRecording: () => Promise; + onAcceptAndSendRecording: () => Promise; + onRetryFailedRecording: () => void; + onDiscardFailedRecording: () => void; + onRealtimeVoiceStop: () => void; +}) { + if (showDictationOverlay) { + return ( + + ); + } + if (showRealtimeOverlay && voice) { + return ( + + ); + } + return null; +} + +function FocusHint({ + visible, + focusInputKeys, +}: { + visible: boolean; + focusInputKeys: ShortcutChord | null | undefined; +}) { + if (!visible || !focusInputKeys) return null; + return ( + + {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus + + ); +} + +function VoiceButtonTooltip({ + onVoicePress, + isDictationStartEnabled, + voiceButtonAccessibilityLabel, + voiceButtonStyle, + renderVoiceButtonIcon, + voiceTooltipText, + isRealtimeVoiceForCurrentAgent, + voiceMuteToggleKeys, + dictationToggleKeys, +}: { + onVoicePress: () => void; + isDictationStartEnabled: boolean; + voiceButtonAccessibilityLabel: string; + voiceButtonStyle: React.ComponentProps["style"]; + renderVoiceButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; + voiceTooltipText: string; + isRealtimeVoiceForCurrentAgent: boolean; + voiceMuteToggleKeys: ShortcutChord | null | undefined; + dictationToggleKeys: ShortcutChord | null | undefined; +}) { + const shortcut = isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys; + return ( + + + {renderVoiceButtonIcon} + + + + + + ); +} + +function SendButtonTooltip({ + shouldShow, + canPressLoadingButton, + onSubmitLoadingPress, + onDefaultSendAction, + isSendButtonDisabled, + submitAccessibilityLabel, + sendButtonCombinedStyle, + isSubmitLoading, + submitIcon, + buttonIconSize, + submitButtonAccessibilityLabel, + defaultActionQueues, + sendKeys, +}: { + shouldShow: boolean; + canPressLoadingButton: boolean; + onSubmitLoadingPress: (() => void) | undefined; + onDefaultSendAction: () => void; + isSendButtonDisabled: boolean; + submitAccessibilityLabel: string; + sendButtonCombinedStyle: React.ComponentProps["style"]; + isSubmitLoading: boolean; + submitIcon: "arrow" | "return"; + buttonIconSize: number; + submitButtonAccessibilityLabel: string | undefined; + defaultActionQueues: boolean; + sendKeys: ShortcutChord | null | undefined; +}) { + if (!shouldShow) return null; + return ( + + + + + + + + + ); +} + +interface DictationTranscriptContext { + value: string; + defaultSendBehavior: "interrupt" | "queue"; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; + onSubmit: (payload: MessagePayload) => void; + onChangeText: (text: string) => void; + attachments: ComposerAttachment[]; + cwd: string; + autoSend: boolean; +} + +function applyDictationTranscript(text: string, ctx: DictationTranscriptContext): void { + if (!text) return; + const shouldPad = ctx.value.length > 0 && !/\s$/.test(ctx.value); + const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`; + + if (!ctx.autoSend) { + ctx.onChangeText(nextValue); + return; } - useComposerHeightMirror({ - value, - textareaRef: webTextareaRef, + if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) { + ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd }); + ctx.onChangeText(""); + return; + } + + ctx.onSubmit({ + text: nextValue, + attachments: ctx.attachments, + cwd: ctx.cwd, + forceSend: ctx.isAgentRunning || undefined, + }); +} + +interface ToggleRealtimeVoiceContext { + voice: + | { + isVoiceSwitching: boolean; + isVoiceModeForAgent: (serverId: string, agentId: string) => boolean; + startVoice: (serverId: string, agentId: string) => Promise; + } + | null + | undefined; + voiceServerId: string | undefined; + voiceAgentId: string | undefined; + isConnected: boolean; + disabled: boolean; + handleStopRealtimeVoice: () => Promise | void; + toast: { error: (msg: string) => void }; +} + +function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { + if (!ctx.voice || !ctx.voiceServerId || !ctx.voiceAgentId || !ctx.isConnected || ctx.disabled) { + return; + } + if (ctx.voice.isVoiceSwitching) return; + if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) { + void ctx.handleStopRealtimeVoice(); + return; + } + void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { + console.error("[MessageInput] Failed to start realtime voice", error); + const message = extractErrorMessage(error); + if (message && message.trim().length > 0) { + ctx.toast.error(message); + } + }); +} + +interface StartDictationContext { + dictationUnavailableMessage: string | null | undefined; + canStartDictation: () => boolean; + isDictatingRef: React.MutableRefObject; + toast: { error: (msg: string) => void }; + startDictation: () => Promise; +} + +async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { + if (ctx.dictationUnavailableMessage) { + ctx.isDictatingRef.current = false; + ctx.toast.error(ctx.dictationUnavailableMessage); + return; + } + if (!ctx.canStartDictation()) { + ctx.isDictatingRef.current = false; + return; + } + ctx.isDictatingRef.current = true; + await ctx.startDictation(); +} + +interface StopRealtimeVoiceContext { + voice: { stopVoice: () => Promise } | null | undefined; + isRealtimeVoiceForCurrentAgent: boolean; + isAgentRunning: boolean; + client: { cancelAgent: (agentId: string) => Promise } | null; + voiceAgentId: string | undefined; +} + +async function stopRealtimeVoiceImpl(ctx: StopRealtimeVoiceContext): Promise { + if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return; + + const tasks: Promise[] = []; + if (ctx.isAgentRunning && ctx.client && ctx.voiceAgentId) { + tasks.push(ctx.client.cancelAgent(ctx.voiceAgentId)); + } + tasks.push(ctx.voice.stopVoice()); + + const results = await Promise.allSettled(tasks); + results.forEach((result) => { + if (result.status === "rejected") { + console.error("[MessageInput] Failed to stop realtime voice", result.reason); + } + }); +} + +interface VoicePressContext { + isRealtimeVoiceForCurrentAgent: boolean; + voice: { toggleMute: () => void } | null | undefined; + isDictating: boolean; + cancelDictation: () => Promise | void; + startDictationIfAvailable: () => Promise; +} + +async function handleVoicePressImpl(ctx: VoicePressContext): Promise { + if (ctx.isRealtimeVoiceForCurrentAgent && ctx.voice) { + ctx.voice.toggleMute(); + return; + } + if (ctx.isDictating) { + await ctx.cancelDictation(); + return; + } + await ctx.startDictationIfAvailable(); +} + +interface SendMessageContext { + value: string; + attachments: ComposerAttachment[]; + hasExternalContent: boolean; + allowEmptySubmit: boolean; + cwd: string; + isAgentRunning: boolean; + onSubmit: (payload: MessagePayload) => void; + onMinimizeHeight: () => void; +} + +function sendMessageImpl(ctx: SendMessageContext): void { + const trimmed = ctx.value.trim(); + if ( + !trimmed && + ctx.attachments.length === 0 && + !ctx.hasExternalContent && + !ctx.allowEmptySubmit + ) { + return; + } + ctx.onSubmit({ + text: trimmed, + attachments: ctx.attachments, + cwd: ctx.cwd, + forceSend: ctx.isAgentRunning || undefined, + }); + ctx.onMinimizeHeight(); +} + +interface QueueMessageContext { + value: string; + attachments: ComposerAttachment[]; + cwd: string; + onQueue: ((payload: MessagePayload) => void) | undefined; + onChangeText: (text: string) => void; + onMinimizeHeight: () => void; +} + +function queueMessageImpl(ctx: QueueMessageContext): void { + if (!ctx.onQueue) return; + const trimmed = ctx.value.trim(); + if (!trimmed && ctx.attachments.length === 0) return; + ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); + ctx.onChangeText(""); + ctx.onMinimizeHeight(); +} + +function computeInvestigationComponentId( + voiceServerId: string | undefined, + voiceAgentId: string | undefined, +): string { + return `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; +} + +function computeIsRealtimeVoiceForAgent( + voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined, + voiceServerId: string | undefined, + voiceAgentId: string | undefined, +): boolean { + if (!voice || !voiceServerId || !voiceAgentId) return false; + return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); +} + +function computeShouldShowDictationOverlay( + isDictating: boolean, + isDictationProcessing: boolean, + dictationStatus: string, +): boolean { + return isDictating || isDictationProcessing || dictationStatus === "failed"; +} + +interface SendableContentInput { + value: string; + attachments: ComposerAttachment[]; + hasExternalContent: boolean; + allowEmptySubmit: boolean; + isSubmitLoading: boolean; +} + +interface SendableContentOutput { + hasAttachments: boolean; + hasRealContent: boolean; + hasSendableContent: boolean; + shouldShowSendButton: boolean; +} + +function computeSendableContent(input: SendableContentInput): SendableContentOutput { + const hasAttachments = input.attachments.length > 0; + const hasRealContent = input.value.trim().length > 0 || hasAttachments; + const hasSendableContent = hasRealContent || input.hasExternalContent; + const shouldShowSendButton = + hasSendableContent || input.allowEmptySubmit || input.isSubmitLoading; + return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton }; +} + +function computeCanStartDictation(input: { + client: DaemonClient | null; + isReadyForDictation: boolean | undefined; + disabled: boolean; + dictationUnavailableMessage: string | null | undefined; +}): boolean { + const socketConnected = input.client?.isConnected ?? false; + const readyForDictation = input.isReadyForDictation ?? socketConnected; + return ( + socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage + ); +} + +function computeIsDictationStartEnabled( + isReadyForDictation: boolean | undefined, + isConnected: boolean, + disabled: boolean, +): boolean { + return (isReadyForDictation ?? isConnected) && !disabled; +} + +function computeTextInputHeightStyle(inputHeight: number) { + if (isWeb) { + return { + height: inputHeight, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: MAX_INPUT_HEIGHT, + }; + } + return { minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, - onHeight: setBoundedInputHeight, - }); + }; +} - function handleContentSizeChange( - event: NativeSyntheticEvent, - ) { - if (isWeb) return; - setBoundedInputHeight(event.nativeEvent.contentSize.height); +function isTextAreaLike(v: unknown): v is TextAreaHandle { + return typeof v === "object" && v !== null && "scrollHeight" in v; +} + +function getWebTextAreaImpl( + current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null, +): TextAreaHandle | null { + if (!current) return null; + const candidate = current as { getNativeRef?: () => unknown }; + if (typeof candidate.getNativeRef === "function") { + const native = candidate.getNativeRef(); + if (isTextAreaLike(native)) return native; } + if (isTextAreaLike(current)) return current; + return null; +} - function handleSelectionChange(event: NativeSyntheticEvent) { - const start = event.nativeEvent.selection?.start ?? 0; - const end = event.nativeEvent.selection?.end ?? start; - if (isWeb) { - const textarea = getWebTextArea(); - logWebStickyBottom("composer_selection_changed", { - now: getDebugNow(), - start, - end, - textareaScrollTop: textarea?.scrollTop ?? null, - textareaClientHeight: textarea?.clientHeight ?? null, - textareaScrollHeight: textarea?.scrollHeight ?? null, - }); - } - onSelectionChangeCallback?.({ start, end }); +function toHtmlElement(current: unknown): HTMLElement | null { + if (!current) return null; + if (current instanceof HTMLElement) return current; + const maybe = current as { getBoundingClientRect?: () => DOMRect }; + if (maybe.getBoundingClientRect) { + return current as HTMLElement; } + return null; +} - const shouldHandleDesktopSubmit = isWeb; +interface SendButtonStateInput { + disabled: boolean; + isSubmitDisabled: boolean; + isSubmitLoading: boolean; + onSubmitLoadingPress: (() => void) | undefined; + defaultSendBehavior: "interrupt" | "queue"; + isAgentRunning: boolean; +} - function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - markScrollInvestigationEvent(investigationComponentId, "keyPress"); - if (!shouldHandleDesktopSubmit) return; +interface SendButtonStateOutput { + canPressLoadingButton: boolean; + isSendButtonDisabled: boolean; + defaultActionQueues: boolean; +} - // IME composition in progress (e.g. CJK input) — all key events belong to the - // IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is - // cleared before the keydown fires. - if (isImeComposingKeyboardEvent(event.nativeEvent)) return; - - // Allow parent to intercept key events (e.g., for autocomplete navigation) - if (onKeyPressCallback) { - const handled = onKeyPressCallback({ - key: event.nativeEvent.key, - preventDefault: () => event.preventDefault(), - }); - if (handled) return; - } - - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - - if (event.nativeEvent.key !== "Enter") return; - - // Shift+Enter: add newline (default behavior, don't intercept) - if (shiftKey) return; - - // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action - if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { - if (isSubmitDisabled || isSubmitLoading || disabled) return; - event.preventDefault(); - handleAlternateSendAction(); - return; - } - - // Enter: default send action (interrupt or queue, based on setting) - if (isSubmitDisabled || isSubmitLoading || disabled) return; - event.preventDefault(); - handleDefaultSendAction(); - } - - const hasAttachments = attachments.length > 0; - const hasRealContent = value.trim().length > 0 || hasAttachments; - const hasSendableContent = hasRealContent || hasExternalContent; - const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading; - const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; +function computeSendButtonState(input: SendButtonStateInput): SendButtonStateOutput { + const canPressLoadingButton = + input.isSubmitLoading && typeof input.onSubmitLoadingPress === "function"; const isSendButtonDisabled = - disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); - const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning; - const defaultSubmitAccessibilityLabel = canPressLoadingButton - ? "Interrupt agent" - : defaultActionQueues - ? "Queue message" - : isAgentRunning - ? "Send and interrupt" - : "Send message"; - const submitAccessibilityLabel = - submitButtonAccessibilityLabel ?? defaultSubmitAccessibilityLabel; + input.disabled || (!canPressLoadingButton && (input.isSubmitDisabled || input.isSubmitLoading)); + const defaultActionQueues = input.defaultSendBehavior === "queue" && input.isAgentRunning; + return { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues }; +} - const handleInputChange = useCallback( - (nextValue: string) => { - markScrollInvestigationEvent(investigationComponentId, "inputChange"); - onChangeText(nextValue); - if (isWeb) { - logWebStickyBottom("composer_text_changed", { - valueLength: nextValue.length, - lineCount: nextValue.split("\n").length, +interface DefaultSendActionContext { + defaultSendBehavior: "interrupt" | "queue"; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; + handleSendMessage: () => void; + handleQueueMessage: () => void; +} + +function runDefaultSendAction(ctx: DefaultSendActionContext): void { + if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) { + ctx.handleQueueMessage(); + return; + } + ctx.handleSendMessage(); +} + +function runAlternateSendAction(ctx: DefaultSendActionContext): void { + if (ctx.defaultSendBehavior === "queue") { + ctx.handleSendMessage(); + return; + } + if (ctx.onQueue) { + ctx.handleQueueMessage(); + } +} + +interface ResolvedMessageInputProps { + value: string; + onChangeText: (text: string) => void; + onSubmit: (payload: MessagePayload) => void; + hasExternalContent: boolean; + allowEmptySubmit: boolean; + submitButtonAccessibilityLabel: string | undefined; + submitIcon: "arrow" | "return"; + isSubmitDisabled: boolean; + isSubmitLoading: boolean; + attachments: ComposerAttachment[]; + cwd: string; + attachmentMenuItems: AttachmentMenuItem[]; + onAttachButtonRef: ((node: View | null) => void) | undefined; + onAddImages: ((images: ImageAttachment[]) => void) | undefined; + client: DaemonClient | null; + isReadyForDictation: boolean | undefined; + placeholder: string; + autoFocus: boolean; + autoFocusKey: string | undefined; + disabled: boolean; + isPaneFocused: boolean; + leftContent: React.ReactNode; + beforeVoiceContent: React.ReactNode; + rightContent: React.ReactNode; + voiceServerId: string | undefined; + voiceAgentId: string | undefined; + isAgentRunning: boolean; + defaultSendBehavior: "interrupt" | "queue"; + onQueue: ((payload: MessagePayload) => void) | undefined; + onSubmitLoadingPress: (() => void) | undefined; + onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; + onSelectionChangeCallback: ((selection: { start: number; end: number }) => void) | undefined; + onFocusChange: ((focused: boolean) => void) | undefined; + onHeightChange: ((height: number) => void) | undefined; + inputWrapperStyle: import("react-native").ViewStyle | undefined; +} + +function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps { + return { + value: props.value, + onChangeText: props.onChangeText, + onSubmit: props.onSubmit, + hasExternalContent: props.hasExternalContent ?? false, + allowEmptySubmit: props.allowEmptySubmit ?? false, + submitButtonAccessibilityLabel: props.submitButtonAccessibilityLabel, + submitIcon: props.submitIcon ?? "arrow", + isSubmitDisabled: props.isSubmitDisabled ?? false, + isSubmitLoading: props.isSubmitLoading ?? false, + attachments: props.attachments, + cwd: props.cwd, + attachmentMenuItems: props.attachmentMenuItems, + onAttachButtonRef: props.onAttachButtonRef, + onAddImages: props.onAddImages, + client: props.client, + isReadyForDictation: props.isReadyForDictation, + placeholder: props.placeholder ?? "Message...", + autoFocus: props.autoFocus ?? false, + autoFocusKey: props.autoFocusKey, + disabled: props.disabled ?? false, + isPaneFocused: props.isPaneFocused ?? true, + leftContent: props.leftContent, + beforeVoiceContent: props.beforeVoiceContent, + rightContent: props.rightContent, + voiceServerId: props.voiceServerId, + voiceAgentId: props.voiceAgentId, + isAgentRunning: props.isAgentRunning ?? false, + defaultSendBehavior: props.defaultSendBehavior ?? "interrupt", + onQueue: props.onQueue, + onSubmitLoadingPress: props.onSubmitLoadingPress, + onKeyPressCallback: props.onKeyPress, + onSelectionChangeCallback: props.onSelectionChange, + onFocusChange: props.onFocusChange, + onHeightChange: props.onHeightChange, + inputWrapperStyle: props.inputWrapperStyle, + }; +} + +function extractErrorMessage(error: unknown): string | null { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return null; +} + +function getScrollableAncestorChain(element: HTMLElement | null): string[] { + if (!element || typeof window === "undefined") { + return []; + } + const results: string[] = []; + let current = element.parentElement; + while (current) { + const style = window.getComputedStyle(current); + const overflowY = style.overflowY; + const canScroll = + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && + current.scrollHeight > current.clientHeight; + if (canScroll) { + results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase()); + } + current = current.parentElement; + } + return results; +} + +export const MessageInput = forwardRef( + function MessageInput(props, ref) { + const { + value, + onChangeText, + onSubmit, + hasExternalContent, + allowEmptySubmit, + submitButtonAccessibilityLabel, + submitIcon, + isSubmitDisabled, + isSubmitLoading, + attachments, + cwd, + attachmentMenuItems, + onAttachButtonRef, + onAddImages, + client, + isReadyForDictation, + placeholder, + autoFocus, + autoFocusKey, + disabled, + isPaneFocused, + leftContent, + beforeVoiceContent, + rightContent, + voiceServerId, + voiceAgentId, + isAgentRunning, + defaultSendBehavior, + onQueue, + onSubmitLoadingPress, + onKeyPressCallback, + onSelectionChangeCallback, + onFocusChange, + onHeightChange, + inputWrapperStyle, + } = resolveMessageInputProps(props); + const { theme } = useUnistyles(); + const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; + const investigationComponentId = computeInvestigationComponentId(voiceServerId, voiceAgentId); + markScrollInvestigationRender(investigationComponentId); + const toast = useToast(); + const voice = useVoiceOptional(); + const sendKeys = useShortcutKeys("message-input-send"); + const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); + const dictationToggleKeys = useShortcutKeys("dictation-toggle"); + const focusInputKeys = useShortcutKeys("focus-message-input"); + const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const [isInputFocused, setIsInputFocused] = useState(false); + const rootRef = useRef(null); + const inputWrapperRef = useRef(null); + const textInputRef = useRef unknown }) | null>( + null, + ); + const isInputFocusedRef = useRef(false); + + useImperativeHandle(ref, () => ({ + focus: () => { + textInputRef.current?.focus(); + }, + blur: () => { + textInputRef.current?.blur?.(); + }, + runKeyboardAction: (action) => + runKeyboardActionImpl(action, { + textInputRef, + isDictatingRef, + sendAfterTranscriptRef, + confirmDictation, + cancelDictation, + startDictationIfAvailable, + handleToggleRealtimeVoiceShortcut, + isRealtimeVoiceForCurrentAgent, + voice, + }), + getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), + })); + const inputHeightRef = useRef(MIN_INPUT_HEIGHT); + const overlayTransition = useSharedValue(0); + const sendAfterTranscriptRef = useRef(false); + const valueRef = useRef(value); + const serverInfo = useSessionStore( + useCallback( + (state) => { + if (!voiceServerId) { + return null; + } + return state.sessions[voiceServerId]?.serverInfo ?? null; + }, + [voiceServerId], + ), + ); + + useEffect(() => { + valueRef.current = value; + }, [value]); + + useEffect(() => { + return () => { + onFocusChange?.(false); + }; + }, [onFocusChange]); + + useAutoFocusOnWebEffect(textInputRef, autoFocus, autoFocusKey); + + const handleDictationTranscript = useCallback( + (text: string, _meta: { requestId: string }) => { + const autoSend = sendAfterTranscriptRef.current; + sendAfterTranscriptRef.current = false; + applyDictationTranscript(text, { + value: valueRef.current, + defaultSendBehavior, + isAgentRunning, + onQueue, + onSubmit, + onChangeText, + attachments, + cwd, + autoSend, }); + }, + [onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior], + ); + + const handleDictationError = useCallback( + (error: Error) => { + console.error("[MessageInput] Dictation error:", error); + toast.error(error.message); + }, + [toast], + ); + + const dictationUnavailableMessage = resolveVoiceUnavailableMessage({ + serverInfo, + mode: "dictation", + }); + + const canStartDictation = useCallback( + () => + computeCanStartDictation({ + client, + isReadyForDictation, + disabled, + dictationUnavailableMessage, + }), + [client, disabled, dictationUnavailableMessage, isReadyForDictation], + ); + + const canConfirmDictation = useCallback(() => client?.isConnected ?? false, [client]); + const isConnected = client?.isConnected ?? false; + const isDictationStartEnabled = computeIsDictationStartEnabled( + isReadyForDictation, + isConnected, + disabled, + ); + + const { + isRecording: isDictating, + isProcessing: isDictationProcessing, + partialTranscript: _dictationPartialTranscript, + volume: dictationVolume, + duration: dictationDuration, + error: dictationError, + status: dictationStatus, + startDictation, + cancelDictation, + confirmDictation, + retryFailedDictation, + discardFailedDictation, + } = useDictation({ + client, + onTranscript: handleDictationTranscript, + onError: handleDictationError, + canStart: canStartDictation, + canConfirm: canConfirmDictation, + autoStopWhenHidden: { isVisible: isPaneFocused }, + enableDuration: true, + }); + + const isDictatingRef = useRef(isDictating); + useEffect(() => { + isDictatingRef.current = isDictating; + }, [isDictating]); + + const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent( + voice, + voiceServerId, + voiceAgentId, + ); + const showDictationOverlay = computeShouldShowDictationOverlay( + isDictating, + isDictationProcessing, + dictationStatus, + ); + const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; + const showOverlay = showDictationOverlay || showRealtimeOverlay; + + useEffect(() => { + if (isDictating || isDictationProcessing) { + return; } - }, - [investigationComponentId, onChangeText], - ); + sendAfterTranscriptRef.current = false; + }, [dictationStatus, isDictating, isDictationProcessing]); - return ( - - {/* Regular input */} - - {/* Text input */} - - { - isInputFocusedRef.current = true; - setIsInputFocused(true); - onFocusChange?.(true); - }} - onBlur={() => { - isInputFocusedRef.current = false; - setIsInputFocused(false); - onFocusChange?.(false); - }} - style={[ - styles.textInput, - isWeb - ? { - height: inputHeight, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: MAX_INPUT_HEIGHT, - } - : { - minHeight: MIN_INPUT_HEIGHT, - maxHeight: MAX_INPUT_HEIGHT, - }, - ]} - multiline - scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} - onContentSizeChange={handleContentSizeChange} - editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} - onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} - onSelectionChange={handleSelectionChange} - autoFocus={isWeb && autoFocus} - /> - {inputScrollbar} - {isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? ( - - {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus - - ) : null} - + const startDictationIfAvailable = useCallback( + () => + startDictationIfAvailableImpl({ + dictationUnavailableMessage, + canStartDictation, + isDictatingRef, + toast, + startDictation, + }), + [canStartDictation, dictationUnavailableMessage, startDictation, toast], + ); - {/* Button row */} - - {/* Left: attachment button + leftContent slot */} - - - - - [ - styles.attachButton, - hovered && styles.iconButtonHovered, - (!isConnected || disabled) && styles.buttonDisabled, - ]} - > - {({ hovered }) => ( - - - - )} - - - - Add attachment - - - - {attachmentMenuItems.map((item) => ( - - {item.label} - - ))} - - - {leftContent} + // Animate overlay + useEffect(() => { + overlayTransition.value = withTiming(showOverlay ? 1 : 0, { + duration: 200, + }); + }, [overlayTransition, showOverlay]); + + const overlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: overlayTransition.value, + pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none", + })); + + const inputAnimatedStyle = useAnimatedStyle(() => ({ + opacity: 1 - overlayTransition.value, + })); + + const handleVoicePress = useCallback( + () => + handleVoicePressImpl({ + isRealtimeVoiceForCurrentAgent, + voice, + isDictating, + cancelDictation, + startDictationIfAvailable, + }), + [ + cancelDictation, + isDictating, + isRealtimeVoiceForCurrentAgent, + startDictationIfAvailable, + voice, + ], + ); + + const handleCancelRecording = useCallback(async () => { + await cancelDictation(); + }, [cancelDictation]); + + const handleAcceptRecording = useCallback(async () => { + sendAfterTranscriptRef.current = false; + await confirmDictation(); + }, [confirmDictation]); + + const handleAcceptAndSendRecording = useCallback(async () => { + sendAfterTranscriptRef.current = true; + await confirmDictation(); + }, [confirmDictation]); + + const handleRetryFailedRecording = useCallback(() => { + void retryFailedDictation(); + }, [retryFailedDictation]); + + const handleDiscardFailedRecording = useCallback(() => { + discardFailedDictation(); + }, [discardFailedDictation]); + + const handleStopRealtimeVoice = useCallback( + () => + stopRealtimeVoiceImpl({ + voice, + isRealtimeVoiceForCurrentAgent, + isAgentRunning, + client, + voiceAgentId, + }), + [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId], + ); + + const handleToggleRealtimeVoiceShortcut = useCallback(() => { + toggleRealtimeVoiceImpl({ + voice, + voiceServerId, + voiceAgentId, + isConnected, + disabled, + handleStopRealtimeVoice, + toast, + }); + }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]); + + const minimizeInputHeight = useCallback(() => { + inputHeightRef.current = MIN_INPUT_HEIGHT; + setInputHeight(MIN_INPUT_HEIGHT); + onHeightChange?.(MIN_INPUT_HEIGHT); + }, [onHeightChange]); + + const handleSendMessage = useCallback( + () => + sendMessageImpl({ + value, + attachments, + hasExternalContent, + allowEmptySubmit, + cwd, + isAgentRunning, + onSubmit, + onMinimizeHeight: minimizeInputHeight, + }), + [ + allowEmptySubmit, + value, + attachments, + cwd, + onSubmit, + isAgentRunning, + hasExternalContent, + minimizeInputHeight, + ], + ); + + const handleQueueMessage = useCallback( + () => + queueMessageImpl({ + value, + attachments, + cwd, + onQueue, + onChangeText, + onMinimizeHeight: minimizeInputHeight, + }), + [value, attachments, cwd, onQueue, onChangeText, minimizeInputHeight], + ); + + const handleDefaultSendAction = useCallback(() => { + runDefaultSendAction({ + defaultSendBehavior, + isAgentRunning, + onQueue, + handleSendMessage, + handleQueueMessage, + }); + }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); + + const handleAlternateSendAction = useCallback(() => { + runAlternateSendAction({ + defaultSendBehavior, + isAgentRunning, + onQueue, + handleSendMessage, + handleQueueMessage, + }); + }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); + + const getWebTextArea = useCallback( + (): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current), + [], + ); + + const webTextareaRef = useRef(null); + + useLayoutEffect(() => { + if (isWeb) { + webTextareaRef.current = getWebTextArea() as HTMLElement | null; + } + }, [getWebTextArea]); + + const inputScrollbar = useWebElementScrollbar(webTextareaRef, { + enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, + }); + + const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { + const current = target === "root" ? rootRef.current : inputWrapperRef.current; + return toHtmlElement(current); + }, []); + + usePasteImagesEffect({ + getWebTextArea, + isConnected, + disabled, + isDictating, + isRealtimeVoiceForCurrentAgent, + onAddImages, + }); + + useComposerResizeObserverEffect({ getWebTextArea, getWebElement, valueRef }); + + useComposerScrollLogEffect({ getWebTextArea, valueRef }); + + const setBoundedInputHeight = useCallback( + (nextHeight: number) => { + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); + if (Math.abs(inputHeightRef.current - bounded) < 1) return; + inputHeightRef.current = bounded; + setInputHeight(bounded); + onHeightChange?.(bounded); + }, + [onHeightChange], + ); + + useComposerHeightMirror({ + value, + textareaRef: webTextareaRef, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: MAX_INPUT_HEIGHT, + onHeight: setBoundedInputHeight, + }); + + const handleContentSizeChange = useCallback( + (event: NativeSyntheticEvent) => { + if (isWeb) return; + setBoundedInputHeight(event.nativeEvent.contentSize.height); + }, + [setBoundedInputHeight], + ); + + const handleSelectionChange = useCallback( + (event: NativeSyntheticEvent) => { + const start = event.nativeEvent.selection?.start ?? 0; + const end = event.nativeEvent.selection?.end ?? start; + if (isWeb) { + const textarea = getWebTextArea(); + logWebStickyBottom("composer_selection_changed", { + now: getDebugNow(), + start, + end, + textareaScrollTop: textarea?.scrollTop ?? null, + textareaClientHeight: textarea?.clientHeight ?? null, + textareaScrollHeight: textarea?.scrollHeight ?? null, + }); + } + onSelectionChangeCallback?.({ start, end }); + }, + [getWebTextArea, onSelectionChangeCallback], + ); + + const shouldHandleDesktopSubmit = isWeb; + + function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { + if (!shouldHandleDesktopSubmit) return; + handleDesktopKeyPressImpl(event, { + investigationComponentId, + onKeyPressCallback, + isAgentRunning, + onQueue, + isSubmitDisabled, + isSubmitLoading, + disabled, + handleAlternateSendAction, + handleDefaultSendAction, + }); + } + + const { shouldShowSendButton } = computeSendableContent({ + value, + attachments, + hasExternalContent, + allowEmptySubmit, + isSubmitLoading, + }); + const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } = + computeSendButtonState({ + disabled, + isSubmitDisabled, + isSubmitLoading, + onSubmitLoadingPress, + defaultSendBehavior, + isAgentRunning, + }); + const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({ + submitButtonAccessibilityLabel, + canPressLoadingButton, + defaultActionQueues, + isAgentRunning, + }); + + const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ + isRealtimeVoiceForCurrentAgent, + isMuted: Boolean(voice?.isMuted), + isDictating, + }); + + const voiceTooltipText = resolveVoiceTooltipText({ + isRealtimeVoiceForCurrentAgent, + isMuted: Boolean(voice?.isMuted), + }); + + const handleInputChange = useCallback( + (nextValue: string) => { + markScrollInvestigationEvent(investigationComponentId, "inputChange"); + onChangeText(nextValue); + if (isWeb) { + logWebStickyBottom("composer_text_changed", { + valueLength: nextValue.length, + lineCount: nextValue.split("\n").length, + }); + } + }, + [investigationComponentId, onChangeText], + ); + + const handleInputFocus = useCallback(() => { + isInputFocusedRef.current = true; + setIsInputFocused(true); + onFocusChange?.(true); + }, [onFocusChange]); + + const handleInputBlur = useCallback(() => { + isInputFocusedRef.current = false; + setIsInputFocused(false); + onFocusChange?.(false); + }, [onFocusChange]); + + const attachButtonStyle = useCallback( + ({ hovered }: { hovered?: boolean }) => [ + styles.attachButton, + Boolean(hovered) && styles.iconButtonHovered, + (!isConnected || disabled) && styles.buttonDisabled, + ], + [isConnected, disabled], + ); + + const voiceButtonStyle = useCallback( + ({ hovered }: { hovered?: boolean }) => [ + styles.voiceButton, + Boolean(hovered) && !isDictating && styles.iconButtonHovered, + !isDictationStartEnabled && styles.buttonDisabled, + isDictating && styles.voiceButtonRecording, + ], + [isDictating, isDictationStartEnabled], + ); + + const handleRealtimeVoiceStop = useCallback(() => { + void handleStopRealtimeVoice(); + }, [handleStopRealtimeVoice]); + + const inputWrapperCombinedStyle = useMemo( + () => [styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle], + [inputWrapperStyle, inputAnimatedStyle], + ); + const textInputStyle = useMemo( + () => [styles.textInput, computeTextInputHeightStyle(inputHeight)], + [inputHeight], + ); + const sendButtonCombinedStyle = useMemo( + () => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled], + [isSendButtonDisabled], + ); + const overlayContainerStyle = useMemo( + () => [styles.overlayContainer, overlayAnimatedStyle], + [overlayAnimatedStyle], + ); + + const renderAttachButtonIcon = useCallback( + ({ hovered }: { hovered?: boolean }) => ( + + ), + [onAttachButtonRef, buttonIconSize, theme.colors.foreground, theme.colors.foregroundMuted], + ); + + const renderVoiceButtonIcon = useCallback( + ({ hovered }: { hovered?: boolean }) => ( + + ), + [ + isDictating, + isRealtimeVoiceForCurrentAgent, + voice?.isMuted, + buttonIconSize, + theme.colors.foreground, + theme.colors.foregroundMuted, + ], + ); + + return ( + + {/* Regular input */} + + {/* Text input */} + + = MAX_INPUT_HEIGHT : true} + onContentSizeChange={handleContentSizeChange} + editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} + onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} + onSelectionChange={handleSelectionChange} + autoFocus={isWeb && autoFocus} + /> + {inputScrollbar} + - {/* Right: voice button, contextual button (realtime/send/cancel) */} - - {beforeVoiceContent} - - [ - styles.voiceButton, - hovered && !isDictating && styles.iconButtonHovered, - !isDictationStartEnabled && styles.buttonDisabled, - isDictating && styles.voiceButtonRecording, - ]} - > - {({ hovered }) => - isDictating ? ( - - ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( - - ) : ( - - ) - } - - - - - {isRealtimeVoiceForCurrentAgent - ? voice?.isMuted - ? "Unmute voice" - : "Mute voice" - : "Dictation"} - - {(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? ( - - ) : null} - - - - {rightContent} - {shouldShowSendButton && ( - - - {isSubmitLoading ? ( - - ) : submitIcon === "return" ? ( - - ) : ( - - )} - - - - - {submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")} - - {sendKeys ? : null} - - - - )} + {/* Button row */} + + {/* Left: attachment button + leftContent slot */} + + + {leftContent} + + + {/* Right: voice button, contextual button (realtime/send/cancel) */} + + {beforeVoiceContent} + + {rightContent} + + - - + - {/* Dictation overlay */} - - {showDictationOverlay ? ( - + - ) : showRealtimeOverlay && voice ? ( - { - void handleStopRealtimeVoice(); - }} - /> - ) : null} - - - ); -}); + + + ); + }, +); -const styles = StyleSheet.create(((theme: any) => ({ +const styles = StyleSheet.create((theme: Theme) => ({ container: { position: "relative", }, @@ -1223,11 +2068,11 @@ const styles = StyleSheet.create(((theme: any) => ({ fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.base * 1.4, ...(isWeb - ? { - outlineStyle: "none" as const, + ? ({ + outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent", - } + } as object) : {}), }, buttonRow: { @@ -1313,4 +2158,4 @@ const styles = StyleSheet.create(((theme: any) => ({ right: 0, bottom: 0, }, -})) as any) as Record; +})) as unknown as Record; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 0bd6154a9..def5e8562 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -7,6 +7,7 @@ import { type LayoutChangeEvent, StyleProp, ViewStyle, + type TextStyle, } from "react-native"; import * as React from "react"; import { @@ -23,7 +24,13 @@ import { cloneElement, } from "react"; import type { ReactNode, ComponentType } from "react"; -import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display"; +import Markdown, { + MarkdownIt, + type ASTNode, + type RenderRules, +} from "react-native-markdown-display"; + +type MarkdownStyles = Record; import { useQuery } from "@tanstack/react-query"; import MaskedView from "@react-native-masked-view/masked-view"; import { @@ -132,7 +139,7 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [ ] as const; const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = ...; -type MarkdownWithStableRendererProps = { +interface MarkdownWithStableRendererProps { children: ReactNode; style: ReturnType; rules: RenderRules; @@ -140,7 +147,7 @@ type MarkdownWithStableRendererProps = { onLinkPress: (url: string) => boolean; allowedImageHandlers: readonly string[]; topLevelMaxExceededItem: ReactNode; -}; +} const MarkdownWithStableRenderer = Markdown as ComponentType; const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` @@ -251,7 +258,10 @@ function canScrollInsideDetailFromTarget( function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean { const startElement = getWheelEventElementTarget(event, detailRoot); const verticalDelta = event.deltaY; - const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0; + let horizontalDelta: number; + if (event.deltaX !== 0) horizontalDelta = event.deltaX; + else if (event.shiftKey) horizontalDelta = event.deltaY; + else horizontalDelta = 0; const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON; const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON; @@ -352,16 +362,17 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) { const uri = useAttachmentPreviewUrl(image); + const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]); if (!uri) { return ; } - return ; + return ; } export const UserMessage = memo(function UserMessage({ message, images = [], - timestamp, + timestamp: _timestamp, isFirstInGroup = true, isLastInGroup = true, disableOuterSpacing, @@ -375,32 +386,50 @@ export const UserMessage = memo(function UserMessage({ const hasImages = images.length > 0; const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered); + const handleHoverIn = useCallback(() => setMessageHovered(true), []); + const handleHoverOut = useCallback(() => setMessageHovered(false), []); + const getMessageContent = useCallback(() => message, [message]); + + const containerStyle = useMemo( + () => [ + userMessageStylesheet.container, + !resolvedDisableOuterSpacing && [ + isFirstInGroup && { marginTop: theme.spacing[4] }, + isLastInGroup && { marginBottom: theme.spacing[4] }, + !isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined, + ], + ], + [resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup, theme.spacing], + ); + const imagePreviewContainerStyle = useMemo( + () => [ + userMessageStylesheet.imagePreviewContainer, + hasText ? userMessageStylesheet.imagePreviewSpacing : undefined, + ], + [hasText], + ); + const copyButtonStyle = useMemo( + () => [ + userMessageStylesheet.copyButton, + showCopyButton + ? userMessageStylesheet.copyButtonVisible + : userMessageStylesheet.copyButtonHidden, + ], + [showCopyButton], + ); + return ( - + setMessageHovered(true)} - onHoverOut={() => setMessageHovered(false)} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} > {hasImages ? ( - - {images.map((image, index) => ( - + + {images.map((image) => ( + ))} @@ -414,13 +443,8 @@ export const UserMessage = memo(function UserMessage({ {hasText ? ( message} - containerStyle={[ - userMessageStylesheet.copyButton, - showCopyButton - ? userMessageStylesheet.copyButtonVisible - : userMessageStylesheet.copyButtonHidden, - ]} + getContent={getMessageContent} + containerStyle={copyButtonStyle} accessibilityLabel="Copy message" onHoverChange={setCopyButtonHovered} /> @@ -565,12 +589,17 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm ], [aspectRatio], ); + const frameStyle = useMemo>( + () => [assistantMessageStylesheet.imageFrame, containerStyle], + [containerStyle], + ); + const imageSource = useMemo(() => ({ uri }), [uri]); return ( - + >( + () => [ + assistantMessageStylesheet.imageFrame, + containerStyle, + assistantMessageStylesheet.imageState, + ], + [containerStyle], + ); + if (resolvedUri) { return ( + ); } + const errorText = resolveAssistantImageErrorText(query.error, dataImageQuery.error); + return ( - - - {query.error instanceof Error - ? query.error.message - : dataImageQuery.error instanceof Error - ? dataImageQuery.error.message - : "Unable to load image preview."} - + + {errorText} ); } +function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown): string { + if (fileError instanceof Error) return fileError.message; + if (dataError instanceof Error) return dataError.message; + return "Unable to load image preview."; +} + +interface InlinePathChipProps { + content: string; + parsed: InlinePathTarget; + onPress: (target: InlinePathTarget) => void; +} + +const INLINE_PATH_CHIP_STYLE = [ + assistantMessageStylesheet.pathChip, + assistantMessageStylesheet.pathChipText, +]; + +function InlinePathChip({ content, parsed, onPress }: InlinePathChipProps) { + const handlePress = useCallback(() => onPress(parsed), [onPress, parsed]); + return ( + + {content} + + ); +} + function MarkdownLink({ href, style, @@ -724,14 +776,21 @@ function MarkdownLink({ children, }: { href: string; - style: any; + style: StyleProp; onPress: (url: string) => void; children: ReactNode; }) { const [hovered, setHovered] = useState(false); + const handlePress = useCallback(() => onPress(href), [onPress, href]); + const handleHoverIn = useCallback(() => setHovered(true), []); + const handleHoverOut = useCallback(() => setHovered(false), []); + const hoveredTextStyle = useMemo>( + () => [style, hovered && { textDecorationLine: "underline" as const }], + [style, hovered], + ); if (isNative) { return ( - onPress(href)} style={style}> + {children} ); @@ -740,11 +799,11 @@ function MarkdownLink({ return ( onPress(href)} - onHoverIn={() => setHovered(true)} - onHoverOut={() => setHovered(false)} + onPress={handlePress} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} > - {children} + {children} ); } @@ -848,12 +907,19 @@ export const TurnCopyButton = memo(function TurnCopyButton({ }; }, []); + const handleHoverIn = useCallback(() => onHoverChange?.(true), [onHoverChange]); + const handleHoverOut = useCallback(() => onHoverChange?.(false), [onHoverChange]); + const pressableStyle = useMemo( + () => [turnCopyButtonStylesheet.container, containerStyle], + [containerStyle], + ); + return ( onHoverChange?.(true)} - onHoverOut={() => onHoverChange?.(false)} - style={[turnCopyButtonStylesheet.container, containerStyle]} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} + style={pressableStyle} accessibilityRole="button" accessibilityLabel={ copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn") @@ -1073,38 +1139,30 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer( [nativeShimmerPeakStyle, peakWidth, rowHeight], ); + const maskElement = useMemo( + () => ( + + + {label} + + {secondaryLabel ? ( + + {secondaryLabel} + + ) : ( + + )} + + ), + [nativeShimmerMaskStyle, nativeLabelMaskStyle, nativeSecondaryMaskStyle, label, secondaryLabel], + ); + return ( - - - {label} - - {secondaryLabel ? ( - - {secondaryLabel} - - ) : ( - - )} - - } - > + - - - - - - - - - - + @@ -1112,6 +1170,34 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer( ); }); +function NativeShimmerPeakSvg({ gradientId }: { gradientId: string }) { + return ( + + + + + + + + + + + ); +} + +interface AssistantMessageBlockContainerProps { + marginBottom: number; + children: ReactNode; +} + +function AssistantMessageBlockContainer({ + marginBottom, + children, +}: AssistantMessageBlockContainerProps) { + const style = useMemo(() => (marginBottom > 0 ? { marginBottom } : undefined), [marginBottom]); + return {children}; +} + interface MemoizedMarkdownBlockProps { text: string; styles: ReturnType; @@ -1141,9 +1227,76 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ ); }); +interface MarkdownInheritedTextProps { + inheritedStyles: TextStyle; + textStyle: TextStyle; + children: ReactNode; +} + +function MarkdownInheritedText({ + inheritedStyles, + textStyle, + children, +}: MarkdownInheritedTextProps) { + const style = useMemo(() => [inheritedStyles, textStyle], [inheritedStyles, textStyle]); + return {children}; +} + +interface MarkdownInheritedCodeLinkProps { + href: string; + inheritedStyles: TextStyle; + codeInlineStyle: TextStyle; + linkStyle: TextStyle; + onPress: (url: string) => boolean; + children: ReactNode; +} + +function MarkdownInheritedCodeLink({ + href, + inheritedStyles, + codeInlineStyle, + linkStyle, + onPress, + children, +}: MarkdownInheritedCodeLinkProps) { + const style = useMemo( + () => [inheritedStyles, codeInlineStyle, linkStyle], + [inheritedStyles, codeInlineStyle, linkStyle], + ); + return ( + + {children} + + ); +} + +interface MarkdownListItemContentProps { + contentStyle: ViewStyle; + children: ReactNode; +} + +const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 }; + +function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) { + const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]); + return {children}; +} + +interface MarkdownParagraphViewProps { + paragraphStyle: ViewStyle; + children: ReactNode; +} + +const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; + +function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) { + const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]); + return {children}; +} + export const AssistantMessage = memo(function AssistantMessage({ message, - timestamp, + timestamp: _timestamp, onInlinePathPress, workspaceRoot, serverId, @@ -1190,107 +1343,140 @@ export const AssistantMessage = memo(function AssistantMessage({ const markdownRules = useMemo(() => { return { text: ( - node: any, + node: ASTNode, _children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {node.content} - + ), textgroup: ( - node: any, + node: ASTNode, children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {children} - + ), code_block: ( - node: any, + node: ASTNode, _children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {node.content} - + ), fence: ( - node: any, + node: ASTNode, _children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {node.content} - + ), code_inline: ( - node: any, + node: ASTNode, _children: ReactNode[], - parent: any, - styles: any, - inheritedStyles: any = {}, + parent: ASTNode[], + styles: MarkdownStyles, + inheritedStyles: TextStyle = {}, ) => { const content = node.content ?? ""; - const isLinkedInlineCode = - nodeHasParentType(parent, "link") || - (!Array.isArray(parent) && typeof parent?.attributes?.href === "string"); + const isLinkedInlineCode = nodeHasParentType(parent, "link"); const parsed = onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null; - if (parsed) { + if (parsed && onInlinePathPress) { return ( - parsed && onInlinePathPress?.(parsed)} - selectable={isWeb ? undefined : false} - style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} - > - {content} - + content={content} + parsed={parsed} + onPress={onInlinePathPress} + /> ); } const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content); if (inlineCodeLinkUrl) { return ( - {content} - + ); } return ( - + {content} - + ); }, - bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + bullet_list: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + ) => ( {children} ), - ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + ordered_list: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + ) => ( {children} ), - list_item: (node: any, children: ReactNode[], parent: any, styles: any) => { + list_item: ( + node: ASTNode, + children: ReactNode[], + parent: ASTNode[], + styles: MarkdownStyles, + ) => { const { isOrdered, marker } = getMarkdownListMarker(node, parent); const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; @@ -1298,39 +1484,54 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( {marker} - {children} + + {children} + ); }, - paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => ( - + paragraph: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownStyles, + ) => ( + {children} - + ), - link: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {Children.map(children, (child) => isValidElement(child) ? cloneElement(child, { - style: [(child.props as any).style, { color: styles.link.color }], - } as any) + style: [ + (child.props as { style?: StyleProp }).style, + { color: styles.link.color as string | undefined }, + ], + } as Partial<{ style: StyleProp }>) : child, )} ), - image: (node: any, _children: ReactNode[], parent: any, styles: any) => { + image: ( + node: ASTNode, + _children: ReactNode[], + parent: ASTNode[], + _styles: MarkdownStyles, + ) => { const paragraphNode = Array.isArray(parent) - ? parent.find((ancestor: any) => ancestor?.type === "paragraph") + ? parent.find((ancestor) => ancestor?.type === "paragraph") : null; const paragraphChildren = Array.isArray(paragraphNode?.children) ? paragraphNode.children : []; - const imageIndex = paragraphChildren.findIndex((child: any) => child?.key === node.key); + const imageIndex = paragraphChildren.findIndex((child: ASTNode) => child?.key === node.key); const hasLeadingContent = imageIndex > 0; return ( @@ -1349,23 +1550,29 @@ export const AssistantMessage = memo(function AssistantMessage({ }, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); + const keyedBlocks = useMemo( + () => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })), + [blocks], + ); + + const assistantContainerStyle = useMemo( + () => [ + assistantMessageStylesheet.container, + (spacing === "compactTop" || spacing === "compactBoth") && + assistantMessageStylesheet.containerCompactTop, + (spacing === "compactBottom" || spacing === "compactBoth") && + assistantMessageStylesheet.containerCompactBottom, + !resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing, + ], + [spacing, resolvedDisableOuterSpacing], + ); return ( - - {blocks.map((block, index) => ( - + {keyedBlocks.map(({ key, block }, index) => ( + - + ))} ); @@ -1416,20 +1623,21 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({ export const SpeakMessage = memo(function SpeakMessage({ message, - timestamp, + timestamp: _timestamp, disableOuterSpacing, }: SpeakMessageProps) { const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); + const containerStyle = useMemo( + () => [ + speakMessageStylesheet.container, + !resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing, + ], + [resolvedDisableOuterSpacing], + ); return ( - + Spoke @@ -1526,7 +1734,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({ export const ActivityLog = memo(function ActivityLog({ type, message, - timestamp, + timestamp: _timestamp, metadata, artifactId, artifactType, @@ -1564,39 +1772,41 @@ export const ActivityLog = memo(function ActivityLog({ const config = typeConfig[type]; const IconComponent = config.Icon; - const handlePress = () => { + const handlePress = useCallback(() => { if (type === "artifact" && artifactId && onArtifactClick) { onArtifactClick(artifactId); } else if (metadata) { - setIsExpanded(!isExpanded); + setIsExpanded((prev) => !prev); } - }; + }, [type, artifactId, onArtifactClick, metadata]); const displayMessage = type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message; const isInteractive = type === "artifact" || metadata; + const pressableStyle = useMemo( + () => [ + activityLogStylesheet.pressable, + !resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing, + config.bg, + isInteractive && activityLogStylesheet.pressableActive, + ], + [resolvedDisableOuterSpacing, config.bg, isInteractive], + ); + const messageTextStyle = useMemo( + () => [activityLogStylesheet.messageText, { color: config.color }], + [config.color], + ); return ( - + - - {displayMessage} - + {displayMessage} {metadata && ( Details @@ -1655,12 +1865,10 @@ export const CompactionMarker = memo(function CompactionMarker({ status, preTokens, }: CompactionMarkerProps) { - const label = - status === "loading" - ? "Compacting..." - : preTokens - ? `Context compacted (${Math.round(preTokens / 1000)}K tokens)` - : "Context compacted"; + let label: string; + if (status === "loading") label = "Compacting..."; + else if (preTokens) label = `Context compacted (${Math.round(preTokens / 1000)}K tokens)`; + else label = "Context compacted"; return ( @@ -1683,6 +1891,36 @@ interface TodoListCardProps { disableOuterSpacing?: boolean; } +interface TodoListItemRowProps { + text: string; + completed: boolean; +} + +function TodoListItemRow({ text, completed }: TodoListItemRowProps) { + const { theme: todoUnistylesTheme } = useUnistyles(); + const badgeStyle = useMemo( + () => [ + todoListCardStylesheet.radioBadge, + completed + ? todoListCardStylesheet.radioBadgeComplete + : todoListCardStylesheet.radioBadgeIncomplete, + ], + [completed], + ); + const textStyle = useMemo( + () => [todoListCardStylesheet.itemText, completed && todoListCardStylesheet.itemTextCompleted], + [completed], + ); + return ( + + + {completed ? : null} + + {text} + + ); +} + const todoListCardStylesheet = StyleSheet.create((theme) => ({ detailsWrapper: { padding: theme.spacing[2], @@ -1728,7 +1966,7 @@ export const TodoListCard = memo(function TodoListCard({ items, disableOuterSpacing, }: TodoListCardProps) { - const { theme: unistylesTheme } = useUnistyles(); + const { theme: _unistylesTheme } = useUnistyles(); const [isExpanded, setIsExpanded] = useState(false); const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); @@ -1744,29 +1982,8 @@ export const TodoListCard = memo(function TodoListCard({ {items.length === 0 ? ( No tasks yet. ) : ( - items.map((item, idx) => ( - - - {item.completed ? ( - - ) : null} - - - {item.text} - - + items.map((item) => ( + )) )} @@ -1803,6 +2020,279 @@ interface ExpandableBadgeProps { testID?: string; } +interface ExpandableBadgeSecondaryLabelProps { + secondaryLabel?: string; + secondaryLabelStyle: StyleProp; + shouldMeasureWebShimmer: boolean; + onSecondaryLayout: (event: LayoutChangeEvent) => void; +} + +function ExpandableBadgeSecondaryLabel({ + secondaryLabel, + secondaryLabelStyle, + shouldMeasureWebShimmer, + onSecondaryLayout, +}: ExpandableBadgeSecondaryLabelProps) { + if (!secondaryLabel) { + return ; + } + return ( + + {secondaryLabel} + + ); +} + +interface ExpandableBadgeWebShimmerOverlayProps { + label: string; + secondaryLabel?: string; + shimmerLabelTextStyle: StyleProp; + shimmerSecondaryTextStyle: StyleProp; +} + +function ExpandableBadgeWebShimmerOverlay({ + label, + secondaryLabel, + shimmerLabelTextStyle, + shimmerSecondaryTextStyle, +}: ExpandableBadgeWebShimmerOverlayProps) { + return ( + + + {label} + + {secondaryLabel ? ( + + {secondaryLabel} + + ) : ( + + )} + + ); +} + +interface ExpandableBadgeLabelRowProps { + label: string; + labelStyle: StyleProp; + secondaryLabel?: string; + secondaryLabelStyle: StyleProp; + shouldMeasureWebShimmer: boolean; + shouldMeasureNativeShimmer: boolean; + isWebShimmer: boolean; + isNativeShimmer: boolean; + shimmerLabelTextStyle: StyleProp; + shimmerSecondaryTextStyle: StyleProp; + labelRowWidth: number; + labelRowHeight: number; + nativeShimmerPeakWidth: number; + shimmerDuration: number; + nativeGradientId: string; + onLabelRowLayout: (event: LayoutChangeEvent) => void; + onLabelLayout: (event: LayoutChangeEvent) => void; + onSecondaryLayout: (event: LayoutChangeEvent) => void; +} + +function ExpandableBadgeLabelRow({ + label, + labelStyle, + secondaryLabel, + secondaryLabelStyle, + shouldMeasureWebShimmer, + shouldMeasureNativeShimmer, + isWebShimmer, + isNativeShimmer, + shimmerLabelTextStyle, + shimmerSecondaryTextStyle, + labelRowWidth, + labelRowHeight, + nativeShimmerPeakWidth, + shimmerDuration, + nativeGradientId, + onLabelRowLayout, + onLabelLayout, + onSecondaryLayout, +}: ExpandableBadgeLabelRowProps) { + return ( + + + {label} + + + {isWebShimmer ? ( + + ) : null} + {isNativeShimmer ? ( + + ) : null} + + ); +} + +function resolveExpandableBadgeIconColor({ + isError, + isActive, + theme, +}: { + isError: boolean; + isActive: boolean; + theme: ReturnType["theme"]; +}): string { + if (isError) return theme.colors.destructive; + if (isActive) return theme.colors.foreground; + return theme.colors.mutedForeground; +} + +function renderExpandableBadgeIcon({ + isError, + iconColor, + icon, +}: { + isError: boolean; + iconColor: string; + icon?: ComponentType<{ size?: number; color?: string }>; +}): ReactNode { + if (isError) { + return ; + } + if (icon) { + const IconComponent = icon; + return ; + } + return null; +} + +function computeShimmerMetrics(input: { + label: string; + secondaryLabel: string | undefined; + isLoading: boolean; + labelRowWidth: number; + labelRowHeight: number; + labelOffsetX: number; + labelWidth: number; + secondaryOffsetX: number; + secondaryWidth: number; +}) { + const totalShimmerChars = input.label.trim().length + (input.secondaryLabel?.trim().length ?? 0); + const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0; + const shimmerDuration = Math.max( + 1, + Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment), + ); + const nativeShimmerPeakWidth = Math.max( + 32, + Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0), + ); + const isWebShimmer = input.isLoading && isWeb; + const shouldMeasureWebShimmer = isWebShimmer; + const shouldMeasureNativeShimmer = input.isLoading && isNative; + const isNativeShimmer = + shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0; + const webShimmerSpanStartX = input.labelOffsetX; + const webShimmerSpanEndX = input.secondaryLabel + ? input.secondaryOffsetX + input.secondaryWidth + : input.labelOffsetX + input.labelWidth; + const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX); + const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22)); + const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth; + const webShimmerTrackEnd = webShimmerSpanEndX; + return { + shimmerDuration, + nativeShimmerPeakWidth, + isWebShimmer, + shouldMeasureWebShimmer, + shouldMeasureNativeShimmer, + isNativeShimmer, + webShimmerPeakWidth, + webShimmerTrackStart, + webShimmerTrackEnd, + }; +} + +function useDetailWheelPropagationBlocker(input: { + detailWrapperRef: React.RefObject; + enabled: boolean; + wheelInvestigationComponentId: string; +}): void { + const { detailWrapperRef, enabled, wheelInvestigationComponentId } = input; + useEffect(() => { + if (!enabled) { + return; + } + const node = detailWrapperRef.current as unknown as HTMLElement | null; + if (!node || typeof node.addEventListener !== "function") { + return; + } + const stopWheelPropagation = (event: WheelEvent) => { + if (shouldStopDetailWheelPropagation(node, event)) { + event.stopPropagation(); + } + }; + markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach"); + node.addEventListener("wheel", stopWheelPropagation, { passive: true }); + return () => { + markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach"); + node.removeEventListener("wheel", stopWheelPropagation); + }; + }, [detailWrapperRef, enabled, wheelInvestigationComponentId]); +} + +const SHIMMER_GRADIENT = + "linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)"; + +function buildShimmerTextStyle(input: { + isWebShimmer: boolean; + webShimmerPeakWidth: number; + shimmerDuration: number; + webShimmerTrackStart: number; + webShimmerTrackEnd: number; + offsetX: number; +}): object | null { + if (!input.isWebShimmer) return null; + return { + opacity: 1, + color: "transparent", + backgroundImage: SHIMMER_GRADIENT, + backgroundSize: `${input.webShimmerPeakWidth}px 100%`, + backgroundRepeat: "no-repeat", + backgroundClip: "text", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`, + "--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`, + "--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`, + }; +} + const ExpandableBadge = memo(function ExpandableBadge({ label, style, @@ -1828,6 +2318,19 @@ const ExpandableBadge = memo(function ExpandableBadge({ const detailWrapperRef = useRef(null); const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`; + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => { + setIsHovered(false); + setIsPressed(false); + }, []); + const handlePressIn = useCallback(() => setIsPressed(true), []); + const handlePressOut = useCallback(() => setIsPressed(false), []); + const handleDetailHoverIn = useCallback(() => onDetailHoverChange?.(true), [onDetailHoverChange]); + const handleDetailHoverOut = useCallback( + () => onDetailHoverChange?.(false), + [onDetailHoverChange], + ); + const nativeGradientIdRef = useRef( `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`, ); @@ -1838,28 +2341,27 @@ const ExpandableBadge = memo(function ExpandableBadge({ const [secondaryOffsetX, setSecondaryOffsetX] = useState(0); const [secondaryWidth, setSecondaryWidth] = useState(0); - const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0); - const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0; - const shimmerDuration = Math.max( - 1, - Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment), - ); - const nativeShimmerPeakWidth = Math.max( - 32, - Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0), - ); - const isWebShimmer = isLoading && isWeb; - const shouldMeasureWebShimmer = isWebShimmer; - const shouldMeasureNativeShimmer = isLoading && isNative; - const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; - const webShimmerSpanStartX = labelOffsetX; - const webShimmerSpanEndX = secondaryLabel - ? secondaryOffsetX + secondaryWidth - : labelOffsetX + labelWidth; - const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX); - const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22)); - const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth; - const webShimmerTrackEnd = webShimmerSpanEndX; + const { + shimmerDuration, + nativeShimmerPeakWidth, + isWebShimmer, + shouldMeasureWebShimmer, + shouldMeasureNativeShimmer, + isNativeShimmer, + webShimmerPeakWidth, + webShimmerTrackStart, + webShimmerTrackEnd, + } = computeShimmerMetrics({ + label, + secondaryLabel, + isLoading, + labelRowWidth, + labelRowHeight, + labelOffsetX, + labelWidth, + secondaryOffsetX, + secondaryWidth, + }); const handleLabelRowLayout = useCallback( (event: LayoutChangeEvent) => { @@ -1904,64 +2406,51 @@ const ExpandableBadge = memo(function ExpandableBadge({ ensureWebToolCallShimmerKeyframes(); }, [isWebShimmer]); - useEffect(() => { - if (isNative || !isExpanded || !hasDetailContent) { - return; - } + useDetailWheelPropagationBlocker({ + detailWrapperRef, + enabled: !isNative && isExpanded && hasDetailContent, + wheelInvestigationComponentId, + }); - const node = detailWrapperRef.current as unknown as HTMLElement | null; - if (!node || typeof node.addEventListener !== "function") { - return; - } + const shimmerLabelStyle = useMemo( + () => + buildShimmerTextStyle({ + isWebShimmer, + webShimmerPeakWidth, + shimmerDuration, + webShimmerTrackStart, + webShimmerTrackEnd, + offsetX: labelOffsetX, + }) as never, + [ + isWebShimmer, + webShimmerPeakWidth, + shimmerDuration, + webShimmerTrackStart, + webShimmerTrackEnd, + labelOffsetX, + ], + ); - const stopWheelPropagation = (event: WheelEvent) => { - if (shouldStopDetailWheelPropagation(node, event)) { - event.stopPropagation(); - } - }; - - markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach"); - node.addEventListener("wheel", stopWheelPropagation, { passive: true }); - return () => { - markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach"); - node.removeEventListener("wheel", stopWheelPropagation); - }; - }, [hasDetailContent, isExpanded, wheelInvestigationComponentId]); - - const shimmerGradient = - "linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)"; - - const shimmerLabelStyle = isWebShimmer - ? ({ - opacity: 1, - color: "transparent", - backgroundImage: shimmerGradient, - backgroundSize: `${webShimmerPeakWidth}px 100%`, - backgroundRepeat: "no-repeat", - backgroundClip: "text", - WebkitBackgroundClip: "text", - WebkitTextFillColor: "transparent", - animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${shimmerDuration}s linear infinite`, - "--paseo-shimmer-start": `${webShimmerTrackStart - labelOffsetX}px`, - "--paseo-shimmer-end": `${webShimmerTrackEnd - labelOffsetX}px`, - } as never) - : null; - - const shimmerSecondaryStyle = isWebShimmer - ? ({ - opacity: 1, - color: "transparent", - backgroundImage: shimmerGradient, - backgroundSize: `${webShimmerPeakWidth}px 100%`, - backgroundRepeat: "no-repeat", - backgroundClip: "text", - WebkitBackgroundClip: "text", - WebkitTextFillColor: "transparent", - animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${shimmerDuration}s linear infinite`, - "--paseo-shimmer-start": `${webShimmerTrackStart - secondaryOffsetX}px`, - "--paseo-shimmer-end": `${webShimmerTrackEnd - secondaryOffsetX}px`, - } as never) - : null; + const shimmerSecondaryStyle = useMemo( + () => + buildShimmerTextStyle({ + isWebShimmer, + webShimmerPeakWidth, + shimmerDuration, + webShimmerTrackStart, + webShimmerTrackEnd, + offsetX: secondaryOffsetX, + }) as never, + [ + isWebShimmer, + webShimmerPeakWidth, + shimmerDuration, + webShimmerTrackStart, + webShimmerTrackEnd, + secondaryOffsetX, + ], + ); const containerStyle = useMemo( () => [ @@ -2035,90 +2524,50 @@ const ExpandableBadge = memo(function ExpandableBadge({ [isExpanded], ); - const IconComponent = icon; - const iconColor = isError - ? theme.colors.destructive - : isActive - ? theme.colors.foreground - : theme.colors.mutedForeground; + const iconColor = resolveExpandableBadgeIconColor({ isError, isActive, theme }); + const iconNode = renderExpandableBadgeIcon({ isError, iconColor, icon }); - let iconNode: ReactNode = null; - if (isError) { - iconNode = ; - } else if (IconComponent) { - iconNode = ; - } + const pressHandlers = isInteractive + ? { + onPress: onToggle, + onHoverIn: handleHoverIn, + onHoverOut: handleHoverOut, + onPressIn: handlePressIn, + onPressOut: handlePressOut, + accessibilityRole: "button" as const, + } + : {}; return ( setIsHovered(true) : undefined} - onHoverOut={ - isInteractive - ? () => { - setIsHovered(false); - setIsPressed(false); - } - : undefined - } - onPressIn={isInteractive ? () => setIsPressed(true) : undefined} - onPressOut={isInteractive ? () => setIsPressed(false) : undefined} + {...pressHandlers} disabled={!isInteractive} - accessibilityRole={isInteractive ? "button" : undefined} accessibilityState={accessibilityState} style={pressableStyle} > {iconNode} - - - {label} - - {secondaryLabel ? ( - - {secondaryLabel} - - ) : ( - - )} - {isWebShimmer ? ( - - - {label} - - {secondaryLabel ? ( - - {secondaryLabel} - - ) : ( - - )} - - ) : null} - {isNativeShimmer ? ( - - ) : null} - + {isInteractive && isHovered ? ( ) : null} @@ -2128,8 +2577,8 @@ const ExpandableBadge = memo(function ExpandableBadge({ onDetailHoverChange?.(true)} - onHoverOut={() => onDetailHoverChange?.(false)} + onHoverIn={handleDetailHoverIn} + onHoverOut={handleDetailHoverOut} > {detailContent} @@ -2204,11 +2653,15 @@ export const ToolCall = memo(function ToolCall({ return undefined; }, [detail, args, result]); - const displayDetail = effectiveDetail ?? { - type: "unknown", - input: null, - output: null, - }; + const displayDetail = useMemo( + () => + effectiveDetail ?? { + type: "unknown", + input: null, + output: null, + }, + [effectiveDetail], + ); const displayModel = useMemo( () => diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 8b7e4f43e..f03f4b0c2 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Text, TextInput, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -11,6 +11,8 @@ import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; +const FLEX_ONE_STYLE = { flex: 1 } as const; + const styles = StyleSheet.create((theme) => ({ helper: { color: theme.colors.foregroundMuted, @@ -72,6 +74,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM inputRef.current?.clear(); }, []); + const pairIcon = useMemo( + () => , + [theme.colors.palette.white], + ); + const handleClose = useCallback(() => { if (isSaving) return; clearInput(); @@ -151,6 +158,14 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM } }, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]); + const handleChangeOfferUrl = useCallback((next: string) => { + offerUrlRef.current = next; + }, []); + + const handleSavePress = useCallback(() => { + void handleSave(); + }, [handleSave]); + return ( { - offerUrlRef.current = next; - }} + onChangeText={handleChangeOfferUrl} placeholder="https://app.paseo.sh/#offer=..." placeholderTextColor={theme.colors.foregroundMuted} style={styles.input} @@ -183,7 +196,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index 11741bb22..f21aa5bb4 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -1,78 +1,167 @@ -import type { ReactNode } from "react"; -import { Text, View } from "react-native"; -import Markdown from "react-native-markdown-display"; +import { useMemo, type ReactNode } from "react"; +import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; +import Markdown, { type ASTNode } from "react-native-markdown-display"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import { getMarkdownListMarker } from "@/utils/markdown-list"; +type MarkdownRuleStyles = Record; + +function MarkdownInlineText({ + textKey, + inheritedStyle, + ruleStyle, + children, +}: { + textKey: string; + inheritedStyle: StyleProp; + ruleStyle: StyleProp; + children: ReactNode; +}) { + const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]); + return ( + + {children} + + ); +} + +function MarkdownListItemContent({ + contentStyle, + children, +}: { + contentStyle: StyleProp; + children: ReactNode; +}) { + const style = useMemo(() => [contentStyle, LIST_ITEM_CONTENT_INNER], [contentStyle]); + return {children}; +} + +function MarkdownParagraph({ + textKey, + paragraphStyle, + isLastChild, + children, +}: { + textKey: string; + paragraphStyle: StyleProp; + isLastChild: boolean; + children: ReactNode; +}) { + const style = useMemo>( + () => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null], + [paragraphStyle, isLastChild], + ); + return ( + + {children} + + ); +} + function createPlanMarkdownRules() { return { text: ( - node: any, + node: ASTNode, _children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownRuleStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {node.content} - + ), textgroup: ( - node: any, + node: ASTNode, children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownRuleStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {children} - + ), code_block: ( - node: any, + node: ASTNode, _children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownRuleStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {node.content} - + ), fence: ( - node: any, + node: ASTNode, _children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownRuleStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {node.content} - + ), code_inline: ( - node: any, + node: ASTNode, _children: ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, + _parent: ASTNode[], + styles: MarkdownRuleStyles, + inheritedStyles: TextStyle = {}, ) => ( - + {node.content} - + ), - bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + bullet_list: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownRuleStyles, + ) => ( {children} ), - ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + ordered_list: ( + node: ASTNode, + children: ReactNode[], + _parent: ASTNode[], + styles: MarkdownRuleStyles, + ) => ( {children} ), - list_item: (node: any, children: ReactNode[], parent: any, styles: any) => { + list_item: ( + node: ASTNode, + children: ReactNode[], + parent: ASTNode[], + styles: MarkdownRuleStyles, + ) => { const { isOrdered, marker } = getMarkdownListMarker(node, parent); const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; @@ -80,16 +169,25 @@ function createPlanMarkdownRules() { return ( {marker} - {children} + {children} ); }, - paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => { + paragraph: ( + node: ASTNode, + children: ReactNode[], + parent: ASTNode[], + styles: MarkdownRuleStyles, + ) => { const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; return ( - + {children} - + ); }, }; @@ -112,23 +210,30 @@ export function PlanCard({ const markdownStyles = createMarkdownStyles(theme); const markdownRules = createPlanMarkdownRules(); + const containerStyle = useMemo( + () => [ + styles.container, + disableOuterSpacing && styles.containerCompact, + { + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.border, + }, + ], + [disableOuterSpacing, theme.colors.surface1, theme.colors.border], + ); + const titleStyle = useMemo( + () => [styles.title, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const descriptionStyle = useMemo( + () => [styles.description, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + return ( - - {title} - {description ? ( - - {description} - - ) : null} + + {title} + {description ? {description} : null} {text} @@ -160,3 +265,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, })); + +const LIST_ITEM_CONTENT_INNER = { flex: 1, flexShrink: 1, minWidth: 0 }; +const PARAGRAPH_LAST_CHILD = { marginBottom: 0 }; diff --git a/packages/app/src/components/pr-pane.tsx b/packages/app/src/components/pr-pane.tsx index c3e7c3718..945837cfa 100644 --- a/packages/app/src/components/pr-pane.tsx +++ b/packages/app/src/components/pr-pane.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Pressable, ScrollView, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { @@ -24,11 +24,31 @@ import type { PrState, } from "@/utils/pr-pane-data"; +function rowPressableStyle({ hovered }: { hovered?: boolean }) { + return [styles.row, Boolean(hovered) && styles.hoverable]; +} + +function activityPressableStyle({ hovered }: { hovered?: boolean }) { + return [styles.activityRow, Boolean(hovered) && styles.hoverable]; +} + export function PrPane({ data }: { data: PrPaneData }) { const { theme } = useUnistyles(); const [checksOpen, setChecksOpen] = useState(true); const [reviewsOpen, setReviewsOpen] = useState(true); + const handleOpenPrUrl = useCallback(() => { + void openExternalUrl(data.url); + }, [data.url]); + + const handleToggleChecks = useCallback(() => { + setChecksOpen((o) => !o); + }, []); + + const handleToggleReviews = useCallback(() => { + setReviewsOpen((o) => !o); + }, []); + const passed = data.checks.filter((c) => c.status === "success").length; const failed = data.checks.filter((c) => c.status === "failure").length; const pending = data.checks.filter((c) => c.status === "pending").length; @@ -46,15 +66,37 @@ export function PrPane({ data }: { data: PrPaneData }) { const stateColor = getStateColor(data.state, theme); const StateIcon = getStateIcon(data.state); const stateLabel = getStateLabel(data.state); + const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]); + const keyedActivity = useMemo( + () => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })), + [data.activity], + ); + + const checkSuccessIcon = useMemo( + () => , + [theme.colors.statusSuccess], + ); + const checkDangerIcon = useMemo( + () => , + [theme.colors.statusDanger], + ); + const checkWarningIcon = useMemo( + () => , + [theme.colors.statusWarning], + ); + const commentIcon = useMemo( + () => , + [theme.colors.foregroundMuted], + ); return ( - void openExternalUrl(data.url)} style={styles.header}> + {({ hovered }) => ( <> - {stateLabel} + {stateLabel} {data.title} @@ -74,29 +116,25 @@ export function PrPane({ data }: { data: PrPaneData }) {
setChecksOpen((o) => !o)} + onToggle={handleToggleChecks} summary={ <> } - /> - } + icon={checkSuccessIcon} /> + } + icon={checkWarningIcon} /> } > - {data.checks.map((check, idx) => ( - + {data.checks.map((check) => ( + ))}
@@ -105,29 +143,29 @@ export function PrPane({ data }: { data: PrPaneData }) {
setReviewsOpen((o) => !o)} + onToggle={handleToggleReviews} summary={ <> } + icon={checkSuccessIcon} /> } + icon={checkDangerIcon} /> } + icon={commentIcon} /> } > - {data.activity.map((item, idx) => ( - + {keyedActivity.map(({ key, item }) => ( + ))}
@@ -177,21 +215,22 @@ function SummaryPill({ color: string; icon: React.ReactNode; }) { + const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]); if (count === 0) return null; return ( {icon} - {count} + {count} ); } function CheckRow({ check }: { check: PrPaneCheck }) { + const handlePress = useCallback(() => { + void openExternalUrl(check.url); + }, [check.url]); return ( - void openExternalUrl(check.url)} - style={({ hovered }) => [styles.row, hovered && styles.hoverable]} - > + {check.name} @@ -216,12 +255,16 @@ function CheckStatusIcon({ status }: { status: CheckStatus }) { function ActivityRow({ item }: { item: PrPaneActivity }) { const verb = getActivityVerb(item); + const handlePress = useCallback(() => { + void openExternalUrl(item.url); + }, [item.url]); + const avatarStyle = useMemo( + () => [styles.avatar, { backgroundColor: item.avatarColor }], + [item.avatarColor], + ); return ( - void openExternalUrl(item.url)} - style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]} - > - + + {item.author.slice(0, 1).toUpperCase()} diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index bb3856f29..91d92cd49 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -1,5 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { + Modal, + Pressable, + ScrollView, + Text, + TextInput, + View, + type PressableStateCallbackType, +} from "react-native"; import { Folder } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQuery } from "@tanstack/react-query"; @@ -12,6 +20,44 @@ import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-sugg import { isNative } from "@/constants/platform"; import { useActiveServerId } from "@/hooks/use-active-server-id"; +interface PathRowProps { + path: string; + active: boolean; + onSelect: (path: string) => void; +} + +function PathRow({ path, active, onSelect }: PathRowProps) { + const { theme } = useUnistyles(); + const handlePress = useCallback(() => { + void onSelect(path); + }, [onSelect, path]); + const pressableStyle = useCallback( + ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.row, + (Boolean(hovered) || pressed || active) && { + backgroundColor: theme.colors.surface1, + }, + ], + [active, theme.colors.surface1], + ); + const rowTextStyle = useMemo( + () => [styles.rowText, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + return ( + + + + + + + {shortenPath(path)} + + + + ); +} + export function ProjectPickerModal() { const { theme } = useUnistyles(); const serverId = useActiveServerId(); @@ -86,6 +132,11 @@ export function ProjectPickerModal() { void handleSelectPath(trimmed); }, [handleSelectPath, query]); + const handleChangeQuery = useCallback((text: string) => { + setQuery(text); + setActiveIndex(0); + }, []); + // Reset state when opening/closing useEffect(() => { if (open) { @@ -145,6 +196,29 @@ export function ProjectPickerModal() { return () => window.removeEventListener("keydown", handler, true); }, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]); + const panelStyle = useMemo( + () => [ + styles.panel, + { + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface0, + }, + ], + [theme.colors.border, theme.colors.surface0], + ); + const headerStyle = useMemo( + () => [styles.header, { borderBottomColor: theme.colors.border }], + [theme.colors.border], + ); + const inputStyle = useMemo( + () => [styles.input, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const emptyTextStyle = useMemo( + () => [styles.emptyText, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + if (!serverId) return null; return ( @@ -152,26 +226,15 @@ export function ProjectPickerModal() { - - + + { - setQuery(text); - setActiveIndex(0); - }} + onChangeText={handleChangeQuery} placeholder="Type a directory path..." placeholderTextColor={theme.colors.foregroundMuted} - style={[styles.input, { color: theme.colors.foreground }]} + style={inputStyle} autoCapitalize="none" autoCorrect={false} autoFocus @@ -185,49 +248,22 @@ export function ProjectPickerModal() { keyboardShouldPersistTaps="always" showsVerticalScrollIndicator={false} > - {isSubmitting ? ( - - Opening project... - - ) : options.length === 0 && !query.trim() ? ( - - Start typing a path - - ) : ( + {isSubmitting ? Opening project... : null} + {!isSubmitting && options.length === 0 && !query.trim() ? ( + Start typing a path + ) : null} + {!isSubmitting && !(options.length === 0 && !query.trim()) ? ( <> - {options.map((path, index) => { - const active = index === activeIndex; - return ( - [ - styles.row, - (hovered || pressed || active) && { - backgroundColor: theme.colors.surface1, - }, - ]} - onPress={() => void handleSelectPath(path)} - > - - - - - - {shortenPath(path)} - - - - ); - })} + {options.map((path, index) => ( + + ))} - )} + ) : null} @@ -264,7 +300,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.lg, paddingVertical: theme.spacing[1], outlineStyle: "none", - } as any, + } as object, results: { flexGrow: 0, }, diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 4808a5234..1c646c171 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -1,6 +1,13 @@ import { AlertCircle, RotateCw, Search } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { + ActivityIndicator, + Pressable, + type PressableStateCallbackType, + ScrollView, + Text, + View, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -19,6 +26,58 @@ interface ProviderDiagnosticSheetProps { serverId: string; } +function ModelRow({ model, isFirst }: { model: AgentModelDefinition; isFirst: boolean }) { + const rowStyle = useMemo( + () => [sheetStyles.modelRow, !isFirst && sheetStyles.modelRowBorder], + [isFirst], + ); + return ( + + + {model.label} + + + {model.id} + + + ); +} + +function DiagnosticCodeBlock(props: { + loading: boolean; + diagnostic: string | null; + foregroundMutedColor: string; +}) { + if (props.loading && !props.diagnostic) { + return ( + + + Running diagnostic… + + ); + } + if (props.diagnostic) { + return ( + + + + {props.diagnostic} + + + + ); + } + return ( + + No diagnostic available. + + ); +} + export function ProviderDiagnosticSheet({ provider, visible, @@ -51,8 +110,9 @@ export function ProviderDiagnosticSheet({ }, [visible]); const fetchedAtLabel = useMemo(() => { if (!providerEntry?.fetchedAt) return null; + // clockTick is referenced so the label recomputes each timer tick. + void clockTick; return formatTimeAgo(new Date(providerEntry.fetchedAt)); - // clockTick triggers re-computation on timer }, [providerEntry?.fetchedAt, clockTick]); const q = query.trim().toLowerCase(); @@ -81,6 +141,15 @@ export function ProviderDiagnosticSheet({ [client, provider], ); + const refreshButtonStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + sheetStyles.iconButton, + (Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered, + refreshInFlight ? sheetStyles.disabled : null, + ], + [refreshInFlight], + ); + const handleRefresh = useCallback(() => { if (!provider) { return; @@ -90,6 +159,35 @@ export function ProviderDiagnosticSheet({ }); }, [fetchDiagnostic, provider, refresh]); + const headerActions = useMemo( + () => ( + + {refreshInFlight ? ( + + ) : ( + + )} + + ), + [ + handleRefresh, + refreshInFlight, + refreshButtonStyle, + providerLabel, + theme.iconSize.sm, + theme.colors.foregroundMuted, + ], + ); + useEffect(() => { if (visible) { fetchDiagnostic(); @@ -132,14 +230,7 @@ export function ProviderDiagnosticSheet({ ); } return filteredModels.map((model: AgentModelDefinition, index) => ( - 0 && sheetStyles.modelRowBorder]}> - - {model.label} - - - {model.id} - - + )); } @@ -148,56 +239,18 @@ export function ProviderDiagnosticSheet({ title={providerLabel} visible={visible} onClose={onClose} - snapPoints={["50%", "85%"]} + snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS} scrollable={false} - headerActions={ - [ - sheetStyles.iconButton, - (hovered || pressed) && sheetStyles.iconButtonHovered, - refreshInFlight ? sheetStyles.disabled : null, - ]} - accessibilityRole="button" - accessibilityLabel={ - refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}` - } - > - {refreshInFlight ? ( - - ) : ( - - )} - - } + headerActions={headerActions} > Diagnostic - {loading && !diagnostic ? ( - - - Running diagnostic… - - ) : diagnostic ? ( - - - - {diagnostic} - - - - ) : ( - - No diagnostic available. - - )} + @@ -225,7 +278,7 @@ export function ProviderDiagnosticSheet({ autoCapitalize="none" autoCorrect={false} // @ts-expect-error - outlineStyle is web-only - style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]} + style={DIAGNOSTIC_SEARCH_INPUT_STYLE} /> ) : null} @@ -363,3 +416,6 @@ const sheetStyles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, })); + +const DIAGNOSTIC_SHEET_SNAP_POINTS = ["50%", "85%"]; +const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]; diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 6609ac108..c64238858 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,5 +1,12 @@ -import { useState, useCallback } from "react"; -import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native"; +import { useState, useCallback, useMemo } from "react"; +import { + View, + Text, + TextInput, + Pressable, + ActivityIndicator, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CircleHelp, X } from "lucide-react-native"; @@ -63,6 +70,126 @@ interface QuestionFormCardProps { const IS_WEB = isWeb; +interface QuestionOptionRowProps { + qIndex: number; + optIndex: number; + option: QuestionOption; + isSelected: boolean; + multiSelect: boolean; + isResponding: boolean; + onToggle: (qIndex: number, optIndex: number, multiSelect: boolean) => void; +} + +function QuestionOptionRow({ + qIndex, + optIndex, + option, + isSelected, + multiSelect, + isResponding, + onToggle, +}: QuestionOptionRowProps) { + const { theme } = useUnistyles(); + + const handlePress = useCallback(() => { + onToggle(qIndex, optIndex, multiSelect); + }, [onToggle, qIndex, optIndex, multiSelect]); + + const pressableStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.optionItem, + (Boolean(hovered) || isSelected) && { + backgroundColor: theme.colors.surface2, + }, + pressed && styles.optionItemPressed, + ], + [isSelected, theme.colors.surface2], + ); + + const optionLabelStyle = useMemo( + () => [styles.optionLabel, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const optionDescriptionStyle = useMemo( + () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + + return ( + + + + {option.label} + {option.description ? ( + {option.description} + ) : null} + + {isSelected ? ( + + + + ) : null} + + + ); +} + +interface QuestionOtherInputProps { + qIndex: number; + value: string; + isResponding: boolean; + onChange: (qIndex: number, text: string) => void; + onSubmit: () => void; +} + +function QuestionOtherInput({ + qIndex, + value, + isResponding, + onChange, + onSubmit, +}: QuestionOtherInputProps) { + const { theme } = useUnistyles(); + const handleChange = useCallback( + (text: string) => { + onChange(qIndex, text); + }, + [onChange, qIndex], + ); + const otherInputStyle = useMemo( + () => + [ + styles.otherInput, + { + borderColor: value.length > 0 ? theme.colors.borderAccent : theme.colors.border, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface2, + }, + IS_WEB ? { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" } : null, + ] as const, + [ + value.length, + theme.colors.borderAccent, + theme.colors.border, + theme.colors.foreground, + theme.colors.surface2, + ], + ); + return ( + + ); +} + export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); @@ -110,22 +237,19 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi } }, []); - if (!questions) { - return null; - } + const allAnswered = + questions?.every((_, qIndex) => { + const selected = selections[qIndex]; + const otherText = otherTexts[qIndex]?.trim(); + return (selected && selected.size > 0) || (otherText && otherText.length > 0); + }) ?? false; - const allAnswered = questions.every((_, qIndex) => { - const selected = selections[qIndex]; - const otherText = otherTexts[qIndex]?.trim(); - return (selected && selected.size > 0) || (otherText && otherText.length > 0); - }); - - function handleSubmit() { - if (!allAnswered || isResponding) return; + const handleSubmit = useCallback(() => { + if (!questions || !allAnswered || isResponding) return; setRespondingAction("submit"); const answers: Record = {}; - for (let i = 0; i < questions!.length; i++) { - const q = questions![i]; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; const selected = selections[i]; const otherText = otherTexts[i]?.trim(); @@ -141,164 +265,146 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi behavior: "allow", updatedInput: { ...permission.request.input, answers }, }); - } + }, [ + questions, + allAnswered, + isResponding, + selections, + otherTexts, + onRespond, + permission.request.input, + ]); - function handleDeny() { + const handleDeny = useCallback(() => { setRespondingAction("dismiss"); onRespond({ behavior: "deny", message: "Dismissed by user", }); + }, [onRespond]); + + const dismissButtonStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.actionButton, + { + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + pressed && styles.optionItemPressed, + ], + [theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent], + ); + + const submitDisabled = !allAnswered || isResponding; + const submitButtonStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.actionButton, + { + backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1, + borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent, + opacity: submitDisabled ? 0.5 : 1, + }, + pressed && !submitDisabled ? styles.optionItemPressed : null, + ], + [ + submitDisabled, + theme.colors.surface2, + theme.colors.surface1, + theme.colors.border, + theme.colors.borderAccent, + ], + ); + + const containerStyle = useMemo( + () => [ + styles.container, + { + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.border, + }, + ], + [theme.colors.surface1, theme.colors.border], + ); + const questionTextStyle = useMemo( + () => [styles.questionText, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const actionsContainerStyle = useMemo( + () => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop], + [isMobile], + ); + const dismissActionTextStyle = useMemo( + () => [styles.actionText, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + const submitActionTextColor = allAnswered + ? theme.colors.foreground + : theme.colors.foregroundMuted; + const submitActionTextStyle = useMemo( + () => [styles.actionText, { color: submitActionTextColor }], + [submitActionTextColor], + ); + + if (!questions) { + return null; } return ( - + {questions.map((q, qIndex) => { const selected = selections[qIndex] ?? new Set(); const otherText = otherTexts[qIndex] ?? ""; return ( - + - - {q.question} - + {q.question} - {q.options.map((opt, optIndex) => { - const isSelected = selected.has(optIndex); - return ( - [ - styles.optionItem, - (hovered || isSelected) && { - backgroundColor: theme.colors.surface2, - }, - pressed && styles.optionItemPressed, - ]} - onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)} - disabled={isResponding} - > - - - - {opt.label} - - {opt.description ? ( - - {opt.description} - - ) : null} - - {isSelected ? ( - - - - ) : null} - - - ); - })} + {q.options.map((opt, optIndex) => ( + + ))} - 0 ? theme.colors.borderAccent : theme.colors.border, - color: theme.colors.foreground, - backgroundColor: theme.colors.surface2, - }, - // @ts-expect-error - outlineStyle is web-only - IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" }, - ]} - placeholder="Other..." - placeholderTextColor={theme.colors.foregroundMuted} + setOtherText(qIndex, text)} - onSubmitEditing={handleSubmit} - editable={!isResponding} - blurOnSubmit={false} + isResponding={isResponding} + onChange={setOtherText} + onSubmit={handleSubmit} /> ); })} - - [ - styles.actionButton, - { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: theme.colors.borderAccent, - }, - pressed && styles.optionItemPressed, - ]} - onPress={handleDeny} - disabled={isResponding} - > + + {respondingAction === "dismiss" ? ( ) : ( - - Dismiss - + Dismiss )} - { - const disabled = !allAnswered || isResponding; - return [ - styles.actionButton, - { - backgroundColor: - hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, - borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, - opacity: disabled ? 0.5 : 1, - }, - pressed && !disabled ? styles.optionItemPressed : null, - ]; - }} - onPress={handleSubmit} - disabled={!allAnswered || isResponding} - > + {respondingAction === "submit" ? ( ) : ( - - - Submit - + + Submit )} diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 4c7390a89..7e04ac4af 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, Square } from "lucide-react-native"; @@ -23,6 +24,19 @@ export function RealtimeVoiceOverlay({ }: RealtimeVoiceOverlayProps) { const { theme } = useUnistyles(); const { volume, isSpeaking } = useVoiceTelemetry(); + const muteButtonStyle = useMemo( + () => [ + styles.actionButton, + styles.muteButton, + isMuted ? styles.muteButtonMuted : undefined, + isSwitching ? styles.buttonDisabled : undefined, + ], + [isMuted, isSwitching], + ); + const stopButtonStyle = useMemo( + () => [styles.actionButton, styles.stopButton, isSwitching ? styles.buttonDisabled : undefined], + [isSwitching], + ); return ( @@ -40,12 +54,7 @@ export function RealtimeVoiceOverlay({ disabled={isSwitching} accessibilityRole="button" accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"} - style={[ - styles.actionButton, - styles.muteButton, - isMuted ? styles.muteButtonMuted : undefined, - isSwitching ? styles.buttonDisabled : undefined, - ]} + style={muteButtonStyle} > {isMuted ? ( @@ -59,11 +68,7 @@ export function RealtimeVoiceOverlay({ disabled={isSwitching} accessibilityRole="button" accessibilityLabel="Stop realtime voice and interrupt turn" - style={[ - styles.actionButton, - styles.stopButton, - isSwitching ? styles.buttonDisabled : undefined, - ]} + style={stopButtonStyle} > {isSwitching ? ( diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index a5bad521a..651e4380a 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -1,5 +1,5 @@ -import { useCallback, useRef, useState } from "react"; -import { View } from "react-native"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { View, type PointerEvent as RNPointerEvent } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; export interface ResizeHandleProps { @@ -32,8 +32,8 @@ export function ResizeHandle({ const highlighted = active || dragging; const handlePointerDown = useCallback( - (event: any) => { - const hitAreaElement = event.currentTarget as HTMLElement | null; + (event: RNPointerEvent) => { + const hitAreaElement = event.currentTarget as unknown as HTMLElement | null; const containerElement = hitAreaElement?.parentElement?.parentElement ?? null; if (!containerElement) { return; @@ -49,7 +49,8 @@ export function ResizeHandle({ pointerStateRef.current = { containerSize, - pointerStart: direction === "horizontal" ? event.clientX : event.clientY, + pointerStart: + direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY, leftSize: sizes[index] ?? 0, rightSize: sizes[index + 1] ?? 0, }; @@ -93,47 +94,57 @@ export function ResizeHandle({ [direction, groupId, index, onResizeSplit, sizes], ); + const handlePointerEnter = useCallback(() => { + hoverTimerRef.current = setTimeout(() => { + setActive(true); + }, 150); + }, []); + + const handlePointerLeave = useCallback(() => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setActive(false); + }, []); + + const handleStyle = useMemo( + () => [ + styles.handle, + direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical, + { backgroundColor: theme.colors.border }, + ], + [direction, theme.colors.border], + ); + const highlightStyle = useMemo( + () => [ + styles.highlight, + direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical, + { backgroundColor: theme.colors.accent }, + ], + [direction, theme.colors.accent], + ); + const hitAreaStyle = useMemo( + () => [ + styles.hitArea, + direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical, + { + cursor: direction === "horizontal" ? "col-resize" : "row-resize", + } as object, + ], + [direction], + ); + return ( - - {highlighted && ( - - )} + + {highlighted && } { - hoverTimerRef.current = setTimeout(() => { - setActive(true); - }, 150); - }} - onPointerLeave={() => { - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - setActive(false); - }} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} /> ); diff --git a/packages/app/src/components/sidebar-agent-list-skeleton.tsx b/packages/app/src/components/sidebar-agent-list-skeleton.tsx index 835abf361..fb1965df5 100644 --- a/packages/app/src/components/sidebar-agent-list-skeleton.tsx +++ b/packages/app/src/components/sidebar-agent-list-skeleton.tsx @@ -1,14 +1,56 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Animated, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +const SECTION_OPACITIES: readonly number[] = [1, 0.7, 0.4]; +const SECTION_KEYS = SECTION_OPACITIES.map((_, i) => `skeleton-section-${i}`); +const ROW_KEYS_BY_SECTION: readonly (readonly string[])[] = SECTION_OPACITIES.map((_, sIdx) => + [0, 1, 2].map((r) => `skeleton-row-${sIdx}-${r}`), +); + function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp }) { const opacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [0.4, 0.8], }); - return ; + const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]); + + return ; +} + +function SkeletonSection({ + pulse, + sectionOpacity, + sectionIdx, +}: { + pulse: Animated.Value; + sectionOpacity: number; + sectionIdx: number; +}) { + const sectionStyle = useMemo( + () => [styles.section, { opacity: sectionOpacity }], + [sectionOpacity], + ); + return ( + + + + + + + + + {ROW_KEYS_BY_SECTION[sectionIdx]?.map((key) => ( + + + + + + ))} + + + ); } export function SidebarAgentListSkeleton() { @@ -36,27 +78,13 @@ export function SidebarAgentListSkeleton() { return ( - {[1, 0.7, 0.4].map((sectionOpacity, sectionIdx) => ( - - - - - - - - - {Array.from({ length: 3 }).map((__, rowIdx) => ( - - - - - - ))} - - + {SECTION_OPACITIES.map((sectionOpacity, sectionIdx) => ( + ))} ); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index d6bb497d6..014965a58 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -45,13 +45,13 @@ vi.mock("@react-native-async-storage/async-storage", () => ({ const SERVER_ID = "sidebar-render-count"; -type RenderCounts = { +interface RenderCounts { frame: number; headers: Record; rows: Record; projectSelection: Record; rowSelection: Record; -}; +} const runningScript: WorkspaceScriptPayload = { scriptName: "web", @@ -203,11 +203,11 @@ function WorkspaceRowProbe({ workspaceId: string; counts: RenderCounts; }): null { - const workspace = useWorkspaceFields(serverId, workspaceId, (entry) => + const workspaceEntry = useWorkspaceFields(serverId, workspaceId, (entry) => createSidebarWorkspaceEntry({ serverId, workspace: entry }), ); - if (workspace) { - incrementRecord(counts.rows, workspace.workspaceId); + if (workspaceEntry) { + incrementRecord(counts.rows, workspaceEntry.workspaceId); } return null; } @@ -223,7 +223,7 @@ function ProjectActiveProbe({ }): null { useIsNavigationProjectActive({ serverId, - workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId), + workspaceIds: project.workspaces.map((entry) => entry.workspaceId), }); incrementRecord(counts.projectSelection, project.projectKey); return null; @@ -253,16 +253,16 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
- {project.workspaces.map((workspace) => ( - + {project.workspaces.map((entry) => ( + @@ -397,13 +397,14 @@ describe("sidebar workspace render isolation", () => { }; ({ root, container } = await renderProbe(counts)); + const applyRunningScript = (current: Parameters[0]) => + patchWorkspaceScripts(current, { + workspaceId: "a-main", + scripts: [{ ...runningScript }], + }); + act(() => { - useSessionStore.getState().setWorkspaces(SERVER_ID, (current) => - patchWorkspaceScripts(current, { - workspaceId: "a-main", - scripts: [{ ...runningScript }], - }), - ); + useSessionStore.getState().setWorkspaces(SERVER_ID, applyRunningScript); }); expect(counts).toEqual({ diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 16d86936d..51f606114 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -8,6 +8,7 @@ import { StatusBar, ScrollView, type GestureResponderEvent, + type PressableStateCallbackType, } from "react-native"; import * as Haptics from "expo-haptics"; import { useQueries } from "@tanstack/react-query"; @@ -19,8 +20,9 @@ import { useRef, type ReactElement, type MutableRefObject, + type Ref, } from "react"; -import { router, usePathname } from "expo-router"; +import { router, usePathname, type Href } from "expo-router"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; @@ -195,6 +197,16 @@ interface WorkspaceRowInnerProps { archiveShortcutKeys?: ShortcutKey[][] | null; } +function getWorkspaceArchiveStatus( + isWorktree: boolean, + archiveStatus: "idle" | "pending" | "success", + isArchivingWorkspace: boolean, +): "idle" | "pending" | "success" { + if (isWorktree) return archiveStatus; + if (isArchivingWorkspace) return "pending"; + return "idle"; +} + function useSidebarWorkspaceEntry( serverId: string | null, workspaceId: string | null, @@ -226,6 +238,14 @@ export function PrBadge({ hint }: { hint: PrHint }) { [hint.url], ); + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => setIsHovered(false), []); + + const prBadgeTextStyle = useMemo( + () => [prBadgeStyles.text, { color: activeColor }], + [activeColor], + ); + return ( setIsHovered(true)} - onHoverOut={() => setIsHovered(false)} - style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} + style={prBadgePressableStyle} > {isHovered ? ( ) : ( )} - + #{hint.number} ); } +function prBadgePressableStyle({ pressed }: PressableStateCallbackType) { + return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]; +} + +function projectKebabStyle({ + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered]; +} + +function workspaceKebabStyle({ + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.kebabButton, hovered && styles.kebabButtonHovered]; +} + +function noop() {} + const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", @@ -268,18 +306,20 @@ const prBadgeStyles = StyleSheet.create((theme) => ({ function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null { const { theme } = useUnistyles(); + const color = theme.colors.palette.red[500]; + const textStyle = useMemo(() => [checksBadgeStyles.text, { color }], [color]); + if (!checks || checks.length === 0) return null; const failed = checks.filter((c) => c.status === "failure").length; if (failed === 0) return null; - const color = theme.colors.palette.red[500]; const label = `${failed} failed`; return ( - {label} + {label} ); } @@ -340,8 +380,10 @@ function WorkspaceStatusIndicator({ ); } - const KindIcon = - workspaceKind === "local_checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 : null; + let KindIcon: typeof Monitor | typeof FolderGit2 | null; + if (workspaceKind === "local_checkout") KindIcon = Monitor; + else if (workspaceKind === "worktree") KindIcon = FolderGit2; + else KindIcon = null; if (!KindIcon) return null; const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false }); @@ -357,24 +399,45 @@ function WorkspaceStatusIndicator({ {dotColor ? ( - ) : null} ); } +function StatusDotOverlay({ + dotColor, + borderColor, + size, + offset, +}: { + dotColor: string; + borderColor: string; + size: number; + offset: number; +}) { + const overlayStyle = useMemo( + () => [ + styles.statusDotOverlay, + { + backgroundColor: dotColor, + borderColor, + width: size, + height: size, + right: offset, + bottom: offset, + }, + ], + [dotColor, borderColor, size, offset], + ); + return ; +} + function ProjectLeadingVisual({ displayName, iconDataUri, @@ -408,18 +471,337 @@ function ProjectLeadingVisual({ ); } - const projectIcon = iconDataUri ? ( - - ) : ( + if (!shouldShowWorkspaceStatus || !activeWorkspace) { + return ( + + + + ); + } + + return ( + + ); +} + +function ProjectRowTrailingActions({ + project, + displayName, + canCreateWorktree, + isHovered, + isMobileBreakpoint, + isProjectActive, + onBeginWorkspaceSetup, + onRemoveProject, + removeProjectStatus, + theme, +}: { + project: SidebarProjectEntry; + displayName: string; + canCreateWorktree: boolean; + isHovered: boolean; + isMobileBreakpoint: boolean; + isProjectActive: boolean; + onBeginWorkspaceSetup: () => void; + onRemoveProject?: () => void; + removeProjectStatus: "idle" | "pending" | "success"; + theme: ReturnType["theme"]; +}) { + const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint; + return ( + + {canCreateWorktree ? ( + + ) : null} + {onRemoveProject ? ( + + + + ) : null} + + ); +} + +function ProjectKebabMenu({ + projectKey, + theme, + onRemoveProject, + removeProjectStatus, +}: { + projectKey: string; + theme: ReturnType["theme"]; + onRemoveProject: () => void; + removeProjectStatus: "idle" | "pending" | "success"; +}) { + const removeProjectLeadingIcon = useMemo( + () => , + [theme.colors.foregroundMuted], + ); + const renderTriggerIcon = useCallback( + ({ hovered }: { hovered?: boolean }) => ( + + ), + [theme.colors.foreground, theme.colors.foregroundMuted], + ); + return ( + + + {renderTriggerIcon} + + + + Remove project + + + + ); +} + +function WorkspaceRowRightGroup({ + workspace, + isHovered, + isTouchPlatform, + showScriptsIcon, + hasRunningService, + isCreating, + showShortcutBadge, + shortcutNumber, + theme, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, + onArchive, + onCopyBranchName, + onCopyPath, +}: { + workspace: SidebarWorkspaceEntry; + isHovered: boolean; + isTouchPlatform: boolean; + showScriptsIcon: boolean; + hasRunningService: boolean; + isCreating: boolean; + showShortcutBadge: boolean; + shortcutNumber: number | null; + theme: ReturnType["theme"]; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + archiveShortcutKeys?: ShortcutKey[][] | null; + onArchive?: () => void; + onCopyBranchName?: () => void; + onCopyPath?: () => void; +}) { + const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); + return ( + + {showScriptsIcon ? ( + + {hasRunningService ? ( + + ) : ( + + )} + + ) : null} + {isCreating ? Creating... : null} + {showKebab && onArchive ? ( + + ) : null} + {!showKebab && workspace.diffStat ? ( + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + + ); +} + +function WorkspaceKebabTriggerIcon({ + hovered, + foreground, + foregroundMuted, +}: { + hovered: boolean; + foreground: string; + foregroundMuted: string; +}) { + return ; +} + +function WorkspaceKebabMenu({ + workspaceKey, + theme, + onCopyPath, + onCopyBranchName, + onArchive, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, +}: { + workspaceKey: string; + theme: ReturnType["theme"]; + onCopyPath?: () => void; + onCopyBranchName?: () => void; + onArchive: () => void; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + archiveShortcutKeys?: ShortcutKey[][] | null; +}) { + const copyLeadingIcon = useMemo( + () => , + [theme.colors.foregroundMuted], + ); + const archiveLeadingIcon = useMemo( + () => , + [theme.colors.foregroundMuted], + ); + const archiveTrailing = useMemo( + () => (archiveShortcutKeys ? : null), + [archiveShortcutKeys], + ); + const renderTriggerIcon = useCallback( + ({ hovered }: { hovered?: boolean }) => ( + + ), + [theme.colors.foreground, theme.colors.foregroundMuted], + ); + return ( + + + {renderTriggerIcon} + + + {onCopyPath ? ( + + Copy path + + ) : null} + {onCopyBranchName ? ( + + Copy branch name + + ) : null} + + {archiveLabel ?? "Archive"} + + + + ); +} + +function ProjectIcon({ + iconDataUri, + placeholderInitial, +}: { + iconDataUri: string | null; + placeholderInitial: string; +}) { + const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); + if (iconDataUri) { + return ; + } + return ( {placeholderInitial} ); +} - if (!shouldShowWorkspaceStatus || !activeWorkspace) { - return {projectIcon}; - } - +function ProjectLeadingVisualStatus({ + iconDataUri, + placeholderInitial, + isArchiving, + shouldShowSyncedLoader, + activeWorkspace, + theme, +}: { + iconDataUri: string | null; + placeholderInitial: string; + isArchiving: boolean; + shouldShowSyncedLoader: boolean; + activeWorkspace: SidebarWorkspaceEntry; + theme: ReturnType["theme"]; +}) { if (isArchiving) { return ( @@ -466,20 +848,13 @@ function ProjectLeadingVisual({ return ( - {projectIcon} + {dotColor ? ( - ) : null} @@ -514,20 +889,30 @@ function NewWorktreeButton({ const { theme } = useUnistyles(); const newWorktreeKeys = useShortcutKeys("new-worktree"); + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.projectIconActionButton, + !visible && styles.projectIconActionButtonHidden, + (Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered, + ], + [visible, loading], + ); + + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onPress(); + }, + [onPress], + ); + return ( [ - styles.projectIconActionButton, - !visible && styles.projectIconActionButtonHidden, - (hovered || pressed) && !loading && styles.projectIconActionButtonHovered, - ]} - onPress={(event) => { - event.stopPropagation(); - onPress(); - }} + style={pressableStyle} + onPress={handlePress} disabled={loading} accessibilityRole="button" accessibilityLabel={`Create a new workspace for ${displayName}`} @@ -664,10 +1049,10 @@ function useLongPressDragInteraction(input: { void Haptics.selectionAsync().catch(() => {}); openContextMenuAtStartPoint(); }, CONTEXT_MENU_DELAY_MS); - }, [clearTimers, input.menuController, openContextMenuAtStartPoint]); + }, [clearTimers, input, openContextMenuAtStartPoint]); const handleDragIntent = useCallback( - (details: { dx: number; dy: number; distance: number }) => { + (_details: { dx: number; dy: number; distance: number }) => { if (!dragActivatedRef.current) { return; } @@ -680,7 +1065,7 @@ function useLongPressDragInteraction(input: { ); const handleScrollIntent = useCallback( - (details: { dx: number; dy: number; distance: number }) => { + (_details: { dx: number; dy: number; distance: number }) => { scrollIntentRef.current = true; didLongPressRef.current = true; clearTimers(); @@ -689,7 +1074,7 @@ function useLongPressDragInteraction(input: { ); const handleSwipeIntent = useCallback( - (details: { dx: number; dy: number; distance: number }) => { + (_details: { dx: number; dy: number; distance: number }) => { didLongPressRef.current = true; clearTimers(); }, @@ -718,7 +1103,7 @@ function useLongPressDragInteraction(input: { ); const handleTouchMove = useCallback( - (event: any) => { + (event: GestureResponderEvent) => { const start = touchStartRef.current; if (!start || didStartDragRef.current || menuOpenedRef.current) { return; @@ -789,7 +1174,7 @@ function ProjectHeaderRow({ canCreateWorktree, isProjectActive = false, onWorkspacePress, - onWorktreeCreated, + onWorktreeCreated: _onWorktreeCreated, shortcutNumber = null, showShortcutBadge = false, drag, @@ -808,12 +1193,12 @@ function ProjectHeaderRow({ return; } router.navigate( - buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any, + buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href, ); onWorkspacePress?.(); }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); - const toast = useToast(); + const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const _toast = useToast(); const interaction = useLongPressDragInteraction({ drag, @@ -828,6 +1213,20 @@ function ProjectHeaderRow({ onPress(); }, [interaction.didLongPressRef, onPress]); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + + const projectRowStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.projectRow, + isDragging && styles.projectRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.projectRowHovered, + pressed && styles.projectRowPressed, + ], + [isDragging, selected, isHovered], + ); + const rowChildren = ( <> @@ -846,57 +1245,18 @@ function ProjectHeaderRow({ - - {canCreateWorktree ? ( - - ) : null} - {onRemoveProject ? ( - - - [ - styles.projectKebabButton, - hovered && styles.projectKebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Project actions" - testID={`sidebar-project-kebab-${project.projectKey}`} - > - {({ hovered }) => ( - - )} - - - } - status={removeProjectStatus} - pendingLabel="Removing..." - onSelect={onRemoveProject} - > - Remove project - - - - - ) : null} - + {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} @@ -908,21 +1268,15 @@ function ProjectHeaderRow({ if (menuController) { return ( setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + {...dragHandleProps?.attributes} + {...dragHandleProps?.listeners} + ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} > [ - styles.projectRow, - isDragging && styles.projectRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.projectRowHovered, - pressed && styles.projectRowPressed, - ]} + style={projectRowStyle} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} @@ -937,20 +1291,14 @@ function ProjectHeaderRow({ return ( setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + {...dragHandleProps?.attributes} + {...dragHandleProps?.listeners} + ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} > [ - styles.projectRow, - isDragging && styles.projectRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.projectRowHovered, - pressed && styles.projectRowPressed, - ]} + style={projectRowStyle} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} @@ -984,7 +1332,7 @@ function WorkspaceRowInner({ archiveShortcutKeys, }: WorkspaceRowInnerProps) { const { theme } = useUnistyles(); - const isCompact = useIsCompactFormFactor(); + const _isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); const isTouchPlatform = platformIsNative; const workspaceDirectory = resolveWorkspaceExecutionDirectory({ @@ -1008,34 +1356,51 @@ function WorkspaceRowInner({ onPress(); }, [interaction.didLongPressRef, onPress]); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + + const workspaceRowStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ], + [isDragging, selected, isHovered], + ); + const isDesktop = !isTouchPlatform; const showScriptsIcon = isDesktop && workspace.hasRunningScripts; const hasRunningService = workspace.scripts.some( (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", ); + const accessibilityState = useMemo(() => ({ selected }), [selected]); + const workspaceBranchTextStyle = useMemo( + () => [ + styles.workspaceBranchText, + isHovered && styles.workspaceBranchTextHovered, + isCreating && styles.workspaceBranchTextCreating, + ], + [isHovered, isCreating], + ); return ( } style={styles.workspaceRowContainer} - onPointerEnter={() => setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} > [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} + accessibilityState={accessibilityState} + style={workspaceRowStyle} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} @@ -1049,92 +1414,28 @@ function WorkspaceRowInner({ workspaceKind={workspace.workspaceKind} loading={isArchiving || isCreating} /> - + {workspace.name} - - {showScriptsIcon ? ( - - {hasRunningService ? ( - - ) : ( - - )} - - ) : null} - {isCreating ? Creating... : null} - {onArchive && (isHovered || isTouchPlatform) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - - )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={ - archiveShortcutKeys ? : null - } - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} - > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} - + {prHint ? ( @@ -1212,9 +1513,9 @@ function WorkspaceRowWithMenu({ if (!confirmed) { return; } - let workspaceDirectory: string; + let archiveDirectory: string; try { - workspaceDirectory = requireWorkspaceExecutionDirectory({ + archiveDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); @@ -1223,7 +1524,7 @@ function WorkspaceRowWithMenu({ return; } - if (!workspaceDirectory) { + if (!archiveDirectory) { toast.error("Workspace path not available"); return; } @@ -1232,8 +1533,8 @@ function WorkspaceRowWithMenu({ void archiveWorktree({ serverId: workspace.serverId, - cwd: workspaceDirectory, - worktreePath: workspaceDirectory, + cwd: archiveDirectory, + worktreePath: archiveDirectory, }).catch((error) => { const message = error instanceof Error ? error.message : "Failed to archive worktree"; toast.error(message); @@ -1299,9 +1600,9 @@ function WorkspaceRowWithMenu({ ]); const handleCopyPath = useCallback(() => { - let workspaceDirectory: string; + let copyTargetDirectory: string; try { - workspaceDirectory = requireWorkspaceExecutionDirectory({ + copyTargetDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); @@ -1309,7 +1610,7 @@ function WorkspaceRowWithMenu({ toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } - void Clipboard.setStringAsync(workspaceDirectory); + void Clipboard.setStringAsync(copyTargetDirectory); toast.copied("Path copied"); }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); @@ -1349,7 +1650,7 @@ function WorkspaceRowWithMenu({ dragHandleProps={dragHandleProps} menuController={null} archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"} - archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? "pending" : "idle"} + archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)} archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."} onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace} onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined} @@ -1594,6 +1895,59 @@ function FlattenedProjectRow({ ); } +interface WorkspaceRowItemProps { + workspace: SidebarWorkspaceEntry; + shortcutNumber: number | null; + showShortcutBadge: boolean; + canCopyBranchName: boolean; + isCreating?: boolean; + selectionEnabled: boolean; + serverId: string | null; + currentPathname: string | null; + onWorkspacePress?: () => void; + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; +} + +function WorkspaceRowItem({ + workspace, + shortcutNumber, + showShortcutBadge, + canCopyBranchName, + isCreating = false, + selectionEnabled, + serverId, + currentPathname, + onWorkspacePress, + drag, + isDragging = false, + dragHandleProps, +}: WorkspaceRowItemProps) { + const handlePress = useCallback(() => { + if (!serverId) { + return; + } + onWorkspacePress?.(); + navigateToWorkspace(serverId, workspace.workspaceId, { currentPathname }); + }, [serverId, onWorkspacePress, workspace.workspaceId, currentPathname]); + + return ( + + ); +} + function WorkspaceRow({ workspace, shortcutNumber, @@ -1674,7 +2028,7 @@ function ProjectBlock({ showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; - onToggleCollapsed: () => void; + onToggleCollapsed: (projectKey: string) => void; onWorkspacePress?: () => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorktreeCreated?: (workspaceId: string) => void; @@ -1714,22 +2068,18 @@ function ProjectBlock({ }, ) => { return ( - { - if (!serverId) { - return; - } - onWorkspacePress?.(); - navigateToWorkspace(serverId, item.workspaceId, { currentPathname }); - }} - drag={input?.drag ?? (() => {})} - isDragging={input?.isDragging ?? false} + serverId={serverId} + currentPathname={currentPathname} + onWorkspacePress={onWorkspacePress} + drag={input?.drag} + isDragging={input?.isDragging} dragHandleProps={input?.dragHandleProps} /> ); @@ -1797,6 +2147,7 @@ function ProjectBlock({ setIsRemovingProject(true); + const isRejected = (r: PromiseSettledResult) => r.status === "rejected"; void Promise.allSettled( project.workspaces.map(async (ws) => { const payload = await client.archiveWorkspace(ws.workspaceId); @@ -1805,15 +2156,32 @@ function ProjectBlock({ } }), ).then((results) => { - const failed = results.filter((r) => r.status === "rejected"); + const failed = results.filter(isRejected); if (failed.length > 0) { toast.error("Failed to remove some workspaces"); } setIsRemovingProject(false); + return; }); })(); }, [isRemovingProject, serverId, displayName, toast, project.workspaces]); + const flattenedRowWorkspaceId = + rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null; + const handleFlattenedRowPress = useCallback(() => { + if (!serverId || !flattenedRowWorkspaceId) { + return; + } + onWorkspacePress?.(); + navigateToWorkspace(serverId, flattenedRowWorkspaceId, { + currentPathname, + }); + }, [serverId, flattenedRowWorkspaceId, onWorkspacePress, currentPathname]); + + const handleToggleCollapsed = useCallback(() => { + onToggleCollapsed(project.projectKey); + }, [onToggleCollapsed, project.projectKey]); + return ( {rowModel.kind === "workspace_link" ? ( @@ -1822,15 +2190,7 @@ function ProjectBlock({ displayName={displayName} iconDataUri={iconDataUri} rowModel={rowModel} - onPress={() => { - if (!serverId) { - return; - } - onWorkspacePress?.(); - navigateToWorkspace(serverId, rowModel.workspace.workspaceId, { - currentPathname, - }); - }} + onPress={handleFlattenedRowPress} serverId={serverId} onWorkspacePress={onWorkspacePress} onWorktreeCreated={onWorktreeCreated} @@ -1853,7 +2213,7 @@ function ProjectBlock({ workspace={null} selected={false} chevron={rowModel.chevron} - onPress={onToggleCollapsed} + onPress={handleToggleCollapsed} serverId={serverId} canCreateWorktree={rowModel.trailingAction === "new_worktree"} isProjectActive={isProjectActive} @@ -1894,8 +2254,8 @@ export function SidebarWorkspaceList({ collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, - isRefreshing = false, - onRefresh, + isRefreshing: _isRefreshing = false, + onRefresh: _onRefresh, onWorkspacePress, onAddProject, listFooterComponent, @@ -1986,11 +2346,12 @@ export function SidebarWorkspaceList({ }, [projectIconQueries, projectIconRequests, projects, serverId]); useEffect(() => { + const timeouts = creatingWorkspaceTimeoutsRef.current; return () => { - for (const timeout of creatingWorkspaceTimeoutsRef.current.values()) { + for (const timeout of timeouts.values()) { clearTimeout(timeout); } - creatingWorkspaceTimeoutsRef.current.clear(); + timeouts.clear(); }; }, []); @@ -2126,7 +2487,7 @@ export function SidebarWorkspaceList({ showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} parentGestureRef={parentGestureRef} - onToggleCollapsed={() => onToggleProjectCollapsed(item.projectKey)} + onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} onWorkspaceReorder={handleWorkspaceReorder} onWorktreeCreated={handleWorktreeCreated} diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 26e796221..d1d9c9b13 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,4 +1,5 @@ -import { Pressable, Text, View } from "react-native"; +import { useCallback, useMemo } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { LucideIcon } from "lucide-react-native"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; @@ -30,6 +31,35 @@ export function SidebarHeaderRow({ }: SidebarHeaderRowProps) { const { theme } = useUnistyles(); + const buttonStyle = useCallback( + ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.button, + (Boolean(hovered) || isActive) && styles.buttonHovered, + ], + [isActive], + ); + + const renderChildren = useCallback( + (state: PressableStateCallbackType & { hovered?: boolean }) => { + const isHighlighted = Boolean(state.hovered) || isActive; + const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted; + return ( + <> + + + + ); + }, + [ + Icon, + isActive, + label, + theme.colors.foreground, + theme.colors.foregroundMuted, + theme.iconSize.md, + ], + ); + return ( [styles.button, (hovered || isActive) && styles.buttonHovered]} + style={buttonStyle} > - {({ hovered }) => { - const isHighlighted = hovered || isActive; - const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted; - return ( - <> - - {label} - - ); - }} + {renderChildren} ); } +function SidebarHeaderRowLabel({ + label, + isHighlighted, +}: { + label: string; + isHighlighted: boolean; +}) { + const labelStyle = useMemo( + () => [styles.label, isHighlighted && styles.labelHighlighted], + [isHighlighted], + ); + return {label}; +} + const styles = StyleSheet.create((theme) => ({ container: { height: { diff --git a/packages/app/src/components/sortable-inline-list.web.test.tsx b/packages/app/src/components/sortable-inline-list.web.test.tsx index 00d77c95f..5196ea7ae 100644 --- a/packages/app/src/components/sortable-inline-list.web.test.tsx +++ b/packages/app/src/components/sortable-inline-list.web.test.tsx @@ -7,10 +7,10 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SortableInlineList } from "./sortable-inline-list.web"; -type DndContextProps = { +interface DndContextProps { onDragStart?: (event: { active: { id: string } }) => void; onDragCancel?: () => void; -}; +} let latestDndContextProps: DndContextProps | null = null; @@ -27,7 +27,7 @@ vi.mock("@dnd-kit/core", () => ({ })); vi.mock("@dnd-kit/sortable", () => ({ - SortableContext: ({ children }: React.PropsWithChildren) => <>{children}, + SortableContext: ({ children }: React.PropsWithChildren) => children, arrayMove: (items: T[], from: number, to: number) => { const next = [...items]; const [item] = next.splice(from, 1); @@ -73,18 +73,28 @@ afterEach(() => { vi.unstubAllGlobals(); }); +const DATA: string[] = ["alpha", "beta"]; + +function keyExtractor(item: string): string { + return item; +} + +function renderItem({ item, isActive }: { item: string; isActive: boolean }) { + return ( +
+ {item} +
+ ); +} + function renderList(): void { act(() => { root?.render( item} + data={DATA} + keyExtractor={keyExtractor} onDragEnd={vi.fn()} - renderItem={({ item, isActive }) => ( -
- {item} -
- )} + renderItem={renderItem} />, ); }); diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index cd71a39ca..22c125cbc 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, type ReactElement } from "react"; +import { useCallback, useMemo, useState, type ReactElement } from "react"; import { DndContext, closestCenter, @@ -25,6 +25,13 @@ const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ y: 0, }); +const DND_MODIFIERS: Modifier[] = [restrictToHorizontalAxis]; + +function computeDragOpacity(hasExternalContext: boolean, isDragging: boolean): number { + if (!isDragging) return 1; + return hasExternalContext ? 0.3 : 0.9; +} + function SortableItem({ id, item, @@ -71,12 +78,15 @@ function SortableItem({ const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : ""; const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" "); - const style = { - transform: combinedTransform || undefined, - transition, - opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1, - zIndex: isDragging ? 1000 : 1, - }; + const style = useMemo( + () => ({ + transform: combinedTransform || undefined, + transition, + opacity: computeDragOpacity(Boolean(externalDndContext), isDragging), + zIndex: isDragging ? 1000 : 1, + }), + [combinedTransform, transition, externalDndContext, isDragging], + ); const info: DraggableRenderItemInfo = { item, @@ -183,7 +193,10 @@ export function SortableInlineList({ [clearDragState, disabled, items, keyExtractor, onDragEnd], ); - const ids = items.map((item, index) => keyExtractor(item, index)); + const ids = useMemo( + () => items.map((item, index) => keyExtractor(item, index)), + [items, keyExtractor], + ); const renderedItems = ( @@ -215,7 +228,7 @@ export function SortableInlineList({ { +interface SplitNodeViewProps extends Omit { node: SplitNode; uiTabs: WorkspaceTab[]; focusedPaneId: string; @@ -141,7 +141,6 @@ interface SplitPaneViewProps extends Omit< | "activeDragTabId" | "showDropZones" | "dropPreview" - | "onMoveTabToPane" | "onResizeSplit" > { pane: SplitPane; @@ -182,8 +181,13 @@ const MountedTabSlot = memo(function MountedTabSlot({ [buildPaneContentModel, paneId, tabDescriptor], ); + const wrapperStyle = useMemo( + () => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }), + [isVisible], + ); + return ( - + | Pick, +): DragMoveRects | null { + const translatedRect = event.active.rect.current.translated; + const overRect = event.over?.rect; + if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) { + return null; + } + return { translatedRect, overRect }; +} + +function computeTabOverDropPreview(input: { + activeData: WorkspaceTabDragData; + overData: WorkspaceTabDragData; + rects: DragMoveRects; + panesById: Map; + uiTabs: WorkspaceTab[]; +}): TabDropPreview | null { + const { activeData, overData, rects, panesById, uiTabs } = input; + const targetPane = panesById.get(overData.paneId) ?? null; + if (!targetPane) { + return null; + } + const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); + return computeTabDropPreview({ + activePaneId: activeData.paneId, + activeTabId: activeData.tabId, + overPaneId: overData.paneId, + overTabId: overData.tabId, + targetTabs, + activeRect: { + left: rects.translatedRect.left, + width: rects.translatedRect.width, + }, + overRect: { + left: rects.overRect.left, + width: rects.overRect.width, + }, + }); +} + +function computePaneOverDropPreview(input: { + overData: SplitPaneDropData; + rects: DragMoveRects; +}): SplitDropZoneHover | null { + const { overData, rects } = input; + const centerX = rects.translatedRect.left + rects.translatedRect.width / 2; + const centerY = rects.translatedRect.top + rects.translatedRect.height / 2; + const relativeX = centerX - rects.overRect.left; + const relativeY = centerY - rects.overRect.top; + if ( + Number.isNaN(relativeX) || + Number.isNaN(relativeY) || + relativeX < 0 || + relativeX > rects.overRect.width || + relativeY < 0 || + relativeY > rects.overRect.height + ) { + return null; + } + return { + paneId: overData.paneId, + position: resolveSplitDropPosition({ + width: rects.overRect.width, + height: rects.overRect.height, + x: relativeX, + y: relativeY, + }), + }; +} + const dropCollisionDetection: CollisionDetection = (args) => { const pointerHits = pointerWithin(args); const tabHits = pointerHits.filter( @@ -328,44 +408,23 @@ export function SplitContainer({ return; } - const translatedRect = event.active.rect.current.translated; - const overRect = event.over?.rect; - if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) { + const rects = resolveDragMoveRects(event); + if (!rects) { setDropPreview(null); setTabDropPreview(null); return; } if (overData?.kind === "workspace-tab") { - const targetPane = panesById.get(overData.paneId) ?? null; - if (!targetPane) { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - const targetTabs = getWorkspacePaneDescriptors({ - pane: targetPane, - tabs: uiTabs, + const preview = computeTabOverDropPreview({ + activeData, + overData, + rects, + panesById, + uiTabs, }); setDropPreview(null); - setTabDropPreview( - computeTabDropPreview({ - activePaneId: activeData.paneId, - activeTabId: activeData.tabId, - overPaneId: overData.paneId, - overTabId: overData.tabId, - targetTabs, - activeRect: { - left: translatedRect.left, - width: translatedRect.width, - }, - overRect: { - left: overRect.left, - width: overRect.width, - }, - }), - ); + setTabDropPreview(preview); return; } @@ -375,33 +434,71 @@ export function SplitContainer({ return; } - const centerX = translatedRect.left + translatedRect.width / 2; - const centerY = translatedRect.top + translatedRect.height / 2; - const relativeX = centerX - overRect.left; - const relativeY = centerY - overRect.top; - if ( - Number.isNaN(relativeX) || - Number.isNaN(relativeY) || - relativeX < 0 || - relativeX > overRect.width || - relativeY < 0 || - relativeY > overRect.height - ) { - setDropPreview(null); + setDropPreview(computePaneOverDropPreview({ overData, rects })); + }, + [panesById, uiTabs], + ); + + const applyTabDropEnd = useCallback( + (input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => { + const { activeData, overData } = input; + const sourcePane = panesById.get(activeData.paneId) ?? null; + const targetPane = panesById.get(overData.paneId) ?? null; + if (!sourcePane || !targetPane) { return; } - setDropPreview({ - paneId: overData.paneId, - position: resolveSplitDropPosition({ - width: overRect.width, - height: overRect.height, - x: relativeX, - y: relativeY, - }), + const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs }); + const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); + const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); + const resolvedTabDropPreview = + tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null; + if (sourceIndex < 0 || !resolvedTabDropPreview) { + return; + } + + if (activeData.paneId === overData.paneId) { + if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { + const nextTabs = arrayMove( + sourceTabs, + sourceIndex, + resolvedTabDropPreview.insertionIndex, + ); + onReorderTabsInPane( + activeData.paneId, + nextTabs.map((tab) => tab.tabId), + ); + } + return; + } + + const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); + nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId); + onMoveTabToPane(activeData.tabId, overData.paneId); + onReorderTabsInPane(overData.paneId, nextTargetTabIds); + }, + [onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs], + ); + + const applyPaneDropEnd = useCallback( + (input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => { + const { activeData, overData } = input; + if (dropPreview?.paneId !== overData.paneId) { + return; + } + if (dropPreview.position === "center") { + if (activeData.paneId !== overData.paneId) { + onMoveTabToPane(activeData.tabId, overData.paneId); + } + return; + } + onSplitPane({ + tabId: activeData.tabId, + targetPaneId: overData.paneId, + position: dropPreview.position, }); }, - [panesById, uiTabs], + [dropPreview, onMoveTabToPane, onSplitPane], ); const handleDragEnd = useCallback( @@ -414,87 +511,18 @@ export function SplitContainer({ setActiveDragTabId(null); - if (activeData?.kind !== "workspace-tab" || !event.over) { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - if (overData?.kind === "workspace-tab") { - const sourcePane = panesById.get(activeData.paneId) ?? null; - const targetPane = panesById.get(overData.paneId) ?? null; - if (!sourcePane || !targetPane) { - setDropPreview(null); - setTabDropPreview(null); - return; + if (activeData?.kind === "workspace-tab" && event.over) { + if (overData?.kind === "workspace-tab") { + applyTabDropEnd({ activeData, overData }); + } else if (overData?.kind === "split-pane-drop") { + applyPaneDropEnd({ activeData, overData }); } - - const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs }); - const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs }); - const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); - const resolvedTabDropPreview = - tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null; - if (sourceIndex < 0 || !resolvedTabDropPreview) { - setDropPreview(null); - setTabDropPreview(null); - return; - } - - if (activeData.paneId === overData.paneId) { - if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { - const nextTabs = arrayMove( - sourceTabs, - sourceIndex, - resolvedTabDropPreview.insertionIndex, - ); - onReorderTabsInPane( - activeData.paneId, - nextTabs.map((tab) => tab.tabId), - ); - } - setDropPreview(null); - setTabDropPreview(null); - return; - } - - const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); - nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId); - onMoveTabToPane(activeData.tabId, overData.paneId); - onReorderTabsInPane(overData.paneId, nextTargetTabIds); - setDropPreview(null); - setTabDropPreview(null); - return; - } - - if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) { - if (dropPreview.position === "center") { - if (activeData.paneId !== overData.paneId) { - onMoveTabToPane(activeData.tabId, overData.paneId); - } - setDropPreview(null); - setTabDropPreview(null); - return; - } - - onSplitPane({ - tabId: activeData.tabId, - targetPaneId: overData.paneId, - position: dropPreview.position, - }); } setDropPreview(null); setTabDropPreview(null); }, - [ - dropPreview, - onMoveTabToPane, - onReorderTabsInPane, - onSplitPane, - panesById, - tabDropPreview, - uiTabs, - ], + [applyTabDropEnd, applyPaneDropEnd], ); return ( @@ -533,7 +561,6 @@ export function SplitContainer({ onFocusPane={onFocusPane} onSplitPane={onSplitPane} onSplitPaneEmpty={onSplitPaneEmpty} - onMoveTabToPane={onMoveTabToPane} onResizeSplit={onResizeSplit} onReorderTabsInPane={onReorderTabsInPane} renderPaneEmptyState={renderPaneEmptyState} @@ -568,15 +595,21 @@ function DragOverlayTabChip({ normalizedWorkspaceId: string; }) { const tab = uiTabs.find((t) => t.tabId === tabId); - if (!tab) { + const descriptor = useMemo( + () => + tab + ? { + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + } + : null, + [tab], + ); + if (!descriptor) { return null; } - const descriptor: WorkspaceTabDescriptor = { - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, - }; return ( [ + styles.dragOverlayChip, + { + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + ], + [theme.colors.surface1, theme.colors.borderAccent], + ); + const chipLabelStyle = useMemo( + () => [styles.dragOverlayLabel, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + return ( + - + {label} @@ -630,6 +667,11 @@ function DragOverlayTabChipInner({ ); } +function SplitGroupChild({ flex, children }: { flex: number; children: ReactNode }) { + const childStyle = useMemo(() => [styles.groupChild, { flex }], [flex]); + return {children}; +} + function SplitNodeView({ node, workspaceKey, @@ -656,7 +698,6 @@ function SplitNodeView({ onFocusPane, onSplitPane, onSplitPaneEmpty, - onMoveTabToPane, onResizeSplit, onReorderTabsInPane, renderPaneEmptyState, @@ -665,6 +706,21 @@ function SplitNodeView({ dropPreview, tabDropPreview, }: SplitNodeViewProps) { + const groupId = node.kind === "group" ? node.group.id : null; + const groupDirection = node.kind === "group" ? node.group.direction : null; + + const storedGroupSizes = useWorkspaceLayoutStore((state) => + groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined, + ); + + const groupStyle = useMemo( + () => [ + styles.group, + groupDirection === "horizontal" ? styles.groupHorizontal : styles.groupVertical, + ], + [groupDirection], + ); + if (node.kind === "pane") { return ( state.splitSizesByWorkspace[workspaceKey]?.[node.group.id], - ) ?? node.group.sizes; + const groupSizes = storedGroupSizes ?? node.group.sizes; return ( - + {node.group.children.map((child, index) => ( - + - + {index < node.group.children.length - 1 ? ( (null); const stableOnFocusPane = useStableEvent(onFocusPane); const padding = useWindowControlsPadding("tabRow"); @@ -873,9 +920,44 @@ function SplitPaneView({ }; }, [stableOnFocusPane, pane.id]); + const paneId = pane.id; + const handleCloseTabsToLeft = useCallback( + (tabId: string) => onCloseTabsToLeft(tabId, paneTabs), + [onCloseTabsToLeft, paneTabs], + ); + const handleCloseTabsToRight = useCallback( + (tabId: string) => onCloseTabsToRight(tabId, paneTabs), + [onCloseTabsToRight, paneTabs], + ); + const handleCloseOtherTabs = useCallback( + (tabId: string) => onCloseOtherTabs(tabId, paneTabs), + [onCloseOtherTabs, paneTabs], + ); + const handleReorderTabs = useCallback( + (nextTabs: WorkspaceTabDescriptor[]) => { + onReorderTabsInPane( + paneId, + nextTabs.map((tab) => tab.tabId), + ); + }, + [onReorderTabsInPane, paneId], + ); + const handleSplitRight = useCallback( + () => onSplitPaneEmpty({ targetPaneId: paneId, position: "right" }), + [onSplitPaneEmpty, paneId], + ); + const handleSplitDown = useCallback( + () => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }), + [onSplitPaneEmpty, paneId], + ); + const paneTabsStyle = useMemo( + () => [styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }], + [padding.left, padding.right], + ); + return ( - + onCloseTabsToLeft(tabId, paneTabs)} - onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} - onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} + onCloseTabsToLeft={handleCloseTabsToLeft} + onCloseTabsToRight={handleCloseTabsToRight} + onCloseOtherTabs={handleCloseOtherTabs} onCreateDraftTab={onCreateDraftTab} onCreateTerminalTab={onCreateTerminalTab} - onReorderTabs={(nextTabs) => { - onReorderTabsInPane( - pane.id, - nextTabs.map((tab) => tab.tabId), - ); - }} - onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })} - onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })} + onReorderTabs={handleReorderTabs} + onSplitRight={handleSplitRight} + onSplitDown={handleSplitDown} externalDndContext activeDragTabId={activeDragTabId} tabDropPreviewIndex={ diff --git a/packages/app/src/components/split-drop-zone.tsx b/packages/app/src/components/split-drop-zone.tsx index 7963403dc..b761f6b93 100644 --- a/packages/app/src/components/split-drop-zone.tsx +++ b/packages/app/src/components/split-drop-zone.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, type Ref } from "react"; import { useDroppable } from "@dnd-kit/core"; import { View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -62,7 +62,7 @@ export function SplitDropZone({ paneId, active, preview }: SplitDropZoneProps) { } return ( - + } style={styles.overlay} pointerEvents="none"> {previewStyles ? ( <> diff --git a/packages/app/src/components/stream-strategy-native.tsx b/packages/app/src/components/stream-strategy-native.tsx index afb651310..bbc7d5bb4 100644 --- a/packages/app/src/components/stream-strategy-native.tsx +++ b/packages/app/src/components/stream-strategy-native.tsx @@ -1,4 +1,12 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + type ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { FlatList, Keyboard, @@ -22,6 +30,10 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ autoscrollToTopThreshold: 0, }); +function keyExtractor(item: { id: string }): string { + return item.id; +} + function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) { const { agentId, @@ -267,10 +279,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); }); - const renderItem = useStableEvent(({ item, index }: ListRenderItemInfo) => { - const rendered = renderHistoryMountedRow(item, index, historyRows); - return rendered ? {rendered} : null; - }); + const renderItem = useStableEvent( + ({ item, index }: ListRenderItemInfo): ReactElement | null => { + const rendered = renderHistoryMountedRow(item, index, historyRows); + return (rendered ?? null) as ReactElement | null; + }, + ); const liveHeaderContent = useMemo(() => { const liveHeadRows = segments.liveHead.map((item, index) => ( @@ -283,7 +297,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory ) { - return listEmptyComponent ? {listEmptyComponent} : null; + return (listEmptyComponent ?? null) as ReactElement | null; } return ( @@ -298,7 +312,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ref={flatListRef} data={historyRows} renderItem={renderItem} - keyExtractor={(item) => item.id} + keyExtractor={keyExtractor} testID="agent-chat-scroll" nativeID="agent-chat-scroll-native-virtualized" ListHeaderComponent={liveHeaderContent ?? undefined} diff --git a/packages/app/src/components/stream-strategy-web.test.tsx b/packages/app/src/components/stream-strategy-web.test.tsx index 3ccd1a82a..1b49e9c8a 100644 --- a/packages/app/src/components/stream-strategy-web.test.tsx +++ b/packages/app/src/components/stream-strategy-web.test.tsx @@ -18,11 +18,13 @@ function userMessage(index: number): StreamItem { }; } +const VIRTUAL_ROW_STYLE = { height: 24 }; + function createRenderers(onRowRender: () => void): StreamSegmentRenderers { return { renderHistoryVirtualizedRow: (item) => { onRowRender(); - return
{item.id}
; + return
{item.id}
; }, renderHistoryMountedRow: (item) =>
{item.id}
, renderLiveHeadRow: (item) =>
{item.id}
, diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 3c228833a..e5e4feeb4 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -13,9 +13,9 @@ import { estimateStreamItemHeight } from "./agent-stream-web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy"; import { createStreamStrategy } from "./stream-strategy"; -type CreateWebStreamStrategyInput = { +interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; -}; +} type ScrollBehaviorLike = "auto" | "smooth"; @@ -107,6 +107,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } = props; const scrollContainerRef = useRef(null); const contentRef = useRef(null); + const handleScrollContainerRef = useCallback((node: HTMLElement | null) => { + scrollContainerRef.current = node; + }, []); + const handleContentRef = useCallback((node: HTMLElement | null) => { + contentRef.current = node; + }, []); const [followOutput, setFollowOutputr] = useState(true); const setFollowOutput = (value: boolean) => { setFollowOutputr(value); @@ -178,7 +184,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; }; - }, [rowVirtualizer]); + }, [rowVirtualizer, props.agentId]); const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); @@ -361,7 +367,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool : null, }); updateScrollMetrics(); - }, [cancelPendingStickToBottom, updateScrollMetrics]); + }, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]); useLayoutEffect(() => { if (!isActivationReady) { @@ -705,19 +711,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return ( <>
{ - scrollContainerRef.current = node; - }} + ref={handleScrollContainerRef} data-testid="agent-chat-scroll" id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`} style={scrollContainerStyle} > -
{ - contentRef.current = node; - }} - style={contentContainerStyle} - > +
{shouldUseVirtualizer ? (
{virtualRows.map((virtualRow) => { @@ -744,7 +743,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ) : null} {mountedHistoryRows} {boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? ( -
+ ) : null} {liveHeadRows} {liveAuxiliary} @@ -790,3 +789,12 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight), }); } + +interface HistoryToHeadSpacerProps { + height: number; +} + +function HistoryToHeadSpacer({ height }: HistoryToHeadSpacerProps) { + const spacerStyle = useMemo(() => ({ height, width: "100%" as const }), [height]); + return
; +} diff --git a/packages/app/src/components/stream-strategy.ts b/packages/app/src/components/stream-strategy.ts index c7f3f013b..6d7cd681b 100644 --- a/packages/app/src/components/stream-strategy.ts +++ b/packages/app/src/components/stream-strategy.ts @@ -21,36 +21,36 @@ export type BottomAnchorTransportBehavior = Readonly<{ verificationRetryMode: "rescroll" | "recheck"; }>; -export type StreamViewportMetrics = { +export interface StreamViewportMetrics { contentHeight: number; viewportHeight: number; -}; +} export type StreamNearBottomInput = StreamViewportMetrics & { offsetY: number; threshold: number; }; -export type StreamEdgeSlotProps = { - ListHeaderComponent?: ReactElement | ComponentType | null; +export interface StreamEdgeSlotProps { + ListHeaderComponent?: ReactElement | ComponentType | null; ListHeaderComponentStyle?: StyleProp; - ListFooterComponent?: ReactElement | ComponentType | null; + ListFooterComponent?: ReactElement | ComponentType | null; ListFooterComponentStyle?: StyleProp; -}; +} -export type StreamViewportHandle = { +export interface StreamViewportHandle { scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; -}; +} -export type StreamSegmentRenderers = { +export interface StreamSegmentRenderers { renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; renderLiveAuxiliary: () => ReactNode; -}; +} -export type StreamRenderInput = { +export interface StreamRenderInput { agentId: string; segments: StreamRenderSegments; boundary: StreamHistoryBoundary; @@ -64,12 +64,12 @@ export type StreamRenderInput = { listStyle: StyleProp; baseListContentContainerStyle: StyleProp; forwardListContentContainerStyle: StyleProp; -}; +} -export type ResolveStreamRenderStrategyInput = { +export interface ResolveStreamRenderStrategyInput { platform: string; isMobileBreakpoint: boolean; -}; +} export interface StreamStrategy { render: (input: StreamRenderInput) => ReactNode; @@ -85,7 +85,7 @@ export interface StreamStrategy { isNearBottom: (input: StreamNearBottomInput) => boolean; getBottomOffset: (metrics: StreamViewportMetrics) => number; getEdgeSlotProps: ( - component: ReactElement | ComponentType | null, + component: ReactElement | ComponentType | null, gapSize: number, ) => StreamEdgeSlotProps; getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined; @@ -98,7 +98,7 @@ export interface StreamStrategy { shouldUseVirtualizedList: () => boolean; } -type StreamStrategyConfig = { +interface StreamStrategyConfig { render: StreamStrategy["render"]; orderTailReverse: boolean; orderHeadReverse: boolean; @@ -114,7 +114,7 @@ type StreamStrategyConfig = { useVirtualizedList: boolean; isNearBottom: (input: StreamNearBottomInput) => boolean; getBottomOffset: (metrics: StreamViewportMetrics) => number; -}; +} const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4; @@ -262,7 +262,7 @@ export function getBottomOffsetForStreamRenderStrategy( export function getStreamEdgeSlotProps(params: { strategy: StreamStrategy; - component: ReactElement | ComponentType | null; + component: ReactElement | ComponentType | null; gapSize: number; }): StreamEdgeSlotProps { return params.strategy.getEdgeSlotProps(params.component, params.gapSize); diff --git a/packages/app/src/components/synced-loader.tsx b/packages/app/src/components/synced-loader.tsx index d0257de4d..775594f91 100644 --- a/packages/app/src/components/synced-loader.tsx +++ b/packages/app/src/components/synced-loader.tsx @@ -7,16 +7,16 @@ import Animated, { withRepeat, withTiming, } from "react-native-reanimated"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; const SYNCED_LOADER_DURATION_MS = 950; const SYNCED_LOADER_EPOCH_MS = 0; const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const; const DOT_COUNT = DOT_SEQUENCE.length; -const GRID_ROWS = 3; const GRID_COLUMNS = 2; const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const; const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const; +const DOT_KEYS = Array.from({ length: DOT_COUNT }, (_, i) => `dot-${i}`); const sharedStepProgress = makeMutable(0); let sharedLoopStarted = false; @@ -66,24 +66,25 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin const gridWidth = dotSize * 2 + gap; const gridHeight = dotSize * 3 + gap * 2; - return ( - [animatedStyle, { width: gridWidth, height: gridHeight }], + [animatedStyle, gridWidth, gridHeight], + ); + + const containerStyle = useMemo( + () => + ({ width: size, height: size, alignItems: "center", justifyContent: "center", - }} - > - + }) as const, + [size], + ); + + return ( + + {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => { const rowIndex = Math.floor(dotIndex / GRID_COLUMNS); const columnIndex = dotIndex % GRID_COLUMNS; @@ -91,16 +92,13 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin return ( ); })} @@ -114,17 +112,15 @@ function SpinnerDot({ dotSize, sequenceIndex, progress, - style, + left, + top, }: { color: string; dotSize: number; sequenceIndex: number; progress: SharedValue; - style: { - position: "absolute"; - left: number; - top: number; - }; + left: number; + top: number; }) { const animatedStyle = useAnimatedStyle(() => { const headIndex = Math.floor(progress.value) % DOT_COUNT; @@ -144,18 +140,21 @@ function SpinnerDot({ }; }); - return ( - + const dotStyle = useMemo( + () => [ + animatedStyle, + { + width: dotSize, + height: dotSize, + borderRadius: dotSize / 2, + backgroundColor: color, + position: "absolute" as const, + left, + top, + }, + ], + [animatedStyle, dotSize, color, left, top], ); + + return ; } diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index de14ccf9c..2feeadd4b 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -1,6 +1,16 @@ "use dom"; -import { useEffect, useMemo, useRef, useState, type Ref } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type Ref, +} from "react"; import type { DOMProps } from "expo/dom"; import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; @@ -35,11 +45,38 @@ const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200; const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110; const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style"; -type ViewportMetrics = { +const HOST_DIV_STYLE: CSSProperties = { + flex: 1, + minHeight: 0, + minWidth: 0, + width: "100%", + height: "100%", + overflow: "hidden", + overscrollBehavior: "none", + paddingTop: 0, + paddingBottom: 0, + paddingLeft: 0, + paddingRight: 0, +}; + +const SCROLLBAR_CONTAINER_STYLE: CSSProperties = { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + width: 12, + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + zIndex: 10, + pointerEvents: "none", +}; + +interface ViewportMetrics { offset: number; viewportSize: number; contentSize: number; -}; +} function buildXtermThemeKey(theme: ITheme): string { const values: Array = [ @@ -159,6 +196,24 @@ export default function TerminalEmulator({ const scrollActiveTimeoutRef = useRef | null>(null); const lastObservedOffsetRef = useRef(null); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); + const xtermThemeRef = useRef(xtermTheme); + xtermThemeRef.current = xtermTheme; + const mountCallbacksRef = useRef({ + onInput, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + }); + mountCallbacksRef.current = { + onInput, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + }; + const initialSnapshotRef = useRef(initialSnapshot); + initialSnapshotRef.current = initialSnapshot; + const pendingModifiersRef = useRef(pendingModifiers); + pendingModifiersRef.current = pendingModifiers; const [viewportMetrics, setViewportMetrics] = useState({ offset: 0, viewportSize: 0, @@ -187,8 +242,9 @@ export default function TerminalEmulator({ ); useEffect(() => { - mountedThemeRef.current = xtermTheme; - runtimeRef.current?.setTheme({ theme: xtermTheme }); + const nextTheme = xtermThemeRef.current; + mountedThemeRef.current = nextTheme; + runtimeRef.current?.setTheme({ theme: nextTheme }); }, [themeKey]); useEffect(() => { @@ -322,18 +378,15 @@ export default function TerminalEmulator({ runtimeRef.current = runtime; runtime.setCallbacks({ callbacks: { - onInput, - onResize, - onTerminalKey, - onPendingModifiersConsumed, + ...mountCallbacksRef.current, onOpenExternalUrl: openExternalUrl, }, }); - runtime.setPendingModifiers({ pendingModifiers }); + runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current }); runtime.mount({ root, host, - initialSnapshot, + initialSnapshot: initialSnapshotRef.current, theme: mountedThemeRef.current, }); @@ -546,13 +599,11 @@ export default function TerminalEmulator({ const handleVisible = scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered); - const handleOpacity = isDraggingScrollbar - ? SCROLLBAR_HANDLE_OPACITY_DRAGGING - : isHandleHovered - ? SCROLLBAR_HANDLE_OPACITY_HOVERED - : isScrollVisible - ? SCROLLBAR_HANDLE_OPACITY_VISIBLE - : 0; + let handleOpacity: number; + if (isDraggingScrollbar) handleOpacity = SCROLLBAR_HANDLE_OPACITY_DRAGGING; + else if (isHandleHovered) handleOpacity = SCROLLBAR_HANDLE_OPACITY_HOVERED; + else if (isScrollVisible) handleOpacity = SCROLLBAR_HANDLE_OPACITY_VISIBLE; + else handleOpacity = 0; const handleWidth = isDraggingScrollbar || isHandleHovered ? SCROLLBAR_HANDLE_WIDTH_ACTIVE @@ -568,7 +619,7 @@ export default function TerminalEmulator({ const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2); const handleTravelDurationMs = isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS; - const handleContextMenu = () => { + const showTerminalContextMenu = useCallback(() => { const showContextMenu = window.paseoDesktop?.menu?.showContextMenu; if (typeof showContextMenu !== "function") { return; @@ -579,115 +630,117 @@ export default function TerminalEmulator({ kind: "terminal", hasSelection, }); - }; + }, []); + + const handleRootPointerDown = useCallback(() => { + runtimeRef.current?.focus(); + }, []); + + const handleRootContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + showTerminalContextMenu(); + }, + [showTerminalContextMenu], + ); + + const scrollbarMaxOffset = scrollbarGeometry.maxScrollOffset; + const handleScrollbarPointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragStartOffsetRef.current = clamp(viewportMetrics.offset, 0, scrollbarMaxOffset); + dragStartClientYRef.current = event.clientY; + setIsDraggingScrollbar(true); + }, + [scrollbarMaxOffset, viewportMetrics.offset], + ); + + const handleScrollbarPointerEnter = useCallback(() => { + if (!isScrollVisible && !isDraggingScrollbar) { + return; + } + setIsHandleHovered(true); + }, [isScrollVisible, isDraggingScrollbar]); + + const handleScrollbarPointerLeave = useCallback(() => { + setIsHandleHovered(false); + }, []); + + const rootDivStyle = useMemo( + () => ({ + position: "relative", + display: "flex", + width: "100%", + height: "100%", + minHeight: 0, + minWidth: 0, + backgroundColor: xtermTheme.background ?? "#0b0b0b", + overflow: "hidden", + overscrollBehavior: "none", + touchAction: "pan-y", + }), + [xtermTheme.background], + ); + const handleContainerStyle = useMemo( + () => ({ + position: "absolute", + top: 0, + right: -3, + width: SCROLLBAR_HANDLE_GRAB_WIDTH, + height: thumbRegionHeight, + transform: `translateY(${thumbRegionOffset}px)`, + cursor: isDraggingScrollbar ? "grabbing" : "grab", + touchAction: "none", + userSelect: "none", + transitionProperty: "transform", + transitionDuration: `${handleTravelDurationMs}ms`, + transitionTimingFunction: "linear", + pointerEvents: handleVisible ? "auto" : "none", + }), + [ + thumbRegionHeight, + thumbRegionOffset, + isDraggingScrollbar, + handleTravelDurationMs, + handleVisible, + ], + ); + const handleInnerStyle = useMemo( + () => ({ + marginTop: handleInsetTop, + height: scrollbarGeometry.handleSize, + width: handleWidth, + borderRadius: 999, + alignSelf: "center", + backgroundColor: "rgba(113, 113, 122, 1)", + opacity: handleOpacity, + transitionProperty: "opacity, width, background-color", + transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`, + transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", + }), + [handleInsetTop, scrollbarGeometry.handleSize, handleWidth, handleOpacity], + ); return (
{ - runtimeRef.current?.focus(); - }} - onContextMenu={(event) => { - event.preventDefault(); - handleContextMenu(); - }} + style={rootDivStyle} + onPointerDown={handleRootPointerDown} + onContextMenu={handleRootContextMenu} > -
+
{scrollbarGeometry.isVisible ? ( -
+
{ - event.preventDefault(); - event.stopPropagation(); - dragStartOffsetRef.current = clamp( - viewportMetrics.offset, - 0, - scrollbarGeometry.maxScrollOffset, - ); - dragStartClientYRef.current = event.clientY; - setIsDraggingScrollbar(true); - }} - onPointerEnter={() => { - if (!isScrollVisible && !isDraggingScrollbar) { - return; - } - setIsHandleHovered(true); - }} - onPointerLeave={() => { - setIsHandleHovered(false); - }} + style={handleContainerStyle} + onPointerDown={handleScrollbarPointerDown} + onPointerEnter={handleScrollbarPointerEnter} + onPointerLeave={handleScrollbarPointerLeave} > -
+
) : null} diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 61794a5fa..fe6920a28 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { + ActivityIndicator, + Pressable, + ScrollView, + Text, + View, + type PressableStateCallbackType, +} from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; @@ -51,11 +58,11 @@ const KEY_BUTTONS: Array<{ id: string; label: string; key: string }> = [ { id: "c", label: "C", key: "c" }, ]; -type ModifierState = { +interface ModifierState { ctrl: boolean; shift: boolean; alt: boolean; -}; +} type PendingTerminalInput = | { @@ -83,6 +90,56 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } +interface ModifierButtonProps { + modifier: keyof ModifierState; + active: boolean; + onToggle: (modifier: keyof ModifierState) => void; +} + +function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) { + const handlePress = useCallback(() => onToggle(modifier), [onToggle, modifier]); + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.keyButton, + active && styles.keyButtonActive, + (Boolean(hovered) || pressed) && styles.keyButtonHovered, + ], + [active], + ); + const textStyle = useMemo( + () => [styles.keyButtonText, active && styles.keyButtonTextActive], + [active], + ); + return ( + + {MODIFIER_LABELS[modifier]} + + ); +} + +interface VirtualKeyButtonProps { + id: string; + label: string; + keyValue: string; + onSend: (key: string) => void; +} + +function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps) { + const handlePress = useCallback(() => onSend(keyValue), [onSend, keyValue]); + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.keyButton, + (Boolean(hovered) || pressed) && styles.keyButtonHovered, + ], + [], + ); + return ( + + {label} + + ); +} + export function TerminalPane({ serverId, cwd, @@ -260,15 +317,15 @@ export function TerminalPane({ const controller = new TerminalStreamController({ client, getPreferredSize: () => lastReportedSizeRef.current, - onOutput: ({ terminalId, text }) => { - if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) { + onOutput: ({ terminalId: outputTerminalId, text }) => { + if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) { return; } emulatorRef.current?.writeOutput(text); }, - onSnapshot: ({ terminalId, state }) => { - workspaceTerminalSession.snapshots.set({ terminalId, state }); - if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) { + onSnapshot: ({ terminalId: snapshotTerminalId, state }) => { + workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); + if (!isWorkspaceFocused || terminalIdRef.current !== snapshotTerminalId) { return; } emulatorRef.current?.renderSnapshot(state); @@ -467,9 +524,7 @@ export function TerminalPane({ clearPendingModifiers, client, dispatchTerminalInputEntry, - modifiers.alt, - modifiers.ctrl, - modifiers.shift, + modifiers, sendTerminalKey, enqueuePendingTerminalInput, ], @@ -543,6 +598,21 @@ export function TerminalPane({ ], ); + const containerStyle = useMemo( + () => [styles.container, keyboardPaddingStyle], + [keyboardPaddingStyle], + ); + + const handleSwipeRight = useCallback(() => { + if (!swipeGesturesEnabled) return; + showMobileAgentList(); + }, [swipeGesturesEnabled, showMobileAgentList]); + + const handleSwipeLeft = useCallback(() => { + if (!swipeGesturesEnabled) return; + onOpenFileExplorer(); + }, [swipeGesturesEnabled, onOpenFileExplorer]); + if (!client || !isConnected) { return ( @@ -552,39 +622,20 @@ export function TerminalPane({ } return ( - + {isWorkspaceFocused ? ( { - if (!swipeGesturesEnabled) { - return; - } - showMobileAgentList(); - }} - onSwipeLeft={() => { - if (!swipeGesturesEnabled) { - return; - } - onOpenFileExplorer(); - }} + onSwipeRight={handleSwipeRight} + onSwipeLeft={handleSwipeLeft} onInput={handleTerminalData} onResize={handleTerminalResize} onTerminalKey={handleTerminalKey} @@ -618,39 +669,22 @@ export function TerminalPane({ {(Object.keys(MODIFIER_LABELS) as Array).map((modifier) => ( - toggleModifier(modifier)} - style={({ hovered, pressed }) => [ - styles.keyButton, - modifiers[modifier] && styles.keyButtonActive, - (hovered || pressed) && styles.keyButtonHovered, - ]} - > - - {MODIFIER_LABELS[modifier]} - - + modifier={modifier} + active={modifiers[modifier]} + onToggle={toggleModifier} + /> ))} {KEY_BUTTONS.map((button) => ( - sendVirtualKey(button.key)} - style={({ hovered, pressed }) => [ - styles.keyButton, - (hovered || pressed) && styles.keyButtonHovered, - ]} - > - {button.label} - + id={button.id} + label={button.label} + keyValue={button.key} + onSend={sendVirtualKey} + /> ))} @@ -744,3 +778,14 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", }, })); + +const TERMINAL_EMULATOR_DOM_PROPS = { + style: { flex: 1 }, + matchContents: false, + scrollEnabled: true, + nestedScrollEnabled: true, + overScrollMode: "never" as const, + bounces: false, + automaticallyAdjustContentInsets: false, + contentInsetAdjustmentBehavior: "never" as const, +}; diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index f2f23b235..7612bb216 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -15,15 +15,15 @@ import { export type ToastVariant = "default" | "success" | "error"; -export type ToastShowOptions = { +export interface ToastShowOptions { icon?: ReactNode; variant?: ToastVariant; durationMs?: number; nativeAndroid?: boolean; testID?: string; -}; +} -export type ToastState = { +export interface ToastState { id: number; content: ReactNode; nativeMessage: string | null; @@ -31,13 +31,13 @@ export type ToastState = { variant: ToastVariant; durationMs: number; testID?: string; -}; +} -export type ToastApi = { +export interface ToastApi { show: (content: ReactNode, options?: ToastShowOptions) => void; copied: (label?: string) => void; error: (message: string) => void; -}; +} type ToastViewportPlacement = "app-shell" | "panel"; @@ -212,10 +212,6 @@ export function ToastViewport({ }; }, [clearTimer, opacity, scheduleDismiss, toast, translateY]); - if (!toast) { - return null; - } - const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topOffset = @@ -223,13 +219,36 @@ export function ToastViewport({ ? insets.top + headerTopPadding + headerHeight + theme.spacing[2] : theme.spacing[3]; - const icon = - toast.icon ?? - (toast.variant === "success" ? ( - - ) : toast.variant === "error" ? ( - - ) : null); + const toastVariant = toast?.variant; + const toastAnimatedStyle = useMemo( + () => [ + styles.toast, + toastVariant === "success" ? styles.toastSuccess : null, + toastVariant === "error" ? styles.toastError : null, + { + marginTop: topOffset, + opacity, + transform: [{ translateY }], + }, + ], + [toastVariant, topOffset, opacity, translateY], + ); + const toastMessageStyle = useMemo( + () => [styles.message, toastVariant === "error" ? styles.messageError : null], + [toastVariant], + ); + + if (!toast) { + return null; + } + + let defaultIcon: ReactNode = null; + if (toast.variant === "success") { + defaultIcon = ; + } else if (toast.variant === "error") { + defaultIcon = ; + } + const icon = toast.icon ?? defaultIcon; const content = ( @@ -237,24 +256,12 @@ export function ToastViewport({ testID={toast.testID ?? "app-toast"} onPointerEnter={isWeb ? pauseDismiss : undefined} onPointerLeave={isWeb ? resumeDismiss : undefined} - style={[ - styles.toast, - toast.variant === "success" ? styles.toastSuccess : null, - toast.variant === "error" ? styles.toastError : null, - { - marginTop: topOffset, - opacity, - transform: [{ translateY }], - }, - ]} + style={toastAnimatedStyle} accessibilityRole="alert" > {icon ? {icon} : null} {typeof toast.content === "string" ? ( - + {toast.content} ) : ( diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index fbc5614ee..f66282dfe 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,10 +1,16 @@ -import React, { useMemo, ReactNode } from "react"; -import { View, Text, ScrollView as RNScrollView } from "react-native"; +import React, { useMemo, type ReactNode } from "react"; +import { + View, + Text, + ScrollView as RNScrollView, + type StyleProp, + type ViewStyle, +} from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; -import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers"; +import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { DiffViewer } from "./diff-viewer"; @@ -23,297 +29,373 @@ interface ToolCallDetailsContentProps { showLoadingSkeleton?: boolean; } -export function ToolCallDetailsContent({ - detail, - errorText, - maxHeight, - fillAvailableHeight = false, - showLoadingSkeleton = false, -}: ToolCallDetailsContentProps) { - const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300); - const webScrollbarStyle = useWebScrollbarStyle(); +interface DetailStyles { + sectionFillStyle: StyleProp; + codeBlockFillStyle: StyleProp; + codeVerticalScrollStyle: StyleProp; + scrollAreaFillStyle: StyleProp; + scrollAreaStyle: StyleProp; + jsonScrollCombined: StyleProp; + jsonScrollErrorCombined: StyleProp; + fullBleedContainerStyle: StyleProp; + loadingContainerStyle: StyleProp; + webScrollbarStyle: StyleProp; + resolvedMaxHeight: number | undefined; + shouldFill: boolean; + isFullBleed: boolean; +} - // Compute diff lines for edit type - const diffLines = useMemo(() => { +function resolveIsFullBleed(detail: ToolCallDetail | undefined): boolean { + return detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write"; +} + +function resolveShouldFill( + detail: ToolCallDetail | undefined, + fillAvailableHeight: boolean, +): boolean { + if (!fillAvailableHeight) return false; + const t = detail?.type; + return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent"; +} + +function useDetailStyles( + detail: ToolCallDetail | undefined, + resolvedMaxHeight: number | undefined, + fillAvailableHeight: boolean, +): DetailStyles { + const webScrollbarStyle = useWebScrollbarStyle(); + const isFullBleed = resolveIsFullBleed(detail); + const shouldFill = resolveShouldFill(detail, fillAvailableHeight); + const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; + + const sectionFillStyle = useMemo( + () => [styles.section, shouldFill && styles.fillHeight], + [shouldFill], + ); + const codeBlockFillStyle = useMemo( + () => [codeBlockStyle, shouldFill && styles.fillHeight], + [codeBlockStyle, shouldFill], + ); + const codeVerticalScrollStyle = useMemo( + () => [ + styles.codeVerticalScroll, + resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + shouldFill && styles.fillHeight, + webScrollbarStyle, + ], + [resolvedMaxHeight, shouldFill, webScrollbarStyle], + ); + const scrollAreaFillStyle = useMemo( + () => [ + styles.scrollArea, + resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + shouldFill && styles.fillHeight, + webScrollbarStyle, + ], + [resolvedMaxHeight, shouldFill, webScrollbarStyle], + ); + const scrollAreaStyle = useMemo( + () => [ + styles.scrollArea, + resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + webScrollbarStyle, + ], + [resolvedMaxHeight, webScrollbarStyle], + ); + const jsonScrollCombined = useMemo( + () => [styles.jsonScroll, webScrollbarStyle], + [webScrollbarStyle], + ); + const jsonScrollErrorCombined = useMemo( + () => [styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle], + [webScrollbarStyle], + ); + const fullBleedContainerStyle = useMemo( + () => [ + isFullBleed ? styles.fullBleedContainer : styles.paddedContainer, + shouldFill && styles.fillHeight, + ], + [isFullBleed, shouldFill], + ); + const loadingContainerStyle = useMemo( + () => [styles.loadingContainer, fillAvailableHeight && styles.fillHeight], + [fillAvailableHeight], + ); + + return { + sectionFillStyle, + codeBlockFillStyle, + codeVerticalScrollStyle, + scrollAreaFillStyle, + scrollAreaStyle, + jsonScrollCombined, + jsonScrollErrorCombined, + fullBleedContainerStyle, + loadingContainerStyle, + webScrollbarStyle, + resolvedMaxHeight, + shouldFill, + isFullBleed, + }; +} + +function useDiffLines(detail: ToolCallDetail | undefined): DiffLine[] | undefined { + return useMemo(() => { if (!detail || detail.type !== "edit") return undefined; - // Use pre-computed unified diff if available (e.g., from apply_patch) if (detail.unifiedDiff) { return parseUnifiedDiff(detail.unifiedDiff); } return buildLineDiff(detail.oldString ?? "", detail.newString ?? ""); }, [detail]); +} - const sections: ReactNode[] = []; - const isFullBleed = - detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write"; - const shouldFill = - fillAvailableHeight && - (detail?.type === "shell" || - detail?.type === "edit" || - detail?.type === "write" || - detail?.type === "read" || - detail?.type === "sub_agent"); - const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; +interface ShellDetailProps { + command: string; + output: string | null | undefined; + ds: DetailStyles; +} - if (detail?.type === "shell") { - const command = detail.command.replace(/\n+$/, ""); - const commandOutput = (detail.output ?? "").replace(/^\n+/, ""); - const hasOutput = commandOutput.length > 0; - sections.push( - - - - - - - $ - {command} - {hasOutput ? `\n\n${commandOutput}` : ""} - - - - - - , - ); - } else if (detail?.type === "worktree_setup") { - const setupLog = detail.log.replace(/^\n+/, ""); - const hasLog = setupLog.length > 0; - sections.push( - - - - - - - {hasLog - ? setupLog - : `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`} - - - - - - , - ); - } else if (detail?.type === "sub_agent") { - const activityLog = detail.log.replace(/^\n+/, ""); - const hasLog = activityLog.length > 0; - const fallbackHeader = - detail.subAgentType && detail.description - ? `${detail.subAgentType}: ${detail.description}` - : (detail.subAgentType ?? detail.description ?? "Sub-agent activity"); - sections.push( - - - - - - - {hasLog ? activityLog : fallbackHeader} - - - - - - , - ); - } else if (detail?.type === "edit") { - sections.push( - - {diffLines ? ( - - - - ) : null} - , - ); - } else if (detail?.type === "write") { - sections.push( - - {detail.content ? ( - - - - {detail.content} - - - - ) : null} - , - ); - } else if (detail?.type === "read") { - if (detail.content) { - sections.push( - - - - - {detail.content} - - - - , - ); - } - } else if (detail?.type === "search") { - const searchSections: ReactNode[] = []; - if (detail.query) { - searchSections.push( - - - {detail.query} - - , - ); - } - if (detail.content) { - searchSections.push( - - - - - {detail.content} - - - - , - ); - } - if (detail.filePaths && detail.filePaths.length > 0) { - searchSections.push( - - - {detail.filePaths.join("\n")} - - , - ); - } - if (detail.webResults && detail.webResults.length > 0) { - searchSections.push( - - - {detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")} - - , - ); - } - if (detail.annotations && detail.annotations.length > 0) { - searchSections.push( - - - {detail.annotations.join("\n\n")} - - , - ); - } - sections.push(...searchSections); - } else if (detail?.type === "fetch") { - sections.push( - +function ShellDetailSection({ command, output, ds }: ShellDetailProps) { + const normalizedCommand = command.replace(/\n+$/, ""); + const commandOutput = (output ?? "").replace(/^\n+/, ""); + const hasOutput = commandOutput.length > 0; + return ( + + + + + + $ + {normalizedCommand} + {hasOutput ? `\n\n${commandOutput}` : ""} + + + + + + + ); +} + +interface WorktreeSetupDetailProps { + log: string; + branchName: string; + worktreePath: string; + ds: DetailStyles; +} + +function WorktreeSetupDetailSection({ + log, + branchName, + worktreePath, + ds, +}: WorktreeSetupDetailProps) { + const setupLog = log.replace(/^\n+/, ""); + const hasLog = setupLog.length > 0; + return ( + + + + + + + {hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`} + + + + + + + ); +} + +function resolveSubAgentFallbackHeader( + subAgentType: string | null | undefined, + description: string | null | undefined, +): string { + if (subAgentType && description) { + return `${subAgentType}: ${description}`; + } + return subAgentType ?? description ?? "Sub-agent activity"; +} + +interface SubAgentDetailProps { + log: string; + subAgentType: string | null | undefined; + description: string | null | undefined; + ds: DetailStyles; +} + +function SubAgentDetailSection({ log, subAgentType, description, ds }: SubAgentDetailProps) { + const activityLog = log.replace(/^\n+/, ""); + const hasLog = activityLog.length > 0; + const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description); + return ( + + + + + + + {hasLog ? activityLog : fallbackHeader} + + + + + + + ); +} + +interface EditDetailProps { + diffLines: DiffLine[] | undefined; + ds: DetailStyles; +} + +function EditDetailSection({ diffLines, ds }: EditDetailProps) { + return ( + + {diffLines ? ( + + + + ) : null} + + ); +} + +interface ScrollableContentProps { + content: string; + ds: DetailStyles; + wrapInSectionFill?: boolean; +} + +function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: ScrollableContentProps) { + const body = ( + + + + {content} + + + + ); + if (!wrapInSectionFill) return body; + return {body}; +} + +interface FetchDetailProps { + url: string; + result: string | null | undefined; + ds: DetailStyles; +} + +function FetchDetailSection({ url, result, ds }: FetchDetailProps) { + return ( + + + + + {result ? `${url}\n\n${result}` : url} + + + + + ); +} + +function PlainTextSection({ text }: { text: string }) { + return ( + + + {text} + + + ); +} + +interface SearchDetail { + query?: string; + content?: string; + filePaths?: string[]; + webResults?: { title: string; url: string }[]; + annotations?: string[]; +} + +function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[] { + const out: ReactNode[] = []; + if (detail.query) { + out.push( + + + {detail.query} + + , + ); + } + if (detail.content) { + out.push( + + - {detail.result ? `${detail.url}\n\n${detail.result}` : detail.url} + {detail.content} , ); - } else if (detail?.type === "plain_text") { - if (detail.text) { - sections.push( - - - {detail.text} - - , - ); - } - } else if (detail?.type === "unknown") { - const plainInputText = - typeof detail.input === "string" && detail.output === null ? detail.input : null; + } + if (detail.filePaths && detail.filePaths.length > 0) { + out.push( + + + {detail.filePaths.join("\n")} + + , + ); + } + if (detail.webResults && detail.webResults.length > 0) { + out.push( + + + {detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")} + + , + ); + } + if (detail.annotations && detail.annotations.length > 0) { + out.push( + + + {detail.annotations.join("\n\n")} + + , + ); + } + return out; +} - if (plainInputText !== null) { - sections.push( - - - {plainInputText} - - , - ); - } else { - const sectionsFromTopLevel = [ - { title: "Input", value: detail.input }, - { title: "Output", value: detail.output }, - ].filter((entry) => - hasMeaningfulToolCallDetail({ - type: "unknown", - input: entry.value ?? null, - output: null, - }), - ); +function serializeUnknownValue(value: unknown): string { + try { + return typeof value === "string" ? value : JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} - for (const section of sectionsFromTopLevel) { - let value = ""; - try { - value = - typeof section.value === "string" - ? section.value - : JSON.stringify(section.value, null, 2); - } catch { - value = String(section.value); - } - if (!value.length) { - continue; - } - sections.push( - - {section.title} - , - ); - sections.push( - - - - {value} - - - , - ); - } - } +interface UnknownDetail { + input: unknown; + output: unknown; +} + +function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNode[] { + const plainInputText = + typeof detail.input === "string" && detail.output === null ? detail.input : null; + + if (plainInputText !== null) { + return [ + + + {plainInputText} + + , + ]; } - // Always show errors if available - if (errorText) { - sections.push( - - Error + const sectionsFromTopLevel = [ + { title: "Input", value: detail.input }, + { title: "Output", value: detail.output }, + ].filter((entry) => + hasMeaningfulToolCallDetail({ + type: "unknown", + input: entry.value ?? null, + output: null, + }), + ); + + const out: ReactNode[] = []; + for (const section of sectionsFromTopLevel) { + const value = serializeUnknownValue(section.value); + if (!value.length) { + continue; + } + out.push( + + {section.title} + , + ); + out.push( + - - {errorText} + + {value} , ); } + return out; +} + +function buildDetailSections( + detail: ToolCallDetail | undefined, + diffLines: DiffLine[] | undefined, + ds: DetailStyles, +): ReactNode[] { + if (!detail) return []; + if (detail.type === "shell") { + return [ + , + ]; + } + if (detail.type === "worktree_setup") { + return [ + , + ]; + } + if (detail.type === "sub_agent") { + return [ + , + ]; + } + if (detail.type === "edit") { + return []; + } + if (detail.type === "write") { + return [ + + {detail.content ? ( + + ) : null} + , + ]; + } + if (detail.type === "read") { + if (!detail.content) return []; + return []; + } + if (detail.type === "search") { + return buildSearchSections(detail, ds); + } + if (detail.type === "fetch") { + return []; + } + if (detail.type === "plain_text") { + if (!detail.text) return []; + return []; + } + if (detail.type === "unknown") { + return buildUnknownSections(detail, ds); + } + return []; +} + +function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) { + return ( + + Error + + + {errorText} + + + + ); +} + +function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp }) { + return ( + + + + + + ); +} + +export function ToolCallDetailsContent({ + detail, + errorText, + maxHeight, + fillAvailableHeight = false, + showLoadingSkeleton = false, +}: ToolCallDetailsContentProps) { + const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300); + const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight); + const diffLines = useDiffLines(detail); + + const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds); + + if (errorText) { + sections.push(); + } if (sections.length === 0) { if (showLoadingSkeleton) { - return ( - - - - - - ); + return ; } return No additional details available; } - return ( - - {sections} - - ); + return {sections}; } // ---- Styles ---- @@ -598,3 +784,6 @@ const styles = StyleSheet.create((theme) => { }, }; }); + +const SECTION_TITLE_ERROR_STYLE = [styles.sectionTitle, styles.errorText]; +const SCROLL_TEXT_ERROR_STYLE = [styles.scrollText, styles.errorText]; diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 372774358..131c7ffa6 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -18,14 +18,14 @@ import { ToolCallDetailsContent } from "./tool-call-details"; // ----- Types ----- -export type ToolCallSheetData = { +export interface ToolCallSheetData { toolName: string; displayName: string; summary?: string; detail?: ToolCallDetail; errorText?: string; showLoadingSkeleton?: boolean; -}; +} interface ToolCallSheetContextValue { openToolCall: (data: ToolCallSheetData) => void; @@ -102,6 +102,11 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) [openToolCall, closeToolCall], ); + const handleIndicatorStyle = useMemo( + () => ({ backgroundColor: theme.colors.palette.zinc[600] }), + [theme.colors.palette.zinc], + ); + return ( {children} @@ -114,7 +119,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) backdropComponent={renderBackdrop} enablePanDownToClose backgroundComponent={CustomSheetBackground} - handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} + handleIndicatorStyle={handleIndicatorStyle} > {sheetData && } diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 49f920148..96dc6f304 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -1,5 +1,14 @@ -import { useCallback, useEffect, useRef } from "react"; -import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + ScrollView, + Text, + View, + Pressable, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { File, Folder } from "lucide-react-native"; import type { Theme } from "@/styles/theme"; @@ -24,7 +33,7 @@ interface AutocompleteProps { maxHeight?: number; } -const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g; +const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu; function removeBoltGlyphs(value?: string): string | undefined { if (!value) { @@ -34,6 +43,79 @@ function removeBoltGlyphs(value?: string): string | undefined { return cleaned.length > 0 ? cleaned : undefined; } +interface AutocompleteRowProps { + index: number; + option: AutocompleteOption; + isSelected: boolean; + mutedColor: string; + onSelect: (option: AutocompleteOption) => void; + onRowLayout: (index: number, event: LayoutChangeEvent) => void; +} + +function AutocompleteRow({ + index, + option, + isSelected, + mutedColor, + onSelect, + onRowLayout, +}: AutocompleteRowProps) { + const optionLabel = removeBoltGlyphs(option.label) ?? option.label; + const optionDescription = removeBoltGlyphs(option.description); + const isFileOrDir = option.kind === "directory" || option.kind === "file"; + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => onRowLayout(index, event), + [index, onRowLayout], + ); + const handlePress = useCallback(() => onSelect(option), [onSelect, option]); + const pressableStyle = useCallback( + ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.item, + (Boolean(hovered) || pressed || isSelected) && styles.itemActive, + ], + [isSelected], + ); + + return ( + + {isFileOrDir ? ( + <> + + {option.kind === "directory" ? ( + + ) : ( + + )} + + + + {optionLabel} + {removeBoltGlyphs(option.detail) ? ( + {removeBoltGlyphs(option.detail)} + ) : null} + + {optionDescription ? ( + + {optionDescription} + + ) : null} + + + ) : ( + + {optionLabel} + {optionDescription ? ( + + {optionDescription} + + ) : null} + + )} + + ); +} + export function Autocomplete({ options, selectedIndex, @@ -109,6 +191,10 @@ export function Autocomplete({ [ensureActiveItemVisible], ); + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, []); + const handleRowLayout = useCallback( (index: number, event: LayoutChangeEvent) => { rowLayoutsRef.current.set(index, { @@ -120,9 +206,11 @@ export function Autocomplete({ [ensureActiveItemVisible], ); + const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]); + if (isLoading) { return ( - + {loadingText} @@ -132,7 +220,7 @@ export function Autocomplete({ if (errorMessage) { return ( - + Error: {errorMessage} @@ -142,7 +230,7 @@ export function Autocomplete({ if (options.length === 0) { return ( - + {emptyText} @@ -167,77 +255,35 @@ export function Autocomplete({ ) : null} ) : null} - + { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }} + onScroll={handleScroll} scrollEventThrottle={16} style={styles.scrollView} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="always" > - {options.map((option, index) => { - const isSelected = index === selectedIndex; - const optionLabel = removeBoltGlyphs(option.label) ?? option.label; - const optionDescription = removeBoltGlyphs(option.description); - const isFileOrDir = option.kind === "directory" || option.kind === "file"; - return ( - handleRowLayout(index, event)} - onPress={() => onSelect(option)} - style={({ hovered = false, pressed }) => [ - styles.item, - (hovered || pressed || isSelected) && styles.itemActive, - ]} - > - {isFileOrDir ? ( - <> - - {option.kind === "directory" ? ( - - ) : ( - - )} - - - - {optionLabel} - {removeBoltGlyphs(option.detail) ? ( - {removeBoltGlyphs(option.detail)} - ) : null} - - {optionDescription ? ( - - {optionDescription} - - ) : null} - - - ) : ( - - {optionLabel} - {optionDescription ? ( - - {optionDescription} - - ) : null} - - )} - - ); - })} + {options.map((option, index) => ( + + ))} ); } -const styles = StyleSheet.create(((theme: Theme) => ({ +const styles = StyleSheet.create((theme: Theme) => ({ outerWrapper: { gap: theme.spacing[1], }, @@ -339,4 +385,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, -})) as any) as Record; +})) as unknown as Record; diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 7da8dbfb5..5e232d36c 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,6 +1,19 @@ -import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react"; +import { + useCallback, + useMemo, + useState, + type ComponentType, + type PropsWithChildren, + type ReactElement, +} from "react"; import { Pressable, Text, View } from "react-native"; -import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native"; +import type { + PressableProps, + PressableStateCallbackType, + StyleProp, + TextStyle, + ViewStyle, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive"; @@ -105,28 +118,55 @@ export function Button({ const [hovered, setHovered] = useState(false); const { theme } = useUnistyles(); - const variantStyle = - variant === "default" - ? styles.default - : variant === "secondary" - ? styles.secondary - : variant === "outline" - ? styles.outline - : variant === "ghost" - ? styles.ghost - : styles.destructive; + let variantStyle: ViewStyle; + if (variant === "default") { + variantStyle = styles.default; + } else if (variant === "secondary") { + variantStyle = styles.secondary; + } else if (variant === "outline") { + variantStyle = styles.outline; + } else if (variant === "ghost") { + variantStyle = styles.ghost; + } else { + variantStyle = styles.destructive; + } - const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md; + let sizeStyle: ViewStyle; + if (size === "sm") { + sizeStyle = styles.sm; + } else if (size === "lg") { + sizeStyle = styles.lg; + } else { + sizeStyle = styles.md; + } const isGhostHovered = hovered && variant === "ghost"; - const resolvedTextStyle = [ - styles.text, - variant === "default" ? styles.textDefault : null, - variant === "destructive" ? styles.textDestructive : null, - variant === "ghost" ? styles.textGhost : null, - textStyle, - isGhostHovered ? styles.textGhostHovered : null, - ]; + const handleHoverIn = useCallback(() => setHovered(true), []); + const handleHoverOut = useCallback(() => setHovered(false), []); + + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType): StyleProp => [ + styles.base, + sizeStyle, + variantStyle, + pressed ? styles.pressed : null, + disabled ? styles.disabled : null, + style, + ], + [sizeStyle, variantStyle, disabled, style], + ); + + const resolvedTextStyle = useMemo( + () => [ + styles.text, + variant === "default" ? styles.textDefault : null, + variant === "destructive" ? styles.textDestructive : null, + variant === "ghost" ? styles.textGhost : null, + textStyle, + isGhostHovered ? styles.textGhostHovered : null, + ], + [variant, textStyle, isGhostHovered], + ); function renderIcon() { if (!leftIcon) return null; @@ -136,14 +176,14 @@ export function Button({ return {leftIcon}; } - const color = - variant === "default" - ? theme.colors.accentForeground - : variant === "ghost" - ? isGhostHovered - ? theme.colors.foreground - : theme.colors.foregroundMuted - : theme.colors.foreground; + let color: string; + if (variant === "default") { + color = theme.colors.accentForeground; + } else if (variant === "ghost") { + color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted; + } else { + color = theme.colors.foreground; + } const iconSize = ICON_SIZE[size]; // Render function @@ -169,16 +209,9 @@ export function Button({ {...props} accessibilityRole={accessibilityRole ?? "button"} disabled={disabled} - onHoverIn={() => setHovered(true)} - onHoverOut={() => setHovered(false)} - style={({ pressed }) => [ - styles.base, - sizeStyle, - variantStyle, - pressed ? styles.pressed : null, - disabled ? styles.disabled : null, - style, - ]} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} + style={pressableStyle} > {renderIcon()} {children != null ? {children} : null} diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 907ae876e..556b88f00 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { ReactElement, ReactNode } from "react"; +import type { ReactElement, ReactNode, Ref } from "react"; import { View, Text, @@ -10,6 +10,8 @@ import { Platform, StatusBar, useWindowDimensions, + type LayoutChangeEvent, + type PressableStateCallbackType, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -88,6 +90,14 @@ export interface ComboboxProps { children?: ReactNode; } +function resolveControlledOpen( + open: boolean | undefined, + internalOpen: boolean, +): { isControlled: boolean; isOpen: boolean } { + const isControlled = typeof open === "boolean"; + return { isControlled, isOpen: isControlled ? open : internalOpen }; +} + function toNumericStyleValue(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; @@ -104,19 +114,19 @@ function toNumericStyleValue(value: unknown): number | null { function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); - return ( - + const combinedStyle = useMemo( + () => [ + style, + { + backgroundColor: theme.colors.surface0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + }, + ], + [style, theme.colors.surface0, theme.borderRadius], ); + + return ; } export interface SearchInputProps { @@ -153,9 +163,9 @@ export function SearchInput({ } // @ts-expect-error - outlineStyle is web-only - style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]} + style={SEARCH_INPUT_STYLE} placeholder={placeholder} placeholderTextColor={theme.colors.foregroundMuted} value={value} @@ -198,33 +208,44 @@ export function ComboboxItem({ }: ComboboxItemProps): ReactElement { const { theme } = useUnistyles(); - const leadingContent = leadingSlot ? ( - {leadingSlot} - ) : kind === "directory" || kind === "file" ? ( - - {kind === "directory" ? ( + let leadingContent: ReactElement | null = null; + if (leadingSlot) { + leadingContent = {leadingSlot}; + } else if (kind === "directory") { + leadingContent = ( + - ) : ( + + ); + } else if (kind === "file") { + leadingContent = ( + - )} - - ) : null; + + ); + } + + const itemPressableStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.comboboxItem, + Boolean(hovered) && + (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered), + pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed), + active && styles.comboboxItemActive, + disabled && styles.comboboxItemDisabled, + ], + [elevated, active, disabled], + ); + + const itemContentStyle = useMemo( + () => [styles.comboboxItemContent, description && styles.comboboxItemContentInline], + [description], + ); return ( - [ - styles.comboboxItem, - hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered), - pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed), - active && styles.comboboxItemActive, - disabled && styles.comboboxItemDisabled, - ]} - > + {leadingContent} - + {label} @@ -254,6 +275,900 @@ export function ComboboxEmpty({ children }: { children: ReactNode }): ReactEleme ); } +type RenderOptionFn = NonNullable; + +interface OptionRowProps { + option: ComboboxOption; + selected: boolean; + active: boolean; + onSelect: (id: string) => void; + renderOption: RenderOptionFn | undefined; +} + +function OptionRow({ option, selected, active, onSelect, renderOption }: OptionRowProps) { + const handlePress = useCallback(() => onSelect(option.id), [onSelect, option.id]); + if (renderOption) { + return {renderOption({ option, selected, active, onPress: handlePress })}; + } + return ( + + ); +} + +interface OptionsListProps { + options: ComboboxOption[]; + value: string; + activeIndex: number; + emptyText: string; + onSelect: (id: string) => void; + renderOption: RenderOptionFn | undefined; +} + +function OptionsList({ + options, + value, + activeIndex, + emptyText, + onSelect, + renderOption, +}: OptionsListProps): ReactElement { + if (options.length === 0) { + return {emptyText}; + } + return ( + <> + {options.map((opt, index) => ( + + ))} + + ); +} + +interface DesktopPositionInput { + isDesktopAboveSearch: boolean; + isMobile: boolean; + desktopPlacement: "top-start" | "bottom-start"; + referenceTop: number | null; + referenceLeft: number | null; + referenceAtOrigin: boolean; + desktopContentWidth: number | null; + windowWidth: number; + windowHeight: number; + collisionPadding: number; + floatingTop: number | null; + floatingLeft: number | null; + floatingStyles: ReturnType["floatingStyles"]; + desktopPreventInitialFlash: boolean; + referenceWidth: number | null; +} + +interface DesktopPositionResult { + desktopPositionStyle: + | { left: number; bottom: number } + | ReturnType["floatingStyles"]; + hasResolvedDesktopPosition: boolean; + shouldHideDesktopContent: boolean; + shouldUseDesktopFade: boolean; + useMeasuredTopStartPosition: boolean; +} + +function shouldUseMeasuredTopStart(input: DesktopPositionInput): boolean { + return ( + !input.isDesktopAboveSearch && + IS_WEB && + !input.isMobile && + input.desktopPlacement === "top-start" && + input.referenceTop !== null && + input.referenceLeft !== null && + input.desktopContentWidth !== null + ); +} + +function resolvePositionReady( + input: DesktopPositionInput, + useMeasured: boolean, + measuredLeft: number | null, + measuredBottom: number | null, + aboveSearchBottom: number | null, +): boolean { + const { isDesktopAboveSearch, floatingLeft, floatingTop, referenceAtOrigin } = input; + const hasNonZeroFloating = (floatingTop ?? 0) !== 0 || floatingLeft !== 0; + if (isDesktopAboveSearch) { + return floatingLeft !== null && aboveSearchBottom !== null; + } + if (useMeasured) { + return measuredLeft !== null && measuredBottom !== null; + } + return floatingLeft !== null && floatingTop !== null && (hasNonZeroFloating || referenceAtOrigin); +} + +function computeDesktopPosition(input: DesktopPositionInput): DesktopPositionResult { + const { + isDesktopAboveSearch, + referenceTop, + referenceLeft, + desktopContentWidth, + windowWidth, + windowHeight, + collisionPadding, + floatingLeft, + floatingStyles, + desktopPreventInitialFlash, + referenceWidth, + } = input; + + const desktopAboveSearchBottom = + isDesktopAboveSearch && referenceTop !== null + ? Math.max(windowHeight - referenceTop, collisionPadding) + : null; + const useMeasuredTopStartPosition = shouldUseMeasuredTopStart(input); + const clampedMeasuredTopStartLeft = + useMeasuredTopStartPosition && referenceLeft !== null && desktopContentWidth !== null + ? Math.max( + collisionPadding, + Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft), + ) + : null; + const measuredTopStartBottom = + useMeasuredTopStartPosition && referenceTop !== null + ? Math.max(windowHeight - referenceTop + 5, collisionPadding) + : null; + + const resolvedPositionReady = resolvePositionReady( + input, + useMeasuredTopStartPosition, + clampedMeasuredTopStartLeft, + measuredTopStartBottom, + desktopAboveSearchBottom, + ); + const hasResolvedDesktopPosition = + referenceWidth !== null && referenceWidth > 0 && resolvedPositionReady; + const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; + const shouldUseDesktopFade = !desktopPreventInitialFlash; + + let desktopPositionStyle: DesktopPositionResult["desktopPositionStyle"]; + if (isDesktopAboveSearch) { + desktopPositionStyle = { + left: floatingLeft ?? 0, + bottom: desktopAboveSearchBottom ?? 0, + }; + } else if (useMeasuredTopStartPosition) { + desktopPositionStyle = { + left: clampedMeasuredTopStartLeft ?? 0, + bottom: measuredTopStartBottom ?? 0, + }; + } else { + desktopPositionStyle = floatingStyles; + } + + return { + desktopPositionStyle, + hasResolvedDesktopPosition, + shouldHideDesktopContent, + shouldUseDesktopFade, + useMeasuredTopStartPosition, + }; +} + +function advanceActiveIndex(itemCount: number, key: "ArrowDown" | "ArrowUp") { + return (currentIndex: number) => getNextActiveIndex({ currentIndex, itemCount, key }); +} + +type DesktopKey = "ArrowDown" | "ArrowUp" | "Enter" | "Escape"; + +interface DesktopKeyHandlerInput { + isOpen: boolean; + isMobile: boolean; + orderedVisibleOptions: ComboboxOption[]; + activeIndex: number; + setActiveIndex: React.Dispatch>; + handleSelect: (id: string) => void; + handleClose: () => void; +} + +function handleDesktopArrowKey(input: DesktopKeyHandlerInput, key: "ArrowDown" | "ArrowUp") { + input.setActiveIndex(advanceActiveIndex(input.orderedVisibleOptions.length, key)); +} + +function handleDesktopEnterKey(input: DesktopKeyHandlerInput) { + if (input.orderedVisibleOptions.length === 0) return; + const { activeIndex, orderedVisibleOptions } = input; + const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; + input.handleSelect(orderedVisibleOptions[index]!.id); +} + +interface FloatingSizeSetters { + setAvailableSize: React.Dispatch< + React.SetStateAction<{ width?: number; height?: number } | null> + >; + setReferenceWidth: React.Dispatch>; +} + +function updateAvailableSize( + setAvailableSize: FloatingSizeSetters["setAvailableSize"], + availableWidth: number, + availableHeight: number, +) { + setAvailableSize((prev) => { + const next = { width: availableWidth, height: availableHeight }; + if (!prev) return next; + if (prev.width === next.width && prev.height === next.height) return prev; + return next; + }); +} + +function updateReferenceWidth( + setReferenceWidth: FloatingSizeSetters["setReferenceWidth"], + width: number, +) { + setReferenceWidth((prev) => { + if (!(width > 0)) return prev; + if (prev === width) return prev; + return width; + }); +} + +interface MeasuredAnchorSetters { + setReferenceLeft: React.Dispatch>; + setReferenceTop: React.Dispatch>; + setReferenceWidth: React.Dispatch>; + setReferenceAtOrigin: React.Dispatch>; +} + +function applyMeasuredAnchor(setters: MeasuredAnchorSetters, x: number, y: number, width: number) { + setters.setReferenceLeft((prev) => (prev === x ? prev : x)); + setters.setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); + setters.setReferenceTop((prev) => (prev === y ? prev : y)); + updateReferenceWidth(setters.setReferenceWidth, width); +} + +function useActiveIndexSync( + isOpen: boolean, + isMobile: boolean, + orderedVisibleOptions: ComboboxOption[], + effectiveOptionsPosition: "below-search" | "above-search", + normalizedSearch: string, + value: string, + setActiveIndex: React.Dispatch>, +) { + useEffect(() => { + if (!isOpen) return; + if (!IS_WEB && isMobile) return; + setActiveIndex( + resolveInitialActiveIndex( + orderedVisibleOptions, + effectiveOptionsPosition, + normalizedSearch, + value, + ), + ); + }, [ + effectiveOptionsPosition, + isMobile, + isOpen, + normalizedSearch, + value, + orderedVisibleOptions, + setActiveIndex, + ]); +} + +function useDesktopOptionsPinToBottom( + isOpen: boolean, + orderedVisibleOptions: ComboboxOption[], + pinDesktopOptionsToBottom: () => void, +) { + useEffect(() => { + if (!isOpen) return; + pinDesktopOptionsToBottom(); + }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); +} + +function useDesktopFloatingUpdate( + isOpen: boolean, + isMobile: boolean, + orderedVisibleOptionsLength: number, + searchQuery: string, + update: () => unknown, +) { + useLayoutEffect(() => { + if (!isOpen || isMobile) return; + void update(); + }, [isOpen, isMobile, orderedVisibleOptionsLength, searchQuery, update]); +} + +function useResetSearchOnOpen( + isOpen: boolean, + setSearchQueryWithCallback: (query: string) => void, +) { + useEffect(() => { + if (isOpen) { + setSearchQueryWithCallback(""); + } + }, [isOpen, setSearchQueryWithCallback]); +} + +interface DesktopResetSetters { + setAvailableSize: React.Dispatch< + React.SetStateAction<{ width?: number; height?: number } | null> + >; + setDesktopContentWidth: React.Dispatch>; + setReferenceLeft: React.Dispatch>; + setReferenceWidth: React.Dispatch>; +} + +function useDesktopPositionReset( + isOpen: boolean, + isMobile: boolean, + desktopPlacement: "top-start" | "bottom-start", + update: () => unknown, + setters: DesktopResetSetters, +) { + const { setAvailableSize, setDesktopContentWidth, setReferenceLeft, setReferenceWidth } = setters; + useEffect(() => { + if (!isOpen || isMobile) { + setAvailableSize(null); + setDesktopContentWidth(null); + setReferenceLeft(null); + setReferenceWidth(null); + return; + } + const raf = requestAnimationFrame(() => void update()); + return () => cancelAnimationFrame(raf); + }, [ + desktopPlacement, + isMobile, + isOpen, + update, + setAvailableSize, + setDesktopContentWidth, + setReferenceLeft, + setReferenceWidth, + ]); +} + +function useAnchorMeasure( + isOpen: boolean, + isMobile: boolean, + anchorRef: React.RefObject, + searchQuery: string, + windowHeight: number, + setters: MeasuredAnchorSetters, +) { + const { setReferenceLeft, setReferenceTop, setReferenceWidth, setReferenceAtOrigin } = setters; + useEffect(() => { + if (!isOpen || isMobile) { + setReferenceLeft(null); + setReferenceAtOrigin(false); + setReferenceTop(null); + return; + } + + const referenceEl = anchorRef.current; + if (!referenceEl) { + setReferenceAtOrigin(false); + setReferenceTop(null); + return; + } + + const measure = () => { + referenceEl.measureInWindow((x, y, width) => { + applyMeasuredAnchor( + { setReferenceLeft, setReferenceTop, setReferenceWidth, setReferenceAtOrigin }, + x, + y, + width, + ); + }); + }; + + measure(); + const raf = requestAnimationFrame(measure); + return () => cancelAnimationFrame(raf); + }, [ + anchorRef, + isMobile, + isOpen, + searchQuery, + windowHeight, + setReferenceLeft, + setReferenceTop, + setReferenceWidth, + setReferenceAtOrigin, + ]); +} + +function applySetOpen( + isControlled: boolean, + setInternalOpen: React.Dispatch>, + onOpenChange: ((open: boolean) => void) | undefined, + nextOpen: boolean, +) { + if (!isControlled) { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); +} + +function applySetSearchQuery( + setSearchQuery: React.Dispatch>, + onSearchQueryChange: ((query: string) => void) | undefined, + nextQuery: string, +) { + setSearchQuery(nextQuery); + onSearchQueryChange?.(nextQuery); +} + +function applyDesktopContentLayout( + event: LayoutChangeEvent, + setDesktopContentWidth: React.Dispatch>, + useMeasuredTopStartPosition: boolean, + hasResolvedDesktopPosition: boolean, + update: () => unknown, +) { + const { width } = event.nativeEvent.layout; + setDesktopContentWidth((prev) => (prev === width ? prev : width)); + if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) { + void update(); + } +} + +function runIfSelected(keepOpenOnSelect: boolean, handleClose: () => void) { + if (!keepOpenOnSelect) { + handleClose(); + } +} + +function runIfPinOpen(isOpen: boolean, pin: () => void) { + if (!isOpen) return; + pin(); +} + +function runIfSubmitSearch( + showCustomOption: boolean, + handleSelect: (id: string) => void, + sanitizedSearchValue: string, +) { + if (showCustomOption) { + handleSelect(sanitizedSearchValue); + } +} + +function computeCollisionPadding(): number { + const basePadding = 16; + if (Platform.OS !== "android") return basePadding; + const statusBarHeight = StatusBar.currentHeight ?? 0; + return Math.max(basePadding, statusBarHeight + basePadding); +} + +function scrollDesktopOptionsToEnd(scrollRef: React.RefObject) { + scrollRef.current?.scrollToEnd({ animated: false }); + requestAnimationFrame(() => { + scrollRef.current?.scrollToEnd({ animated: false }); + }); +} + +function resolveEffectiveOptionsPosition( + isMobile: boolean, + optionsPosition: "below-search" | "above-search", +): "below-search" | "above-search" { + return isMobile ? "below-search" : optionsPosition; +} + +function resolveIsDesktopAboveSearch( + isMobile: boolean, + effectiveOptionsPosition: "below-search" | "above-search", +): boolean { + return !isMobile && isWeb && effectiveOptionsPosition === "above-search"; +} + +function maybePinDesktopOptionsToBottom( + isMobile: boolean, + effectiveOptionsPosition: "below-search" | "above-search", + scrollRef: React.RefObject, +) { + if (isMobile || effectiveOptionsPosition !== "above-search") return; + scrollDesktopOptionsToEnd(scrollRef); +} + +interface FloatingMiddlewareInput { + collisionPadding: number; + isDesktopAboveSearch: boolean; + setAvailableSize: FloatingSizeSetters["setAvailableSize"]; + setReferenceWidth: FloatingSizeSetters["setReferenceWidth"]; +} + +function buildFloatingMiddleware(input: FloatingMiddlewareInput) { + const { collisionPadding, isDesktopAboveSearch, setAvailableSize, setReferenceWidth } = input; + return [ + floatingOffset(isWeb ? 5 : 4), + ...(isWeb ? [] : [flip({ padding: collisionPadding })]), + ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), + floatingSize({ + padding: collisionPadding, + apply({ availableWidth, availableHeight, rects }) { + updateAvailableSize(setAvailableSize, availableWidth, availableHeight); + updateReferenceWidth(setReferenceWidth, rects.reference.width); + }, + }), + ]; +} + +interface DesktopContainerStyleInput { + desktopMinWidth: number | undefined; + referenceWidth: number | null; + desktopFixedHeight: number | undefined; + desktopPositionStyle: DesktopPositionResult["desktopPositionStyle"]; + shouldHideDesktopContent: boolean; + availableHeight: number | undefined; +} + +function buildDesktopContainerStyle(input: DesktopContainerStyleInput) { + const { + desktopMinWidth, + referenceWidth, + desktopFixedHeight, + desktopPositionStyle, + shouldHideDesktopContent, + availableHeight, + } = input; + const fixedHeightStyle = + desktopFixedHeight != null + ? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight } + : null; + const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null; + const availableHeightStyle = + typeof availableHeight === "number" + ? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) } + : null; + return [ + styles.desktopContainer, + { + position: "absolute" as const, + minWidth: desktopMinWidth ?? referenceWidth ?? 200, + maxWidth: Math.max(400, desktopMinWidth ?? 0), + }, + fixedHeightStyle, + desktopPositionStyle, + hiddenStyle, + availableHeightStyle, + ]; +} + +function isDesktopKey(key: string): key is DesktopKey { + return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape"; +} + +function useWebKeyboardListener( + isOpen: boolean, + handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => void, +) { + useEffect(() => { + if (!IS_WEB || !isOpen) return; + + const handler = (event: KeyboardEvent) => { + if (!isDesktopKey(event.key)) return; + handleDesktopKey(event.key, event); + }; + + // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. + window.addEventListener("keydown", handler, true); + return () => { + window.removeEventListener("keydown", handler, true); + }; + }, [handleDesktopKey, isOpen]); +} + +function dispatchDesktopKey(input: DesktopKeyHandlerInput, key: DesktopKey, event?: KeyboardEvent) { + if (!input.isOpen) return; + if (!IS_WEB && input.isMobile) return; + + if (key === "ArrowDown" || key === "ArrowUp") { + event?.preventDefault(); + handleDesktopArrowKey(input, key); + return; + } + if (key === "Enter") { + if (input.orderedVisibleOptions.length === 0) return; + event?.preventDefault(); + handleDesktopEnterKey(input); + return; + } + if (key === "Escape") { + event?.preventDefault(); + input.handleClose(); + } +} + +function resolveInitialActiveIndex( + orderedVisibleOptions: ComboboxOption[], + effectiveOptionsPosition: "below-search" | "above-search", + normalizedSearch: string, + value: string, +): number { + if (orderedVisibleOptions.length === 0) return -1; + const fallbackIndex = getComboboxFallbackIndex( + orderedVisibleOptions.length, + effectiveOptionsPosition, + ); + if (normalizedSearch) return fallbackIndex; + const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); + return selectedIndex >= 0 ? selectedIndex : fallbackIndex; +} + +interface MobileBodyProps { + bottomSheetRef: ReturnType["sheetRef"]; + snapPoints: string[]; + handleSheetChange: ReturnType["handleSheetChange"]; + handleIndicatorStyle: { backgroundColor: string }; + titleColor: string; + title: string; + stickyHeader: ReactNode; + searchable: boolean; + hasChildren: boolean; + searchPlaceholder: string; + searchQuery: string; + setSearchQueryWithCallback: (query: string) => void; + handleSubmitSearch: () => void; + orderedVisibleOptions: ComboboxOption[]; + value: string; + activeIndex: number; + emptyText: string; + handleSelect: (id: string) => void; + renderOption: RenderOptionFn | undefined; + children: ReactNode; +} + +function MobileComboboxBody(props: MobileBodyProps): ReactElement { + const renderBackdrop = useCallback( + (backdropProps: React.ComponentProps) => ( + + ), + [], + ); + + const comboboxTitleStyle = useMemo( + () => [styles.comboboxTitle, { color: props.titleColor }], + [props.titleColor], + ); + + const body = props.hasChildren ? ( + props.children + ) : ( + + ); + + return ( + + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + {body} + + + ); +} + +interface DesktopBodyProps { + isOpen: boolean; + handleClose: () => void; + refs: ReturnType["refs"]; + shouldUseDesktopFade: boolean; + desktopContainerStyle: unknown; + handleDesktopContentLayout: (event: LayoutChangeEvent) => void; + stickyHeader: ReactNode; + searchable: boolean; + searchPlaceholder: string; + searchQuery: string; + setSearchQueryWithCallback: (query: string) => void; + handleSubmitSearch: () => void; + effectiveOptionsPosition: "below-search" | "above-search"; + desktopOptionsScrollRef: React.RefObject; + desktopAboveSearchContentContainerStyle: unknown; + handleDesktopOptionsContentSizeChange: () => void; + orderedVisibleOptions: ComboboxOption[]; + value: string; + activeIndex: number; + emptyText: string; + handleSelect: (id: string) => void; + renderOption: RenderOptionFn | undefined; + hasChildren: boolean; + children: ReactNode; +} + +function DesktopComboboxChildrenBody(props: { + stickyHeader: ReactNode; + children: ReactNode; +}): ReactElement { + return ( + <> + {props.stickyHeader} + + {props.children} + + + ); +} + +function DesktopComboboxOptionsBody(props: { + stickyHeader: ReactNode; + searchable: boolean; + searchPlaceholder: string; + searchQuery: string; + setSearchQueryWithCallback: (query: string) => void; + handleSubmitSearch: () => void; + effectiveOptionsPosition: "below-search" | "above-search"; + desktopOptionsScrollRef: React.RefObject; + desktopAboveSearchContentContainerStyle: unknown; + handleDesktopOptionsContentSizeChange: () => void; + orderedVisibleOptions: ComboboxOption[]; + value: string; + activeIndex: number; + emptyText: string; + handleSelect: (id: string) => void; + renderOption: RenderOptionFn | undefined; +}): ReactElement { + const list = ( + + ); + + return ( + <> + {props.stickyHeader} + {props.searchable ? ( + + ) : null} + {props.effectiveOptionsPosition === "above-search" ? ( + + {list} + + ) : ( + + {list} + + )} + + ); +} + +function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { + return ( + + + + + {props.hasChildren ? ( + + {props.children} + + ) : ( + + )} + + + + ); +} + export function Combobox({ options, value, @@ -280,12 +1195,12 @@ export function Combobox({ keepOpenOnSelect = false, anchorRef, children, -}: ComboboxProps): ReactElement { +}: ComboboxProps): ReactElement | null { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); const titleColor = theme.colors.foreground; - const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; - const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; + const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); + const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition); const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const snapPoints = useMemo(() => ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( @@ -300,25 +1215,16 @@ export function Combobox({ const desktopOptionsScrollRef = useRef(null); const [desktopContentWidth, setDesktopContentWidth] = useState(null); - const isControlled = typeof open === "boolean"; const [internalOpen, setInternalOpen] = useState(false); - const isOpen = isControlled ? open : internalOpen; + const { isControlled, isOpen } = resolveControlledOpen(open, internalOpen); const setOpen = useCallback( - (nextOpen: boolean) => { - if (!isControlled) { - setInternalOpen(nextOpen); - } - onOpenChange?.(nextOpen); - }, + (nextOpen: boolean) => applySetOpen(isControlled, setInternalOpen, onOpenChange, nextOpen), [isControlled, onOpenChange], ); const setSearchQueryWithCallback = useCallback( - (nextQuery: string) => { - setSearchQuery(nextQuery); - onSearchQueryChange?.(nextQuery); - }, + (nextQuery: string) => applySetSearchQuery(setSearchQuery, onSearchQueryChange, nextQuery), [onSearchQueryChange], ); @@ -327,44 +1233,18 @@ export function Combobox({ setSearchQueryWithCallback(""); }, [setOpen, setSearchQueryWithCallback]); - useEffect(() => { - if (isOpen) { - setSearchQueryWithCallback(""); - } - }, [isOpen, setSearchQueryWithCallback]); + useResetSearchOnOpen(isOpen, setSearchQueryWithCallback); - const collisionPadding = useMemo(() => { - const basePadding = 16; - if (Platform.OS !== "android") return basePadding; - const statusBarHeight = StatusBar.currentHeight ?? 0; - return Math.max(basePadding, statusBarHeight + basePadding); - }, []); + const collisionPadding = useMemo(computeCollisionPadding, []); const middleware = useMemo( - () => [ - floatingOffset(isWeb ? 5 : 4), - ...(isWeb ? [] : [flip({ padding: collisionPadding })]), - ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), - floatingSize({ - padding: collisionPadding, - apply({ availableWidth, availableHeight, rects }) { - setAvailableSize((prev) => { - const next = { width: availableWidth, height: availableHeight }; - if (!prev) return next; - if (prev.width === next.width && prev.height === next.height) return prev; - return next; - }); - setReferenceWidth((prev) => { - const next = rects.reference.width; - if (!(next > 0)) { - return prev; - } - if (prev === next) return prev; - return next; - }); - }, + () => + buildFloatingMiddleware({ + collisionPadding, + isDesktopAboveSearch, + setAvailableSize, + setReferenceWidth, }), - ], [collisionPadding, isDesktopAboveSearch], ); @@ -377,100 +1257,46 @@ export function Combobox({ }, }); - useEffect(() => { - if (!isOpen || isMobile) { - setAvailableSize(null); - setDesktopContentWidth(null); - setReferenceLeft(null); - setReferenceWidth(null); - return; - } - const raf = requestAnimationFrame(() => void update()); - return () => cancelAnimationFrame(raf); - }, [desktopPlacement, isMobile, isOpen, update]); + useDesktopPositionReset(isOpen, isMobile, desktopPlacement, update, { + setAvailableSize, + setDesktopContentWidth, + setReferenceLeft, + setReferenceWidth, + }); - useEffect(() => { - if (!isOpen || isMobile) { - setReferenceLeft(null); - setReferenceAtOrigin(false); - setReferenceTop(null); - return; - } - - const referenceEl = anchorRef.current; - if (!referenceEl) { - setReferenceAtOrigin(false); - setReferenceTop(null); - return; - } - - const measure = () => { - referenceEl.measureInWindow((x, y, width, height) => { - setReferenceLeft((prev) => (prev === x ? prev : x)); - setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); - setReferenceTop((prev) => (prev === y ? prev : y)); - setReferenceWidth((prev) => { - if (!(width > 0)) { - return prev; - } - return prev === width ? prev : width; - }); - }); - }; - - measure(); - const raf = requestAnimationFrame(measure); - return () => cancelAnimationFrame(raf); - }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]); + useAnchorMeasure(isOpen, isMobile, anchorRef, searchQuery, windowHeight, { + setReferenceLeft, + setReferenceTop, + setReferenceWidth, + setReferenceAtOrigin, + }); const floatingTop = toNumericStyleValue(floatingStyles.top); const floatingLeft = toNumericStyleValue(floatingStyles.left); - const desktopAboveSearchBottom = - isDesktopAboveSearch && referenceTop !== null - ? Math.max(windowHeight - referenceTop, collisionPadding) - : null; - const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0; - const useMeasuredTopStartPosition = - !isDesktopAboveSearch && - IS_WEB && - !isMobile && - desktopPlacement === "top-start" && - referenceTop !== null && - referenceLeft !== null && - desktopContentWidth !== null; - const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition - ? Math.max( - collisionPadding, - Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft), - ) - : null; - const measuredTopStartBottom = useMeasuredTopStartPosition - ? Math.max(windowHeight - referenceTop + 5, collisionPadding) - : null; - const hasResolvedDesktopPosition = - referenceWidth !== null && - referenceWidth > 0 && - (isDesktopAboveSearch - ? floatingLeft !== null && desktopAboveSearchBottom !== null - : useMeasuredTopStartPosition - ? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null - : floatingLeft !== null && - floatingTop !== null && - (hasNonZeroFloatingPosition || referenceAtOrigin)); - const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; - const shouldUseDesktopFade = !desktopPreventInitialFlash; - const desktopPositionStyle = isDesktopAboveSearch - ? { - left: floatingLeft ?? 0, - bottom: desktopAboveSearchBottom ?? 0, - } - : useMeasuredTopStartPosition - ? { - left: clampedMeasuredTopStartLeft ?? 0, - bottom: measuredTopStartBottom ?? 0, - } - : floatingStyles; + const { + desktopPositionStyle, + hasResolvedDesktopPosition, + shouldHideDesktopContent, + shouldUseDesktopFade, + useMeasuredTopStartPosition, + } = computeDesktopPosition({ + isDesktopAboveSearch, + isMobile, + desktopPlacement, + referenceTop, + referenceLeft, + referenceAtOrigin, + desktopContentWidth, + windowWidth, + windowHeight, + collisionPadding, + floatingTop, + floatingLeft, + floatingStyles, + desktopPreventInitialFlash, + referenceWidth, + }); const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ visible: isOpen, @@ -478,13 +1304,6 @@ export function Combobox({ onClose: handleClose, }); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = useMemo( @@ -525,288 +1344,168 @@ export function Combobox({ [effectiveOptionsPosition, visibleOptions], ); - const pinDesktopOptionsToBottom = useCallback(() => { - if (isMobile || effectiveOptionsPosition !== "above-search") { - return; - } - desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); - requestAnimationFrame(() => { - desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); - }); - }, [effectiveOptionsPosition, isMobile]); + const handleDesktopContentLayout = useCallback( + (event: LayoutChangeEvent) => + applyDesktopContentLayout( + event, + setDesktopContentWidth, + useMeasuredTopStartPosition, + hasResolvedDesktopPosition, + update, + ), + [useMeasuredTopStartPosition, hasResolvedDesktopPosition, update], + ); - const handleDesktopOptionsContentSizeChange = useCallback(() => { - if (!isOpen) { - return; - } - pinDesktopOptionsToBottom(); - }, [isOpen, pinDesktopOptionsToBottom]); + const pinDesktopOptionsToBottom = useCallback( + () => + maybePinDesktopOptionsToBottom(isMobile, effectiveOptionsPosition, desktopOptionsScrollRef), + [effectiveOptionsPosition, isMobile], + ); - useEffect(() => { - if (!isOpen) { - return; - } - pinDesktopOptionsToBottom(); - }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); + const handleDesktopOptionsContentSizeChange = useCallback( + () => runIfPinOpen(isOpen, pinDesktopOptionsToBottom), + [isOpen, pinDesktopOptionsToBottom], + ); - useLayoutEffect(() => { - if (!isOpen || isMobile) { - return; - } - void update(); - }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]); + useDesktopOptionsPinToBottom(isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom); - useEffect(() => { - if (!isOpen) return; - if (!IS_WEB && isMobile) return; + useDesktopFloatingUpdate(isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update); - if (orderedVisibleOptions.length === 0) { - setActiveIndex(-1); - return; - } - - const fallbackIndex = getComboboxFallbackIndex( - orderedVisibleOptions.length, - effectiveOptionsPosition, - ); - - if (normalizedSearch) { - setActiveIndex(fallbackIndex); - return; - } - - const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); - setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex); - }, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions]); + useActiveIndexSync( + isOpen, + isMobile, + orderedVisibleOptions, + effectiveOptionsPosition, + normalizedSearch, + value, + setActiveIndex, + ); const handleSelect = useCallback( (id: string) => { onSelect(id); - if (!keepOpenOnSelect) { - handleClose(); - } + runIfSelected(keepOpenOnSelect, handleClose); }, [handleClose, keepOpenOnSelect, onSelect], ); - const handleSubmitSearch = useCallback(() => { - if (showCustomOption) { - handleSelect(sanitizedSearchValue); - } - }, [handleSelect, sanitizedSearchValue, showCustomOption]); + const handleSubmitSearch = useCallback( + () => runIfSubmitSearch(showCustomOption, handleSelect, sanitizedSearchValue), + [handleSelect, sanitizedSearchValue, showCustomOption], + ); const handleDesktopKey = useCallback( - (key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => { - if (!isOpen) return; - if (!IS_WEB && isMobile) return; - - if (key === "ArrowDown" || key === "ArrowUp") { - event?.preventDefault(); - setActiveIndex((currentIndex) => - getNextActiveIndex({ - currentIndex, - itemCount: orderedVisibleOptions.length, - key, - }), - ); - return; - } - - if (key === "Enter") { - if (orderedVisibleOptions.length === 0) return; - event?.preventDefault(); - const index = - activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; - handleSelect(orderedVisibleOptions[index]!.id); - return; - } - - if (key === "Escape") { - event?.preventDefault(); - handleClose(); - } + (key: DesktopKey, event?: KeyboardEvent) => { + dispatchDesktopKey( + { + isOpen, + isMobile, + orderedVisibleOptions, + activeIndex, + setActiveIndex, + handleSelect, + handleClose, + }, + key, + event, + ); }, [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions], ); - useEffect(() => { - if (!IS_WEB || !isOpen) return; + useWebKeyboardListener(isOpen, handleDesktopKey); - const handler = (event: KeyboardEvent) => { - const key = event.key; - if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { - return; - } - handleDesktopKey(key, event); - }; - - // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. - window.addEventListener("keydown", handler, true); - return () => { - window.removeEventListener("keydown", handler, true); - }; - }, [handleDesktopKey, isOpen]); - - const searchInput = ( - + const handleIndicatorStyle = useMemo( + () => ({ backgroundColor: theme.colors.palette.zinc[600] }), + [theme.colors.palette.zinc], ); - const optionsList = ( - <> - {orderedVisibleOptions.length > 0 ? ( - orderedVisibleOptions.map((opt, index) => - renderOption ? ( - - {renderOption({ - option: opt, - selected: opt.id === value, - active: index === activeIndex, - onPress: () => handleSelect(opt.id), - })} - - ) : ( - handleSelect(opt.id)} - /> - ), - ) - ) : ( - {emptyText} - )} - + const desktopContainerStyle = useMemo( + () => + buildDesktopContainerStyle({ + desktopMinWidth, + referenceWidth, + desktopFixedHeight, + desktopPositionStyle, + shouldHideDesktopContent, + availableHeight: availableSize?.height, + }), + [ + desktopMinWidth, + referenceWidth, + desktopFixedHeight, + desktopPositionStyle, + shouldHideDesktopContent, + availableSize?.height, + ], ); - const defaultContent = optionsList; + const desktopAboveSearchContentContainerStyle = useMemo( + () => [styles.desktopScrollContent, styles.desktopScrollContentAboveSearch], + [], + ); - const content = children ?? defaultContent; + const effectiveSearchPlaceholder = searchPlaceholder ?? placeholder; + const hasChildren = Boolean(children); if (isMobile) { return ( - - - - {title} - - - {stickyHeader} - {!children && searchable ? searchInput : null} - - {content} - - + {children} + ); } - if (!isOpen) return <>; + if (!isOpen) return null; return ( - - - - { - const { width, height } = event.nativeEvent.layout; - setDesktopContentWidth((prev) => (prev === width ? prev : width)); - if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) { - void update(); - } - }} - > - {children ? ( - <> - {stickyHeader} - - {content} - - - ) : ( - <> - {stickyHeader} - {searchable ? searchInput : null} - {effectiveOptionsPosition === "above-search" ? ( - - {optionsList} - - ) : ( - - {optionsList} - - )} - - )} - - - + + {children} + ); } @@ -949,3 +1648,5 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "flex-end", }, })); + +const SEARCH_INPUT_STYLE = [styles.searchInput, IS_WEB && { outlineStyle: "none" }]; diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 8698a0bfe..22b1670cc 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -9,6 +9,7 @@ import { useState, type PropsWithChildren, type ReactElement, + type ReactNode, type Ref, type MutableRefObject, } from "react"; @@ -22,7 +23,9 @@ import { StatusBar, Text, View, + type GestureResponderEvent, type PressableProps, + type PressableStateCallbackType, type StyleProp, type ViewStyle, } from "react-native"; @@ -53,13 +56,13 @@ interface Rect { height: number; } -type ContextMenuContextValue = { +interface ContextMenuContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; anchorRect: Rect | null; setAnchorRect: (rect: Rect | null) => void; -}; +} const ContextMenuContext = createContext(null); @@ -173,7 +176,17 @@ function computePosition({ } function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null { - const nativeEvent: any = (event as any)?.nativeEvent ?? event; + const wrapper = event as + | { + nativeEvent?: { pageX?: number; pageY?: number; clientX?: number; clientY?: number }; + pageX?: number; + pageY?: number; + clientX?: number; + clientY?: number; + } + | null + | undefined; + const nativeEvent = wrapper?.nativeEvent ?? wrapper; const pageX = nativeEvent?.pageX; const pageY = nativeEvent?.pageY; if (typeof pageX === "number" && typeof pageY === "number") { @@ -235,7 +248,11 @@ export function ContextMenu({ return {children}; } -type TriggerState = { pressed: boolean; hovered: boolean; open: boolean }; +interface TriggerState { + pressed: boolean; + hovered: boolean; + open: boolean; +} type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); export function ContextMenuTrigger({ @@ -291,6 +308,42 @@ export function ContextMenuTrigger({ [ctx.triggerRef, triggerRef], ); + const propsOnLongPress = props.onLongPress; + const handleLongPress = useCallback( + (event: GestureResponderEvent) => { + if (isWeb) { + propsOnLongPress?.(event); + return; + } + openAtEvent(event); + propsOnLongPress?.(event); + }, + [propsOnLongPress, openAtEvent], + ); + + const handleContextMenu = useCallback( + (event: unknown) => { + if (isNative) { + return; + } + const e = event as { preventDefault?: () => void; stopPropagation?: () => void } | undefined; + e?.preventDefault?.(); + e?.stopPropagation?.(); + openAtEvent(event as GestureResponderEvent); + }, + [openAtEvent], + ); + + const pressableStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { + if (typeof style === "function") { + return style({ pressed, hovered: Boolean(hovered), open: ctx.open }); + } + return style; + }, + [style, ctx.open], + ); + return ( { - if (isWeb) { - props.onLongPress?.(event); - return; - } - openAtEvent(event); - props.onLongPress?.(event); - }} + onLongPress={handleLongPress} // @ts-ignore - onContextMenu is web-only and not in RN types. - onContextMenu={(event: unknown) => { - if (isNative) { - return; - } - const e: any = event; - e?.preventDefault?.(); - e?.stopPropagation?.(); - openAtEvent(event); - }} - style={({ pressed, hovered = false }) => { - if (typeof style === "function") { - return style({ pressed, hovered: Boolean(hovered), open: ctx.open }); - } - return style; - }} + onContextMenu={handleContextMenu} + style={pressableStyle} > {children} @@ -380,6 +413,21 @@ export function ContextMenuContent({ [], ); + const sheetBackgroundStyle = useMemo( + () => [ + styles.sheetBackground, + { + backgroundColor: theme.colors.surface0, + borderColor: theme.colors.border, + }, + ], + [theme.colors.surface0, theme.colors.border], + ); + const sheetHandleStyle = useMemo( + () => [styles.sheetHandle, { backgroundColor: theme.colors.surface2 }], + [theme.colors.surface2], + ); + // Measure trigger when opening (fallback) and capture point anchors. useEffect(() => { if (useMobileSheet) { @@ -415,6 +463,7 @@ export function ContextMenuContent({ ...rect, y: rect.y + statusBarHeight, }); + return; }); return () => { @@ -456,6 +505,26 @@ export function ContextMenuContent({ [], ); + const animatedContentStyle = useMemo(() => { + const { width: screenWidth } = Dimensions.get("window"); + const resolvedWidthStyle: ViewStyle = fullWidth + ? { width: screenWidth - horizontalPadding * 2 } + : { + ...(typeof width === "number" ? { width } : null), + ...(typeof minWidth === "number" ? { minWidth } : null), + ...(typeof maxWidth === "number" ? { maxWidth } : null), + }; + return [ + styles.content, + resolvedWidthStyle, + { + position: "absolute" as const, + top: position?.y ?? -9999, + left: position?.x ?? -9999, + }, + ]; + }, [fullWidth, horizontalPadding, width, minWidth, maxWidth, position?.y, position?.x]); + if (useMobileSheet) { return ( @@ -467,14 +536,8 @@ export function ContextMenuContent({ onChange={handleSheetChange} backdropComponent={renderSheetBackdrop} enablePanDownToClose - backgroundStyle={[ - styles.sheetBackground, - { - backgroundColor: theme.colors.surface0, - borderColor: theme.colors.border, - }, - ]} - handleIndicatorStyle={[styles.sheetHandle, { backgroundColor: theme.colors.surface2 }]} + backgroundStyle={sheetBackgroundStyle} + handleIndicatorStyle={sheetHandleStyle} keyboardBehavior="extend" keyboardBlurBehavior="restore" > @@ -493,15 +556,6 @@ export function ContextMenuContent({ if (!open) return null; - const { width: screenWidth } = Dimensions.get("window"); - const resolvedWidthStyle: ViewStyle = fullWidth - ? { width: screenWidth - horizontalPadding * 2 } - : { - ...(typeof width === "number" ? { width } : null), - ...(typeof minWidth === "number" ? { minWidth } : null), - ...(typeof maxWidth === "number" ? { maxWidth } : null), - }; - return ( {children} @@ -553,8 +599,9 @@ export function ContextMenuLabel({ style, testID, }: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { + const containerStyle = useMemo(() => [styles.labelContainer, style], [style]); return ( - + {children} ); @@ -567,7 +614,8 @@ export function ContextMenuSeparator({ style?: ViewStyle; testID?: string; }): ReactElement { - return ; + const separatorStyle = useMemo(() => [styles.separator, style], [style]); + return ; } export function ContextMenuHint({ @@ -581,6 +629,34 @@ export function ContextMenuHint({ ); } +function resolveLeadingContent(input: { + isPending: boolean; + isSuccess: boolean; + leading: ReactElement | null | undefined; + pendingColor: string; + successColor: string; +}): ReactElement | null { + if (input.isPending) { + return ; + } + if (input.isSuccess) { + return ; + } + return input.leading ?? null; +} + +function resolveItemLabel(input: { + children: ReactNode; + isPending: boolean; + isSuccess: boolean; + pendingLabel: string | undefined; + successLabel: string | undefined; +}): ReactNode { + if (input.isPending && input.pendingLabel) return input.pendingLabel; + if (input.isSuccess && input.successLabel) return input.successLabel; + return input.children; +} + export function ContextMenuItem({ children, description, @@ -621,25 +697,19 @@ export function ContextMenuItem({ const { theme } = useUnistyles(); const { setOpen } = useContextMenuContext("ContextMenuItem"); - const isPending = status === "pending" || loading; + const isPending = status === "pending" || Boolean(loading); const isSuccess = status === "success"; - const isDisabled = disabled || isPending || isSuccess; + const isDisabled = Boolean(disabled) || isPending || isSuccess; - let leadingContent: ReactElement | null = null; - if (isPending) { - leadingContent = ; - } else if (isSuccess) { - leadingContent = ; - } else if (leading) { - leadingContent = leading; - } + const leadingContent = resolveLeadingContent({ + isPending, + isSuccess, + leading, + pendingColor: theme.colors.foregroundMuted, + successColor: theme.colors.palette.green[500], + }); - let label = children; - if (isPending && pendingLabel) { - label = pendingLabel; - } else if (isSuccess && successLabel) { - label = successLabel; - } + const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel }); const trailingContent = trailing ?? @@ -647,32 +717,58 @@ export function ContextMenuItem({ ) : null); - const content = ( - { - if (isDisabled) return; - if (closeOnSelect) { - setOpen(false); - } - onSelect?.(); - }} - style={({ pressed, hovered }) => [ + const handleItemPress = useCallback(() => { + if (isDisabled) return; + if (closeOnSelect) { + setOpen(false); + } + onSelect?.(); + }, [isDisabled, closeOnSelect, setOpen, onSelect]); + + const itemPressableStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { + let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null; + if (!selected) selectedStyle = null; + else if (selectedVariant === "accent") selectedStyle = styles.itemSelectedAccent; + else selectedStyle = styles.itemSelected; + return [ styles.item, - selected - ? selectedVariant === "accent" - ? styles.itemSelectedAccent - : styles.itemSelected - : null, + selectedStyle, selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null, isDisabled ? styles.itemDisabled : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null, pressed && !isDisabled ? styles.itemPressed : null, - ]} + ]; + }, + [selected, selectedVariant, isDisabled], + ); + + const itemTextStyle = useMemo( + () => [ + styles.itemText, + destructive && !isSuccess ? styles.itemTextDestructive : null, + isSuccess ? styles.itemTextSuccess : null, + selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null, + ], + [destructive, isSuccess, selected, selectedVariant], + ); + const itemDescriptionStyle = useMemo( + () => [ + styles.itemDescription, + selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null, + ], + [selected, selectedVariant], + ); + + const content = ( + {showSelectedCheck ? ( @@ -681,27 +777,11 @@ export function ContextMenuItem({ ) : null} {leadingContent ? {leadingContent} : null} - + {label} {description && !isPending && !isSuccess ? ( - + {description} ) : null} @@ -854,3 +934,5 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.85, }, })); + +const SCROLL_CONTENT_CONTAINER_STYLE = { flexGrow: 1 }; diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 42e6cfd58..6d7d136f9 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -21,6 +21,7 @@ import { Platform, StatusBar, type PressableProps, + type PressableStateCallbackType, type ViewStyle, type StyleProp, } from "react-native"; @@ -33,6 +34,8 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; +const DROPDOWN_SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const; + type Placement = "top" | "bottom" | "left" | "right"; type Alignment = "start" | "center" | "end"; @@ -43,13 +46,13 @@ interface Rect { height: number; } -type DropdownMenuContextValue = { +interface DropdownMenuContextValue { open: boolean; setOpen: (open: boolean) => void; selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void; flushPendingSelect: () => void; triggerRef: React.RefObject; -}; +} const DropdownMenuContext = createContext(null); @@ -224,7 +227,11 @@ export function DropdownMenu({ return {children}; } -type TriggerState = { pressed: boolean; hovered: boolean; open: boolean }; +interface TriggerState { + pressed: boolean; + hovered: boolean; + open: boolean; +} type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); interface DropdownMenuTriggerProps extends Omit { @@ -245,6 +252,24 @@ export function DropdownMenuTrigger({ ctx.setOpen(!ctx.open); }, [disabled, ctx]); + const pressableStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { + if (typeof style === "function") { + return style({ pressed, hovered: Boolean(hovered), open: ctx.open }); + } + return style; + }, + [style, ctx.open], + ); + + const renderChildren = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { + const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open }; + return typeof children === "function" ? children(state) : children; + }, + [children, ctx.open], + ); + return ( { - if (typeof style === "function") { - return style({ pressed, hovered: Boolean(hovered), open: ctx.open }); - } - return style; - }} + style={pressableStyle} > - {({ pressed, hovered = false }) => { - const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open }; - return typeof children === "function" ? children(state) : children; - }} + {renderChildren} ); } function getTransformOrigin(placement: Placement, alignment: Alignment): string { - const vertical = placement === "bottom" ? "top" : placement === "top" ? "bottom" : "center"; - const horizontal = alignment === "start" ? "left" : alignment === "end" ? "right" : "center"; + let vertical: string; + if (placement === "bottom") vertical = "top"; + else if (placement === "top") vertical = "bottom"; + else vertical = "center"; + let horizontal: string; + if (alignment === "start") horizontal = "left"; + else if (alignment === "end") horizontal = "right"; + else horizontal = "center"; return `${vertical} ${horizontal}`; } @@ -362,6 +385,7 @@ export function DropdownMenuContent({ ...rect, y: rect.y + statusBarHeight, }); + return; }); return () => { @@ -406,16 +430,38 @@ export function DropdownMenuContent({ [], ); - if (!modalVisible) return null; + const contentStyle = useMemo(() => { + const { width: screenWidth } = Dimensions.get("window"); + const resolvedWidthStyle: ViewStyle = fullWidth + ? { width: screenWidth - horizontalPadding * 2 } + : { + ...(typeof width === "number" ? { width } : null), + ...(typeof minWidth === "number" ? { minWidth } : null), + ...(typeof maxWidth === "number" ? { maxWidth } : null), + }; + return [ + styles.content, + resolvedWidthStyle, + { + position: "absolute" as const, + top: position?.y ?? -9999, + left: position?.x ?? -9999, + transformOrigin: getTransformOrigin(actualPlacement, align), + }, + ]; + }, [ + fullWidth, + horizontalPadding, + width, + minWidth, + maxWidth, + position?.x, + position?.y, + actualPlacement, + align, + ]); - const { width: screenWidth } = Dimensions.get("window"); - const resolvedWidthStyle: ViewStyle = fullWidth - ? { width: screenWidth - horizontalPadding * 2 } - : { - ...(typeof width === "number" ? { width } : null), - ...(typeof minWidth === "number" ? { minWidth } : null), - ...(typeof maxWidth === "number" ? { maxWidth } : null), - }; + if (!modalVisible) return null; return ( {children} @@ -477,8 +514,9 @@ export function DropdownMenuLabel({ style, testID, }: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { + const labelContainerStyle = useMemo(() => [styles.labelContainer, style], [style]); return ( - + {children} ); @@ -491,7 +529,8 @@ export function DropdownMenuSeparator({ style?: ViewStyle; testID?: string; }): ReactElement { - return ; + const separatorStyle = useMemo(() => [styles.separator, style], [style]); + return ; } export function DropdownMenuHint({ @@ -505,6 +544,35 @@ export function DropdownMenuHint({ ); } +function resolveDropdownItemLeadingContent(input: { + isPending: boolean | undefined; + isSuccess: boolean; + leading: ReactElement | null; + theme: { colors: { foregroundMuted: string; palette: { green: Record } } }; +}): ReactElement | null { + const { isPending, isSuccess, leading, theme } = input; + if (isPending) { + return ; + } + if (isSuccess) { + return ; + } + return leading; +} + +function resolveDropdownItemLabel(input: { + children: ReactNode; + isPending: boolean | undefined; + isSuccess: boolean; + pendingLabel?: string; + successLabel?: string; +}): ReactNode { + const { children, isPending, isSuccess, pendingLabel, successLabel } = input; + if (isPending && pendingLabel) return pendingLabel; + if (isSuccess && successLabel) return successLabel; + return children; +} + export function DropdownMenuItem({ children, description, @@ -555,23 +623,20 @@ export function DropdownMenuItem({ const isSuccess = status === "success"; const isDisabled = disabled || isPending || isSuccess; - // Determine leading icon based on status - let leadingContent: ReactElement | null = null; - if (isPending) { - leadingContent = ; - } else if (isSuccess) { - leadingContent = ; - } else if (leading) { - leadingContent = leading; - } + const leadingContent = resolveDropdownItemLeadingContent({ + isPending, + isSuccess, + leading: leading ?? null, + theme, + }); - // Determine label based on status - let label = children; - if (isPending && pendingLabel) { - label = pendingLabel; - } else if (isSuccess && successLabel) { - label = successLabel; - } + const label = resolveDropdownItemLabel({ + children, + isPending, + isSuccess, + pendingLabel, + successLabel, + }); const trailingContent = trailing ?? @@ -579,30 +644,61 @@ export function DropdownMenuItem({ ) : null); + const handleItemPress = useCallback(() => { + if (isDisabled) return; + selectItem(onSelect, closeOnSelect); + }, [isDisabled, selectItem, onSelect, closeOnSelect]); + + const itemPressableStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { + let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null = + null; + if (selected && selectedVariant === "accent") { + selectedStyle = styles.itemSelectedAccent; + } else if (selected) { + selectedStyle = styles.itemSelected; + } + return [ + styles.item, + selectedStyle, + selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent" + ? styles.itemSelectedInteractive + : null, + isDisabled ? styles.itemDisabled : null, + muted && !isDisabled ? styles.itemMuted : null, + Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null, + pressed && !isDisabled ? styles.itemPressed : null, + ]; + }, + [selected, selectedVariant, isDisabled, muted], + ); + + const itemTextStyle = useMemo( + () => [ + styles.itemText, + destructive && !isSuccess ? styles.itemTextDestructive : null, + isSuccess ? styles.itemTextSuccess : null, + selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null, + muted && !isDisabled ? styles.itemTextMuted : null, + ], + [destructive, isSuccess, selected, selectedVariant, muted, isDisabled], + ); + + const itemDescriptionStyle = useMemo( + () => [ + styles.itemDescription, + selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null, + ], + [selected, selectedVariant], + ); + const content = ( { - if (isDisabled) return; - selectItem(onSelect, closeOnSelect); - }} - style={({ pressed, hovered }) => [ - styles.item, - selected - ? selectedVariant === "accent" - ? styles.itemSelectedAccent - : styles.itemSelected - : null, - selected && (hovered || pressed) && selectedVariant !== "accent" - ? styles.itemSelectedInteractive - : null, - isDisabled ? styles.itemDisabled : null, - muted && !isDisabled ? styles.itemMuted : null, - hovered && !pressed && !isDisabled ? styles.itemHovered : null, - pressed && !isDisabled ? styles.itemPressed : null, - ]} + onPress={handleItemPress} + style={itemPressableStyle} > {showSelectedCheck ? ( @@ -611,28 +707,11 @@ export function DropdownMenuItem({ ) : null} {leadingContent ? {leadingContent} : null} - + {label} {description && !isPending && !isSuccess ? ( - + {description} ) : null} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx index bcd43f279..efb52ae06 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx @@ -8,6 +8,12 @@ import { useIsolatedBottomSheetVisibility, } from "./isolated-bottom-sheet-modal"; +const SNAP_POINTS_50: (string | number)[] = ["50%"]; +const SNAP_POINTS_60: (string | number)[] = ["60%"]; +const SNAP_POINTS_90: (string | number)[] = ["90%"]; + +function noop(): void {} + const { modalMethods, modalProps } = vi.hoisted(() => ({ modalMethods: { present: vi.fn(), @@ -57,7 +63,7 @@ function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }
Sheet content
@@ -112,8 +118,8 @@ describe("IsolatedBottomSheetModal", () => { it("allows nested sheets inside a parent sheet without creating a sibling provider", () => { const { getAllByTestId } = render( - - {}}> + +
Nested model picker
, diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index 483f4c5cb..78426e69b 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -1,21 +1,21 @@ -import type { ReactNode } from "react"; -import { Pressable, Text, View } from "react-native"; -import type { StyleProp, ViewStyle } from "react-native"; +import { useCallback, useMemo, type ReactNode } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; type SegmentedControlSize = "sm" | "md"; type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; -export type SegmentedControlOption = { +export interface SegmentedControlOption { value: T; label: string; icon?: SegmentedControlIconRenderer; disabled?: boolean; testID?: string; -}; +} -type SegmentedControlProps = { +interface SegmentedControlProps { options: SegmentedControlOption[]; value: T; onValueChange: (value: T) => void; @@ -23,7 +23,7 @@ type SegmentedControlProps = { hideLabels?: boolean; style?: StyleProp; testID?: string; -}; +} export function SegmentedControl({ options, @@ -40,53 +40,104 @@ export function SegmentedControl({ const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd; const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md; + const containerStyle = useMemo( + () => [styles.container, containerSizeStyle, style], + [containerSizeStyle, style], + ); + return ( - + {options.map((option) => { const isSelected = option.value === value; const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted; return ( - { - if (!option.disabled && option.value !== value) { - onValueChange(option.value); - } - }} - style={({ hovered, pressed }) => [ - styles.segment, - segmentSizeStyle, - isSelected && styles.segmentSelected, - hovered && !isSelected && styles.segmentHover, - pressed && !isSelected && styles.segmentPressed, - option.disabled && styles.segmentDisabled, - ]} - > - {option.icon ? ( - - {option.icon({ color: iconColor, size: iconSize })} - - ) : null} - {hideLabels ? null : ( - - {option.label} - - )} - + option={option} + isSelected={isSelected} + iconColor={iconColor} + iconSize={iconSize} + hideLabels={hideLabels} + segmentSizeStyle={segmentSizeStyle} + labelSizeStyle={labelSizeStyle} + currentValue={value} + onValueChange={onValueChange} + /> ); })} ); } +function SegmentItem({ + option, + isSelected, + iconColor, + iconSize, + hideLabels, + segmentSizeStyle, + labelSizeStyle, + currentValue, + onValueChange, +}: { + option: SegmentedControlOption; + isSelected: boolean; + iconColor: string; + iconSize: number; + hideLabels: boolean; + segmentSizeStyle: StyleProp; + labelSizeStyle: StyleProp; + currentValue: T; + onValueChange: (value: T) => void; +}) { + const labelStyle = useMemo( + () => [styles.label, labelSizeStyle, isSelected && styles.labelSelected], + [labelSizeStyle, isSelected], + ); + const handlePress = useCallback(() => { + if (!option.disabled && option.value !== currentValue) { + onValueChange(option.value); + } + }, [option.disabled, option.value, currentValue, onValueChange]); + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.segment, + segmentSizeStyle, + isSelected && styles.segmentSelected, + Boolean(hovered) && !isSelected && styles.segmentHover, + pressed && !isSelected && styles.segmentPressed, + option.disabled && styles.segmentDisabled, + ], + [isSelected, option.disabled, segmentSizeStyle], + ); + const accessibilityState = useMemo( + () => ({ selected: isSelected, disabled: option.disabled }), + [isSelected, option.disabled], + ); + return ( + + {option.icon ? ( + + {option.icon({ color: iconColor, size: iconSize })} + + ) : null} + {hideLabels ? null : ( + + {option.label} + + )} + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { flexDirection: "row", diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index ec3d83e8f..0e49935f8 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from "react"; +import { useMemo, type ReactElement } from "react"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; @@ -19,24 +19,28 @@ export function Shortcut({ const shortcutOs = getShortcutOs(); const singleCombo = displayChord[0]; + const badgeStyle = useMemo(() => [styles.badge, style], [style]); + const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]); + const sequenceStyle = useMemo(() => [styles.sequence, style], [style]); + if (!singleCombo) { return ; } if (displayChord.length === 1) { return ( - - {formatShortcut(singleCombo, shortcutOs)} + + {formatShortcut(singleCombo, shortcutOs)} ); } return ( - - {displayChord.map(function (combo, index) { + + {displayChord.map(function (combo) { return ( - - {formatShortcut(combo, shortcutOs)} + + {formatShortcut(combo, shortcutOs)} ); })} diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx index ca80ebb7a..8ba00efd1 100644 --- a/packages/app/src/components/ui/status-badge.tsx +++ b/packages/app/src/components/ui/status-badge.tsx @@ -1,5 +1,6 @@ +import { useMemo } from "react"; import { View, Text } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; type StatusBadgeVariant = "success" | "error" | "muted"; @@ -9,25 +10,26 @@ interface StatusBadgeProps { } export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) { - const { theme } = useUnistyles(); + const pillStyle = useMemo( + () => [ + styles.pill, + variant === "success" && styles.pillSuccess, + variant === "error" && styles.pillError, + ], + [variant], + ); + const textStyle = useMemo( + () => [ + styles.pillText, + variant === "success" && styles.pillTextSuccess, + variant === "error" && styles.pillTextError, + ], + [variant], + ); return ( - - - {label} - + + {label} ); } diff --git a/packages/app/src/components/ui/tooltip.test.tsx b/packages/app/src/components/ui/tooltip.test.tsx index 78f634574..6482b09ad 100644 --- a/packages/app/src/components/ui/tooltip.test.tsx +++ b/packages/app/src/components/ui/tooltip.test.tsx @@ -16,7 +16,7 @@ vi.mock("@/constants/layout", () => ({ })); vi.mock("@gorhom/portal", () => ({ - Portal: ({ children }: { children: React.ReactNode }) => <>{children}, + Portal: ({ children }: { children: React.ReactNode }) => children, })); vi.mock("@gorhom/bottom-sheet", () => ({ diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index add551c15..df841e3f9 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -41,14 +41,14 @@ interface Rect { height: number; } -type TooltipContextValue = { +interface TooltipContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; enabled: boolean; openOnPress: boolean; delayDuration: number; -}; +} const TooltipContext = createContext(null); @@ -132,6 +132,37 @@ function measureElement(element: View): Promise { }); } +function resolveActualSide(args: { + triggerRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + side: Side; +}): Side { + const { triggerRect, contentSize, displayArea, side } = args; + const spaceTop = triggerRect.y - displayArea.y; + const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); + const spaceLeft = triggerRect.x - displayArea.x; + const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width); + + if (side === "bottom" && spaceBottom < contentSize.height && spaceTop > spaceBottom) return "top"; + if (side === "top" && spaceTop < contentSize.height && spaceBottom > spaceTop) return "bottom"; + if (side === "left" && spaceLeft < contentSize.width && spaceRight > spaceLeft) return "right"; + if (side === "right" && spaceRight < contentSize.width && spaceLeft > spaceRight) return "left"; + return side; +} + +function resolveAlignedCoordinate(args: { + align: Align; + start: number; + size: number; + contentSize: number; +}): number { + const { align, start, size, contentSize } = args; + if (align === "start") return start; + if (align === "end") return start + size - contentSize; + return start + (size - contentSize) / 2; +} + function computePosition({ triggerRect, contentSize, @@ -148,62 +179,43 @@ function computePosition({ offset: number; }): { x: number; y: number; actualSide: Side } { const { width: contentWidth, height: contentHeight } = contentSize; - - const spaceTop = triggerRect.y - displayArea.y; - const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); - const spaceLeft = triggerRect.x - displayArea.x; - const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width); - - let actualSide = side; - if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { - actualSide = "top"; - } else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { - actualSide = "bottom"; - } else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) { - actualSide = "right"; - } else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) { - actualSide = "left"; - } + const actualSide = resolveActualSide({ triggerRect, contentSize, displayArea, side }); let x = 0; let y = 0; if (actualSide === "bottom") { y = triggerRect.y + triggerRect.height + offset; - if (align === "start") { - x = triggerRect.x; - } else if (align === "end") { - x = triggerRect.x + triggerRect.width - contentWidth; - } else { - x = triggerRect.x + (triggerRect.width - contentWidth) / 2; - } + x = resolveAlignedCoordinate({ + align, + start: triggerRect.x, + size: triggerRect.width, + contentSize: contentWidth, + }); } else if (actualSide === "top") { y = triggerRect.y - contentHeight - offset; - if (align === "start") { - x = triggerRect.x; - } else if (align === "end") { - x = triggerRect.x + triggerRect.width - contentWidth; - } else { - x = triggerRect.x + (triggerRect.width - contentWidth) / 2; - } + x = resolveAlignedCoordinate({ + align, + start: triggerRect.x, + size: triggerRect.width, + contentSize: contentWidth, + }); } else if (actualSide === "left") { x = triggerRect.x - contentWidth - offset; - if (align === "start") { - y = triggerRect.y; - } else if (align === "end") { - y = triggerRect.y + triggerRect.height - contentHeight; - } else { - y = triggerRect.y + (triggerRect.height - contentHeight) / 2; - } + y = resolveAlignedCoordinate({ + align, + start: triggerRect.y, + size: triggerRect.height, + contentSize: contentHeight, + }); } else { x = triggerRect.x + triggerRect.width + offset; - if (align === "start") { - y = triggerRect.y; - } else if (align === "end") { - y = triggerRect.y + triggerRect.height - contentHeight; - } else { - y = triggerRect.y + (triggerRect.height - contentHeight) / 2; - } + y = resolveAlignedCoordinate({ + align, + start: triggerRect.y, + size: triggerRect.height, + contentSize: contentHeight, + }); } const padding = 8; @@ -307,24 +319,24 @@ export function TooltipTrigger({ }, [clearOpenTimer]); const handleHoverIn = useCallback( - (e?: any) => { - onHoverIn?.(e); + (e?: unknown) => { + onHoverIn?.(e as never); scheduleOpen(); }, [onHoverIn, scheduleOpen], ); const handleHoverOut = useCallback( - (e?: any) => { - onHoverOut?.(e); + (e?: unknown) => { + onHoverOut?.(e as never); close(); }, [onHoverOut, close], ); const handleFocus = useCallback( - (e: any) => { - onFocus?.(e); + (e: unknown) => { + onFocus?.(e as never); if (!ctx.enabled || disabled) return; if (!shouldOpenOnFocus()) return; clearOpenTimer(); @@ -334,16 +346,16 @@ export function TooltipTrigger({ ); const handleBlur = useCallback( - (e: any) => { - onBlur?.(e); + (e: unknown) => { + onBlur?.(e as never); close(); }, [close, onBlur], ); const handlePress = useCallback( - (e: any) => { - onPress?.(e); + (e: unknown) => { + onPress?.(e as never); if (!ctx.enabled || disabled) { return; } @@ -372,7 +384,7 @@ export function TooltipTrigger({ onPointerLeave: handleHoverOut, onMouseEnter: handleHoverIn, onMouseLeave: handleHoverOut, - } as any) + } as object) : null), }; @@ -382,21 +394,21 @@ export function TooltipTrigger({ throw new Error("TooltipTrigger with asChild expects a single React element child"); } - const childProps = child.props as Record; + const childProps = child.props as Record; const mergedProps = { ...childProps, ...triggerProps, disabled: childProps.disabled || disabled, - onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn), - onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut), - onFocus: composeEventHandlers(childProps.onFocus, handleFocus), - onBlur: composeEventHandlers(childProps.onBlur, handleBlur), - onPress: composeEventHandlers(childProps.onPress, handlePress), - onPointerEnter: composeEventHandlers(childProps.onPointerEnter, handleHoverIn), - onPointerLeave: composeEventHandlers(childProps.onPointerLeave, handleHoverOut), - onMouseEnter: composeEventHandlers(childProps.onMouseEnter, handleHoverIn), - onMouseLeave: composeEventHandlers(childProps.onMouseLeave, handleHoverOut), - } as Record; + onHoverIn: composeEventHandlers(childProps.onHoverIn as never, handleHoverIn), + onHoverOut: composeEventHandlers(childProps.onHoverOut as never, handleHoverOut), + onFocus: composeEventHandlers(childProps.onFocus as never, handleFocus), + onBlur: composeEventHandlers(childProps.onBlur as never, handleBlur), + onPress: composeEventHandlers(childProps.onPress as never, handlePress), + onPointerEnter: composeEventHandlers(childProps.onPointerEnter as never, handleHoverIn), + onPointerLeave: composeEventHandlers(childProps.onPointerLeave as never, handleHoverOut), + onMouseEnter: composeEventHandlers(childProps.onMouseEnter as never, handleHoverIn), + onMouseLeave: composeEventHandlers(childProps.onMouseLeave as never, handleHoverOut), + } as Record; const existingRefProp = childProps[triggerRefProp] as Ref | undefined; mergedProps[triggerRefProp] = (node: View | null) => { @@ -450,6 +462,7 @@ export function TooltipContent({ measureElement(ctx.triggerRef.current).then((rect) => { if (cancelled) return; setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + return; }); return () => { @@ -480,6 +493,22 @@ export function TooltipContent({ [], ); + const contentStyle = useMemo( + () => [ + styles.content, + { maxWidth }, + style, + { + position: "absolute" as const, + top: position?.y ?? -9999, + left: position?.x ?? -9999, + }, + ], + [maxWidth, style, position?.x, position?.y], + ); + + const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]); + if (!ctx.open || !ctx.enabled) return null; // On web, avoid React Native's implementation (it uses and can @@ -496,16 +525,7 @@ export function TooltipContent({ collapsable={false} testID={testID} onLayout={handleLayout} - style={[ - styles.content, - { maxWidth }, - style, - { - position: "absolute", - top: position?.y ?? -9999, - left: position?.x ?? -9999, - }, - ]} + style={contentStyle} > {children}
@@ -520,9 +540,9 @@ export function TooltipContent({ transparent animationType="none" statusBarTranslucent={Platform.OS === "android"} - onRequestClose={() => ctx.setOpen(false)} + onRequestClose={handleDismiss} > - ctx.setOpen(false)}> + {children} diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/components/use-bottom-anchor-controller.ts index 55457bebe..00fe07a2f 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.ts +++ b/packages/app/src/components/use-bottom-anchor-controller.ts @@ -3,16 +3,16 @@ import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strate export type BottomAnchorMode = "sticky-bottom" | "detached"; -export type BottomAnchorRouteRequest = { +export interface BottomAnchorRouteRequest { reason: "initial-entry" | "resume"; agentId: string; requestKey: string; -}; +} -export type BottomAnchorLocalRequest = { +export interface BottomAnchorLocalRequest { reason: "jump-to-bottom" | "message-sent"; agentId: string; -}; +} export type BottomAnchorBlockedReason = | "waiting_for_history_readiness" @@ -24,14 +24,14 @@ type BottomAnchorRequestReason = | BottomAnchorRouteRequest["reason"] | BottomAnchorLocalRequest["reason"]; -type BottomAnchorRequest = { +interface BottomAnchorRequest { id: number; agentId: string; reason: BottomAnchorRequestReason; requestKey: string; -}; +} -type ControllerMeasurementState = { +interface ControllerMeasurementState { containerKey: string; viewportWidth: number; viewportHeight: number; @@ -39,37 +39,25 @@ type ControllerMeasurementState = { offsetY: number; viewportMeasuredForKey: string | null; contentMeasuredForKey: string | null; -}; +} -type AttemptContext = { +interface AttemptContext { requestId: number | null; retries: number; confirmationPasses?: number; startedContentHeight?: number; startedOffsetY?: number; startedViewportHeight?: number; -}; +} -type ScheduledFrameHandle = { +interface ScheduledFrameHandle { cancelled: boolean; rafId: number | null; remainingFrames: number; callback: () => void; -}; +} -type BottomAnchorEvent = - | "request_created" - | "evaluate_called" - | "attempt_started" - | "attempt_verified" - | "attempt_failed" - | "request_fulfilled" - | "request_cancelled" - | "detached_by_user" - | "verification_scheduled" - | "blocked_reason_changed"; - -type BottomAnchorControllerDriver = { +interface BottomAnchorControllerDriver { destroy: () => void; getSnapshot: () => { mode: BottomAnchorMode; @@ -99,9 +87,9 @@ type BottomAnchorControllerDriver = { }) => void; notifyAuthoritativeHistoryMaybeChanged: () => void; reevaluate: (animated?: boolean) => void; -}; +} -type CreateBottomAnchorControllerDriverInput = { +interface CreateBottomAnchorControllerDriverInput { getAgentId: () => string; getIsAuthoritativeHistoryReady: () => boolean; getRenderStrategy: () => string; @@ -116,7 +104,7 @@ type CreateBottomAnchorControllerDriverInput = { delayFrames?: number; }) => unknown; cancelFrame: (handle: unknown) => void; -}; +} const MAX_VERIFICATION_RETRIES = 3; const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1; @@ -242,25 +230,6 @@ function shouldRequireRouteRequestConfirmation(input: { return input.confirmationPasses < 1; } -function getDetailedMeasurementState( - measurementState: ControllerMeasurementState, -): Record { - const distanceFromBottom = Math.max( - 0, - measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight), - ); - return { - containerKey: measurementState.containerKey, - viewportWidth: measurementState.viewportWidth, - viewportHeight: measurementState.viewportHeight, - contentHeight: measurementState.contentHeight, - offsetY: measurementState.offsetY, - distanceFromBottom, - viewportMeasuredForKey: measurementState.viewportMeasuredForKey, - contentMeasuredForKey: measurementState.contentMeasuredForKey, - }; -} - function createBottomAnchorControllerDriver( input: CreateBottomAnchorControllerDriverInput, ): BottomAnchorControllerDriver { @@ -275,7 +244,7 @@ function createBottomAnchorControllerDriver( let stickyMeasurementRevision = 0; let lastVerifiedStickyMeasurementRevision = 0; - const getLogContext = (extra?: Record) => { + const _getLogContext = (extra?: Record) => { const measurementState = input.getMeasurementState(); const distanceFromBottom = Math.max( 0, @@ -336,7 +305,7 @@ function createBottomAnchorControllerDriver( pendingVerification = null; }; - const cancelPendingRequest = (reason: string) => { + const cancelPendingRequest = (_reason: string) => { const currentRequest = pendingRequest; if (!currentRequest) { cancelPendingAttempt(); @@ -358,7 +327,7 @@ function createBottomAnchorControllerDriver( }); const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => { - const scheduledMeasurementState = input.getMeasurementState(); + const _scheduledMeasurementState = input.getMeasurementState(); if (verificationHandle) { input.cancelFrame(verificationHandle); } @@ -461,7 +430,7 @@ function createBottomAnchorControllerDriver( const evaluate = ( animated: boolean, - reason: + _reason: | "request_created" | "viewport_change" | "content_size_change" @@ -660,7 +629,7 @@ export const __private__ = { deriveBottomAnchorBlockedReason, deriveVerificationBlockedReason, deriveRetryDisposition, - deriveModeForLocalRequest(input: { + deriveModeForLocalRequest(_input: { reason: BottomAnchorLocalRequest["reason"]; }): BottomAnchorMode { return "sticky-bottom"; diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx index 469627700..5156fe66f 100644 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ b/packages/app/src/components/use-web-scrollbar.tsx @@ -60,8 +60,12 @@ export function useWebElementScrollbar( if (!element) return; element.setAttribute("data-hide-scrollbar", ""); - (element.style as any).scrollbarWidth = "none"; - (element.style as any).msOverflowStyle = "none"; + ( + element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } + ).scrollbarWidth = "none"; + ( + element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } + ).msOverflowStyle = "none"; ensureHideScrollbarStyle(); function update() { @@ -90,8 +94,12 @@ export function useWebElementScrollbar( element.removeEventListener("scroll", update); resizeObserver.disconnect(); element.removeAttribute("data-hide-scrollbar"); - (element.style as any).scrollbarWidth = ""; - (element.style as any).msOverflowStyle = ""; + ( + element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } + ).scrollbarWidth = ""; + ( + element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } + ).msOverflowStyle = ""; }; }, [contentRef, elementRef, enabled]); diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx index d396c2455..b6865c6e1 100644 --- a/packages/app/src/components/volume-meter.tsx +++ b/packages/app/src/components/volume-meter.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { View } from "react-native"; import ReanimatedAnimated, { useSharedValue, @@ -33,8 +33,18 @@ export function VolumeMeter({ // Base dimensions const LINE_SPACING = isCompact ? 6 : 8; const LINE_WIDTH = isCompact ? 6 : 8; - const MAX_HEIGHT = orientation === "horizontal" ? (isCompact ? 18 : 30) : isCompact ? 32 : 50; - const MIN_HEIGHT = orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20; + let MAX_HEIGHT: number; + if (orientation === "horizontal") { + MAX_HEIGHT = isCompact ? 18 : 30; + } else { + MAX_HEIGHT = isCompact ? 32 : 50; + } + let MIN_HEIGHT: number; + if (orientation === "horizontal") { + MIN_HEIGHT = isCompact ? 8 : 12; + } else { + MIN_HEIGHT = isCompact ? 14 : 20; + } // Create shared values for 3 dots unconditionally const animatedVolume = useSharedValue(0); @@ -82,7 +92,7 @@ export function VolumeMeter({ -1, false, ); - }, [isMuted]); + }, [isMuted, line1Pulse, line2Pulse, line3Pulse]); // Drive a single animated volume value and derive the individual bar heights // on the UI thread instead of scheduling three independent springs per sample. @@ -99,13 +109,20 @@ export function VolumeMeter({ }, [animatedVolume, isMuted, volume]); const lineColor = color ?? theme.colors.foreground; - const containerHeight = - orientation === "horizontal" ? (isCompact ? 32 : 60) : isCompact ? 64 : 100; + let containerHeight: number; + if (orientation === "horizontal") { + containerHeight = isCompact ? 32 : 60; + } else { + containerHeight = isCompact ? 64 : 100; + } // Create animated styles unconditionally at top level const line1Style = useAnimatedStyle(() => { const isActive = isSpeaking; - const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; + let baseOpacity: number; + if (isMuted) baseOpacity = 0.3; + else if (isActive) baseOpacity = 0.9; + else baseOpacity = 0.5; const currentVolume = isMuted ? 0 : animatedVolume.value; const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 1.2; const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; @@ -117,7 +134,10 @@ export function VolumeMeter({ const line2Style = useAnimatedStyle(() => { const isActive = isSpeaking; - const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; + let baseOpacity: number; + if (isMuted) baseOpacity = 0.3; + else if (isActive) baseOpacity = 0.9; + else baseOpacity = 0.5; const currentVolume = isMuted ? 0 : animatedVolume.value; const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 1.05; const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; @@ -129,7 +149,10 @@ export function VolumeMeter({ const line3Style = useAnimatedStyle(() => { const isActive = isSpeaking; - const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; + let baseOpacity: number; + if (isMuted) baseOpacity = 0.3; + else if (isActive) baseOpacity = 0.9; + else baseOpacity = 0.5; const currentVolume = isMuted ? 0 : animatedVolume.value; const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 0.9; const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; @@ -139,19 +162,35 @@ export function VolumeMeter({ }; }); + const containerStyle = useMemo( + () => [styles.container, { height: containerHeight }], + [containerHeight], + ); + const lineBase = useMemo( + () => ({ width: LINE_WIDTH, backgroundColor: lineColor }), + [LINE_WIDTH, lineColor], + ); + const spacerStyle = useMemo(() => ({ width: LINE_SPACING }), [LINE_SPACING]); + const line1CombinedStyle = useMemo( + () => [styles.line, lineBase, line1Style], + [lineBase, line1Style], + ); + const line2CombinedStyle = useMemo( + () => [styles.line, lineBase, line2Style], + [lineBase, line2Style], + ); + const line3CombinedStyle = useMemo( + () => [styles.line, lineBase, line3Style], + [lineBase, line3Style], + ); + return ( - - - - - - + + + + + + ); } diff --git a/packages/app/src/components/web-desktop-scrollbar.math.ts b/packages/app/src/components/web-desktop-scrollbar.math.ts index 05a7f3259..cdf155d7a 100644 --- a/packages/app/src/components/web-desktop-scrollbar.math.ts +++ b/packages/app/src/components/web-desktop-scrollbar.math.ts @@ -4,20 +4,20 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -export type VerticalScrollbarGeometryInput = { +export interface VerticalScrollbarGeometryInput { viewportSize: number; contentSize: number; offset: number; minHandleSize?: number; -}; +} -export type VerticalScrollbarGeometry = { +export interface VerticalScrollbarGeometry { isVisible: boolean; maxScrollOffset: number; handleSize: number; handleOffset: number; maxHandleOffset: number; -}; +} export function computeVerticalScrollbarGeometry( input: VerticalScrollbarGeometryInput, @@ -55,12 +55,12 @@ export function computeVerticalScrollbarGeometry( }; } -export type ScrollOffsetFromDragDeltaInput = { +export interface ScrollOffsetFromDragDeltaInput { startOffset: number; dragDelta: number; maxScrollOffset: number; maxHandleOffset: number; -}; +} export function computeScrollOffsetFromDragDelta(input: ScrollOffsetFromDragDeltaInput): number { if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) { diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index ce0977038..947e0c64f 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -27,7 +27,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240; const HANDLE_SCROLL_VISIBILITY_MS = 1200; const HANDLE_SCROLL_ACTIVE_MS = 110; -function readClientY(event: any): number | null { +interface PointerLikeEvent { + clientY?: number; + pageY?: number; + nativeEvent?: { clientY?: number; pageY?: number; preventDefault?: () => void }; + preventDefault?: () => void; + stopPropagation?: () => void; +} + +function readClientY(event: PointerLikeEvent): number | null { const value = event?.nativeEvent?.clientY ?? event?.clientY ?? event?.nativeEvent?.pageY ?? event?.pageY; return typeof value === "number" ? value : null; @@ -37,11 +45,11 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -export type ScrollbarMetrics = { +export interface ScrollbarMetrics { offset: number; viewportSize: number; contentSize: number; -}; +} function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean { return ( @@ -107,12 +115,12 @@ export function useWebDesktopScrollbarMetrics() { }; } -type WebDesktopScrollbarOverlayProps = { +interface WebDesktopScrollbarOverlayProps { enabled: boolean; metrics: ScrollbarMetrics; onScrollToOffset: (offset: number) => void; inverted?: boolean; -}; +} export function WebDesktopScrollbarOverlay({ enabled, @@ -281,7 +289,7 @@ export function WebDesktopScrollbarOverlay({ }); }, [applyDragDelta]); - const startWebDrag = useCallback((event: any) => { + const startWebDrag = useCallback((event: PointerLikeEvent) => { if (!platformIsWeb) { return; } @@ -333,18 +341,12 @@ export function WebDesktopScrollbarOverlay({ }; }, [applyDragDelta, isDragging]); - if (!enabled || !geometry.isVisible) { - return null; - } - const handleVisible = isDragging || isScrollVisible || isHandleHovered; - const handleOpacity = isDragging - ? HANDLE_OPACITY_DRAGGING - : isHandleHovered - ? HANDLE_OPACITY_HOVERED - : isScrollVisible - ? HANDLE_OPACITY_VISIBLE - : 0; + let handleOpacity: number; + if (isDragging) handleOpacity = HANDLE_OPACITY_DRAGGING; + else if (isHandleHovered) handleOpacity = HANDLE_OPACITY_HOVERED; + else if (isScrollVisible) handleOpacity = HANDLE_OPACITY_VISIBLE; + else handleOpacity = 0; const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE; const handleColor = theme.colors.scrollbarHandle; const handleCursor = isDragging ? "grabbing" : "grab"; @@ -357,26 +359,55 @@ export function WebDesktopScrollbarOverlay({ ); const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2); + const thumbRegionStyle = useMemo( + () => [ + styles.thumbRegion, + { + top: 0, + height: thumbRegionHeight, + transform: [{ translateY: thumbRegionOffset }], + }, + platformIsWeb && + ({ + cursor: handleCursor, + touchAction: "none", + userSelect: "none", + transitionProperty: "transform", + transitionDuration: `${handleTravelDurationMs}ms`, + transitionTimingFunction: "linear", + } as object), + ], + [thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs], + ); + + const handleStyle = useMemo( + () => [ + styles.handle, + { + marginTop: handleInsetTop, + height: geometry.handleSize, + width: handleWidth, + backgroundColor: handleColor, + opacity: handleOpacity, + }, + platformIsWeb && + ({ + transitionProperty: "opacity, width, background-color", + transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, + transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", + } as object), + ], + [handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity], + ); + + if (!enabled || !geometry.isVisible) { + return null; + } + return ( - + ); diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 52e740590..84f34344f 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -16,14 +16,14 @@ import { PaseoLogo } from "@/components/icons/paseo-logo"; import { openExternalUrl } from "@/utils/open-external-url"; import { isWeb, isNative } from "@/constants/platform"; -type WelcomeAction = { +interface WelcomeAction { key: "scan-qr" | "direct-connection" | "paste-pairing-link"; label: string; testID: string; primary: boolean; icon: typeof QrCode; onPress: () => void; -}; +} const styles = StyleSheet.create((theme) => ({ root: { @@ -178,6 +178,30 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { [router], ); + const handleOpenPaseoSite = useCallback(() => { + void openExternalUrl("https://paseo.sh"); + }, []); + + const handleOpenSettings = useCallback(() => { + router.push("/settings"); + }, [router]); + + const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []); + const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []); + const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []); + const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []); + const handleScanQr = useCallback(() => { + router.push("/pair-scan?source=onboarding"); + }, [router]); + + const handleHostSaved = useCallback( + ({ profile, serverId }: { profile: HostProfile; serverId: string }) => { + onHostAdded?.(profile); + finishOnboarding(serverId); + }, + [onHostAdded, finishOnboarding], + ); + const actions: WelcomeAction[] = isWeb ? [ { @@ -186,7 +210,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { testID: "welcome-direct-connection", primary: true, icon: Link2, - onPress: () => setIsDirectOpen(true), + onPress: handleOpenDirect, }, { key: "paste-pairing-link", @@ -194,7 +218,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { testID: "welcome-paste-pairing-link", primary: false, icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), + onPress: handleOpenPasteLink, }, ] : [ @@ -204,7 +228,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { testID: "welcome-scan-qr", primary: true, icon: QrCode, - onPress: () => router.push("/pair-scan?source=onboarding"), + onPress: handleScanQr, }, { key: "direct-connection", @@ -212,7 +236,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { testID: "welcome-direct-connection", primary: false, icon: Link2, - onPress: () => setIsDirectOpen(true), + onPress: handleOpenDirect, }, { key: "paste-pairing-link", @@ -220,20 +244,22 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { testID: "welcome-paste-pairing-link", primary: false, icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), + onPress: handleOpenPasteLink, }, ]; const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId; + const scrollContentContainerStyle = useMemo( + () => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }], + [theme.spacing, insets.bottom], + ); + return ( @@ -247,10 +273,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { Welcome to Paseo Connect your computer to get started {isNative ? ( - openExternalUrl("https://paseo.sh")} - > + paseo.sh @@ -260,34 +283,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { - {actions.map((action) => { - const Icon = action.icon; - return ( - - - - {action.label} - - - ); - })} + {actions.map((action) => ( + + ))} + const refreshIcon = useMemo( + () => , + [theme.iconSize.md, theme.colors.foregroundMuted], ); + const refreshButton = useMemo( + () => ( + + ), + [refreshIcon, handleRefreshPress, isBusy, isRefreshing], + ); + + if (!isDesktopApp) { + return null; + } + return ( @@ -50,29 +78,19 @@ export function DesktopPermissionsSection() { title="Notifications" status={snapshot?.notifications ?? null} isRequesting={requestingPermission === "notifications"} - onRequest={() => { - void requestPermission("notifications"); - }} + onRequest={handleRequestNotifications} extraActionLabel="Test" isExtraActionBusy={isSendingTestNotification} isExtraActionDisabled={!notificationsGranted || isBusy} - onExtraAction={() => { - void sendTestNotification(); - }} + onExtraAction={handleSendTestNotification} /> - {testNotificationError ? ( - - {testNotificationError} - - ) : null} + {testNotificationError ? {testNotificationError} : null} { - void requestPermission("microphone"); - }} + onRequest={handleRequestMicrophone} /> diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 60d2401d2..f1d25066b 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -19,36 +19,33 @@ import { stopDesktopDaemon, } from "@/desktop/daemon/desktop-daemon"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; +import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; import { resolveAppVersion } from "@/utils/app-version"; -export function LocalDaemonSection() { - const { theme } = useUnistyles(); - const showSection = shouldUseDesktopDaemon(); - const appVersion = resolveAppVersion(); - const { settings, updateSettings } = useAppSettings(); - const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); +function getDaemonManagementButtonLabel(isUpdating: boolean, isPaused: boolean): string { + if (isUpdating) return isPaused ? "Resuming..." : "Pausing..."; + return isPaused ? "Resume" : "Pause"; +} + +function getDaemonRestartButtonLabel( + isRestarting: boolean, + daemonStatus: string | undefined, + actionLabel: string, +): string { + if (!isRestarting) return actionLabel; + return daemonStatus === "running" ? "Restarting..." : "Starting..."; +} + +function useDaemonRestartAction(args: { + showSection: boolean; + daemonStatus: DesktopDaemonStatus | null; + daemonActionLabel: string; + setStatus: (status: DesktopDaemonStatus) => void; + refetch: () => void; +}) { + const { showSection, daemonStatus, daemonActionLabel, setStatus, refetch } = args; const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); - const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [statusMessage, setStatusMessage] = useState(null); - const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); - const [cliStatusOutput, setCliStatusOutput] = useState(null); - const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); - const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); - - const daemonStatus = data?.status ?? null; - const daemonLogs = data?.logs ?? null; - const daemonVersion = daemonStatus?.version ?? null; - - const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); - const daemonStatusStateText = - statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running"); - const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; - const isDaemonManagementPaused = !settings.manageBuiltInDaemon; - const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon"; - const daemonActionMessage = - daemonStatus?.status === "running" - ? "Restarts the built-in daemon." - : "Starts the built-in daemon."; const handleUpdateLocalDaemon = useCallback(() => { if (!showSection || isRestartingDaemon) { @@ -82,6 +79,7 @@ export function LocalDaemonSection() { daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.", ); refetch(); + return; }) .catch((error) => { console.error("[Settings] Failed to change desktop daemon state", error); @@ -91,6 +89,7 @@ export function LocalDaemonSection() { .finally(() => { setIsRestartingDaemon(false); }); + return; }) .catch((error) => { console.error("[Settings] Failed to open desktop daemon action confirmation", error); @@ -105,6 +104,20 @@ export function LocalDaemonSection() { showSection, ]); + return { isRestartingDaemon, statusMessage, setStatusMessage, handleUpdateLocalDaemon }; +} + +function useDaemonManagementToggle(args: { + daemonStatus: DesktopDaemonStatus | null; + settings: { manageBuiltInDaemon: boolean }; + updateSettings: (next: { manageBuiltInDaemon: boolean }) => Promise; + setStatus: (status: DesktopDaemonStatus) => void; + setStatusMessage: (message: string | null) => void; + refetch: () => void; +}) { + const { daemonStatus, settings, updateSettings, setStatus, setStatusMessage, refetch } = args; + const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); + const handleToggleDaemonManagement = useCallback(() => { if (isUpdatingDaemonManagement) { return; @@ -116,6 +129,7 @@ export function LocalDaemonSection() { void updateSettings({ manageBuiltInDaemon: true }) .then(() => { setStatusMessage("Built-in daemon management resumed."); + return; }) .catch((error) => { console.error("[Settings] Failed to update built-in daemon management", error); @@ -158,6 +172,7 @@ export function LocalDaemonSection() { .then(() => { refetch(); setStatusMessage("Built-in daemon paused and stopped."); + return; }) .catch((error) => { console.error("[Settings] Failed to pause built-in daemon management", error); @@ -166,6 +181,7 @@ export function LocalDaemonSection() { .finally(() => { setIsUpdatingDaemonManagement(false); }); + return; }) .catch((error) => { console.error("[Settings] Failed to open built-in daemon pause confirmation", error); @@ -176,32 +192,18 @@ export function LocalDaemonSection() { isUpdatingDaemonManagement, refetch, setStatus, + setStatusMessage, settings.manageBuiltInDaemon, updateSettings, ]); - const handleCopyLogPath = useCallback(() => { - const logPath = daemonLogs?.logPath; - if (!logPath) { - return; - } + return { isUpdatingDaemonManagement, handleToggleDaemonManagement }; +} - void Clipboard.setStringAsync(logPath) - .then(() => { - Alert.alert("Copied", "Log path copied."); - }) - .catch((error) => { - console.error("[Settings] Failed to copy log path", error); - Alert.alert("Error", "Unable to copy log path."); - }); - }, [daemonLogs?.logPath]); - - const handleOpenLogs = useCallback(() => { - if (!daemonLogs) { - return; - } - setIsLogsModalOpen(true); - }, [daemonLogs]); +function useDaemonCliStatusModal() { + const [cliStatusOutput, setCliStatusOutput] = useState(null); + const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); + const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); const handleOpenCliStatus = useCallback(async () => { setIsLoadingCliStatus(true); @@ -224,30 +226,376 @@ export function LocalDaemonSection() { void Clipboard.setStringAsync(cliStatusOutput) .then(() => { Alert.alert("Copied", "Status copied to clipboard."); + return; }) .catch((error) => { console.error("[Settings] Failed to copy daemon status", error); }); }, [cliStatusOutput]); + const handleRunCliStatus = useCallback(() => { + void handleOpenCliStatus(); + }, [handleOpenCliStatus]); + + const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []); + + return { + cliStatusOutput, + isCliStatusModalOpen, + isLoadingCliStatus, + handleCopyCliStatus, + handleRunCliStatus, + handleCloseCliStatusModal, + }; +} + +function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) { + const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); + + const handleCopyLogPath = useCallback(() => { + const logPath = daemonLogs?.logPath; + if (!logPath) { + return; + } + + void Clipboard.setStringAsync(logPath) + .then(() => { + Alert.alert("Copied", "Log path copied."); + return; + }) + .catch((error) => { + console.error("[Settings] Failed to copy log path", error); + Alert.alert("Error", "Unable to copy log path."); + }); + }, [daemonLogs?.logPath]); + + const handleOpenLogs = useCallback(() => { + if (!daemonLogs) { + return; + } + setIsLogsModalOpen(true); + }, [daemonLogs]); + + const handleCloseLogsModal = useCallback(() => setIsLogsModalOpen(false), []); + + return { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal }; +} + +interface DaemonLogsModalProps { + visible: boolean; + onClose: () => void; + daemonLogs: { logPath?: string; contents?: string } | null; +} + +function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) { + return ( + + + {daemonLogs?.logPath ?? "Log path unavailable."} + + {daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"} + + + + ); +} + +interface DaemonCliStatusModalProps { + visible: boolean; + onClose: () => void; + cliStatusOutput: string | null; + onCopy: () => void; +} + +function DaemonCliStatusModal({ + visible, + onClose, + cliStatusOutput, + onCopy, +}: DaemonCliStatusModalProps) { + return ( + + + + {cliStatusOutput ?? ""} + + + + + + + + ); +} + +interface DaemonInfoCardProps { + daemonStatusStateText: string; + daemonStatusDetailText: string; + isDaemonManagementPaused: boolean; + playIcon: React.ReactElement; + pauseIcon: React.ReactElement; + rotateIcon: React.ReactElement; + copyIcon: React.ReactElement; + fileTextIcon: React.ReactElement; + activityIcon: React.ReactElement; + handleToggleDaemonManagement: () => void; + isUpdatingDaemonManagement: boolean; + daemonActionLabel: string; + daemonActionMessage: string; + statusMessage: string | null; + handleUpdateLocalDaemon: () => void; + isRestartingDaemon: boolean; + daemonStatus: DesktopDaemonStatus | null; + daemonLogs: { logPath?: string } | null; + handleCopyLogPath: () => void; + handleOpenLogs: () => void; + handleRunCliStatus: () => void; + isLoadingCliStatus: boolean; +} + +function DaemonInfoCard(props: DaemonInfoCardProps) { + const { + daemonStatusStateText, + daemonStatusDetailText, + isDaemonManagementPaused, + playIcon, + pauseIcon, + rotateIcon, + copyIcon, + fileTextIcon, + activityIcon, + handleToggleDaemonManagement, + isUpdatingDaemonManagement, + daemonActionLabel, + daemonActionMessage, + statusMessage, + handleUpdateLocalDaemon, + isRestartingDaemon, + daemonStatus, + daemonLogs, + handleCopyLogPath, + handleOpenLogs, + handleRunCliStatus, + isLoadingCliStatus, + } = props; + + return ( + + + + Status + + Only the built-in desktop daemon is shown here. + + + + {daemonStatusStateText} + {daemonStatusDetailText} + + + + + Daemon management + + {isDaemonManagementPaused + ? "Paused. The built-in daemon stays stopped until you start it again." + : "Enabled. Paseo can manage the built-in daemon from the desktop app."} + + + + + + + {daemonActionLabel} + {daemonActionMessage} + {statusMessage ? {statusMessage} : null} + + + + + + Log file + + {daemonLogs?.logPath ?? "Log path unavailable."} + + + + {daemonLogs?.logPath ? ( + + ) : null} + + + + + + Full status + + Runs `paseo daemon status` and shows the output. + + + + + + ); +} + +export function LocalDaemonSection() { + const { theme } = useUnistyles(); + const showSection = shouldUseDesktopDaemon(); + const appVersion = resolveAppVersion(); + const { settings, updateSettings } = useAppSettings(); + const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); + + const daemonStatus = data?.status ?? null; + const daemonLogs = data?.logs ?? null; + const daemonVersion = daemonStatus?.version ?? null; + + const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); + const daemonStatusStateText = + statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running"); + const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; + const isDaemonManagementPaused = !settings.manageBuiltInDaemon; + const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon"; + const daemonActionMessage = + daemonStatus?.status === "running" + ? "Restarts the built-in daemon." + : "Starts the built-in daemon."; + + const { isRestartingDaemon, statusMessage, setStatusMessage, handleUpdateLocalDaemon } = + useDaemonRestartAction({ + showSection, + daemonStatus, + daemonActionLabel, + setStatus, + refetch, + }); + + const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({ + daemonStatus, + settings, + updateSettings, + setStatus, + setStatusMessage, + refetch, + }); + + const { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal } = + useDaemonLogsModal(daemonLogs); + + const { + cliStatusOutput, + isCliStatusModalOpen, + isLoadingCliStatus, + handleCopyCliStatus, + handleRunCliStatus, + handleCloseCliStatusModal, + } = useDaemonCliStatusModal(); + + const handleOpenAdvancedSettings = useCallback( + () => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL), + [], + ); + + const advancedSettingsIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foregroundMuted], + ); + const playIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + const pauseIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + const rotateIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + const copyIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + const fileTextIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + const activityIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + + const advancedSettingsButton = useMemo( + () => ( + + ), + [advancedSettingsIcon, handleOpenAdvancedSettings], + ); + if (!showSection) { return null; } - const advancedSettingsButton = ( - - ); - return ( {isLoading ? ( - + ) : ( <> - - - - Status - - Only the built-in desktop daemon is shown here. - - - - {daemonStatusStateText} - {daemonStatusDetailText} - - - - - Daemon management - - {isDaemonManagementPaused - ? "Paused. The built-in daemon stays stopped until you start it again." - : "Enabled. Paseo can manage the built-in daemon from the desktop app."} - - - - - - - {daemonActionLabel} - {daemonActionMessage} - {statusMessage ? {statusMessage} : null} - - - - - - Log file - - {daemonLogs?.logPath ?? "Log path unavailable."} - - - - {daemonLogs?.logPath ? ( - - ) : null} - - - - - - Full status - - Runs `paseo daemon status` and shows the output. - - - - - + {daemonVersionMismatch ? ( - App and daemon versions don't match. Update both to the same version for the best - experience. + { + "App and daemon versions don't match. Update both to the same version for the best experience." + } ) : null} )} - setIsLogsModalOpen(false)} - title="Daemon logs" - testID="managed-daemon-logs-dialog" - snapPoints={["70%", "92%"]} - > - - - {daemonLogs?.logPath ?? "Log path unavailable."} - - - {daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"} - - - + onClose={handleCloseLogsModal} + daemonLogs={daemonLogs} + /> - setIsCliStatusModalOpen(false)} - title="Daemon status" - testID="daemon-cli-status-dialog" - snapPoints={["60%", "85%"]} - > - - - {cliStatusOutput ?? ""} - - - - - - - + onClose={handleCloseCliStatusModal} + cliStatusOutput={cliStatusOutput} + onCopy={handleCopyCliStatus} + /> ); } @@ -485,3 +721,8 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, })); + +const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard]; +const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; +const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"]; +const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"]; diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index e5ec00812..6a6853f35 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -18,6 +18,7 @@ import { const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; +const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; export function IntegrationsSection() { const { theme } = useUnistyles(); @@ -76,37 +77,53 @@ export function IntegrationsSection() { }); }, [isInstallingSkills]); + const handleOpenCliDocs = useCallback(() => { + void openExternalUrl(CLI_DOCS_URL); + }, []); + + const handleOpenSkillsDocs = useCallback(() => { + void openExternalUrl(SKILLS_DOCS_URL); + }, []); + + const arrowIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foregroundMuted], + ); + + const trailing = useMemo( + () => ( + + + + + ), + [arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs], + ); + if (!showSection) { return null; } - const trailing = ( - - - - - ); - return ( @@ -136,7 +153,7 @@ export function IntegrationsSection() { )} - + diff --git a/packages/app/src/desktop/components/pair-device-modal.tsx b/packages/app/src/desktop/components/pair-device-modal.tsx index 4a774b2c4..548ca557f 100644 --- a/packages/app/src/desktop/components/pair-device-modal.tsx +++ b/packages/app/src/desktop/components/pair-device-modal.tsx @@ -7,13 +7,15 @@ export interface PairDeviceModalProps { testID?: string; } +const SNAP_POINTS: string[] = ["82%", "94%"]; + export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { return ( diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 36ed801aa..39e468f31 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { ActivityIndicator, Image, Text, TextInput, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import * as QRCode from "qrcode"; @@ -10,6 +10,34 @@ import { Button } from "@/components/ui/button"; import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { useState } from "react"; +type PairingViewState = + | { tag: "loading" } + | { tag: "error"; message: string } + | { tag: "unavailable"; message: string } + | { tag: "ready"; url: string }; + +function resolvePairingViewState(args: { + isPending: boolean; + isError: boolean; + error: unknown; + data: { url?: string | null; relayEnabled?: boolean } | undefined; +}): PairingViewState { + if (args.isPending) return { tag: "loading" }; + if (args.isError) { + const message = + args.error instanceof Error ? args.error.message : "Failed to load pairing offer."; + return { tag: "error", message }; + } + if (!args.data?.url) { + const message = + args.data?.relayEnabled === false + ? "Relay is not enabled. Enable relay to pair a device." + : "Pairing offer unavailable."; + return { tag: "unavailable", message }; + } + return { tag: "ready", url: args.data.url }; +} + export function PairDeviceSection() { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); @@ -42,94 +70,145 @@ export function PairDeviceSection() { setTimeout(() => setCopied(false), 2000); }, [pairingQuery.data?.url]); + const handleRefetch = useCallback(() => { + void pairingQuery.refetch(); + }, [pairingQuery]); + + const handleCopyPress = useCallback(() => { + void handleCopyLink(); + }, [handleCopyLink]); + + const qrImageSource = useMemo( + () => (qrQuery.data ? { uri: qrQuery.data } : null), + [qrQuery.data], + ); + + const retryIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + const copyButtonIcon = useMemo( + () => + copied ? ( + + ) : ( + + ), + [copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground], + ); + if (!showSection) return null; + const viewState = resolvePairingViewState({ + isPending: pairingQuery.isPending, + isError: pairingQuery.isError, + error: pairingQuery.error, + data: pairingQuery.data, + }); + return ( - {pairingQuery.isPending ? ( - - - Loading pairing offer… - - ) : pairingQuery.isError ? ( - - - {pairingQuery.error instanceof Error - ? pairingQuery.error.message - : "Failed to load pairing offer."} - - - - ) : !pairingQuery.data?.url ? ( - - - {pairingQuery.data?.relayEnabled === false - ? "Relay is not enabled. Enable relay to pair a device." - : "Pairing offer unavailable."} - - - - ) : ( - - - Scan this QR code with Paseo on your phone, or copy the link below. - - - {qrQuery.data ? ( - - ) : qrQuery.isError ? ( - QR code unavailable. - ) : ( - - )} - - - - - - - - - )} + ); } +interface PairDeviceBodyProps { + viewState: PairingViewState; + theme: { colors: { accent: string } }; + retryIcon: React.ReactElement; + copyButtonIcon: React.ReactElement; + qrImageSource: { uri: string } | null; + qrQuery: { isError: boolean }; + copied: boolean; + handleRefetch: () => void; + handleCopyPress: () => void; +} + +function PairDeviceBody(props: PairDeviceBodyProps) { + const { + viewState, + theme, + retryIcon, + copyButtonIcon, + qrImageSource, + qrQuery, + copied, + handleRefetch, + handleCopyPress, + } = props; + + if (viewState.tag === "loading") { + return ( + + + Loading pairing offer… + + ); + } + + if (viewState.tag === "error" || viewState.tag === "unavailable") { + return ( + + {viewState.message} + + + ); + } + + return ( + + + Scan this QR code with Paseo on your phone, or copy the link below. + + + + + + + + + + + + ); +} + +function PairDeviceQrContent(props: { + qrImageSource: { uri: string } | null; + qrQuery: { isError: boolean }; +}) { + if (props.qrImageSource) { + return ; + } + if (props.qrQuery.isError) { + return QR code unavailable.; + } + return ; +} + const styles = StyleSheet.create((theme) => ({ centered: { alignItems: "center", @@ -182,5 +261,5 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], outlineStyle: "none", - } as any, + } as object, })); diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts index b7ed732ff..f999a1a13 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts @@ -16,7 +16,7 @@ const desktopDaemonMock = vi.hoisted(() => { const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise>(); const listenToLocalTransportEvents = vi.fn( async ( - handler: typeof eventHandler extends ((...args: infer A) => any) | null + handler: typeof eventHandler extends ((...args: infer A) => unknown) | null ? (...args: A) => void : never, ) => { diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index ded938b64..46a98bcea 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -117,6 +117,7 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto return; } unlisten = cleanup; + return; }) .catch((error) => { emitError(error); @@ -130,6 +131,7 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto } sessionId = id; emitOpen(); + return; }) .catch((error) => { emitError(error); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 9204dc7b3..cf6bf859b 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -3,7 +3,7 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke"; export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; -export type DesktopDaemonStatus = { +export interface DesktopDaemonStatus { serverId: string; status: DesktopDaemonState; listen: string | null; @@ -13,25 +13,26 @@ export type DesktopDaemonStatus = { version: string | null; desktopManaged: boolean; error: string | null; -}; +} -export type DesktopDaemonLogs = { +export interface DesktopDaemonLogs { logPath: string; contents: string; -}; +} -export type DesktopPairingOffer = { +export interface DesktopPairingOffer { relayEnabled: boolean; url: string | null; qr: string | null; -}; +} -export type LocalTransportTarget = { +export interface LocalTransportTarget { + [key: string]: unknown; transportType: "socket" | "pipe"; transportPath: string; -}; +} -type LocalTransportEventPayload = { +interface LocalTransportEventPayload { sessionId: string; kind: "open" | "message" | "close" | "error"; text?: string | null; @@ -39,7 +40,7 @@ type LocalTransportEventPayload = { code?: number | null; reason?: string | null; error?: string | null; -}; +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; diff --git a/packages/app/src/desktop/electron/events.ts b/packages/app/src/desktop/electron/events.ts index 409f13a06..095cac731 100644 --- a/packages/app/src/desktop/electron/events.ts +++ b/packages/app/src/desktop/electron/events.ts @@ -2,9 +2,9 @@ import { getDesktopHost } from "@/desktop/host"; export type DesktopEventUnlisten = () => void; -type EventEnvelope = { +interface EventEnvelope { payload?: unknown; -}; +} export async function listenToDesktopEvent( event: string, diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts index 899b4e663..08fd2de29 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; type MockPlatform = "web" | "ios" | "android"; -type GlobalSnapshot = { +interface GlobalSnapshot { Notification: unknown; navigatorDescriptor?: PropertyDescriptor; windowDescriptor?: PropertyDescriptor; paseoDesktop: unknown; -}; +} const originalGlobals: GlobalSnapshot = { Notification: (globalThis as { Notification?: unknown }).Notification, @@ -86,9 +86,7 @@ describe("desktop-permissions", () => { }); it("reads notification and microphone status", async () => { - class MockNotification { - static permission = "default"; - } + const MockNotification = { permission: "default" }; (globalThis as { Notification?: unknown }).Notification = MockNotification; setNavigator({ permissions: { @@ -152,10 +150,10 @@ describe("desktop-permissions", () => { }); it("requests notification permission via the browser Notification API", async () => { - class MockNotification { - static permission = "default"; - static requestPermission = vi.fn(async () => "granted"); - } + const MockNotification = { + permission: "default", + requestPermission: vi.fn(async () => "granted"), + }; (globalThis as { Notification?: unknown }).Notification = MockNotification; const { requestDesktopPermission } = await loadModuleForPlatform("web"); @@ -166,9 +164,7 @@ describe("desktop-permissions", () => { }); it("reads browser Notification permission when available", async () => { - class MockNotification { - static permission = "denied"; - } + const MockNotification = { permission: "denied" }; (globalThis as { Notification?: unknown }).Notification = MockNotification; setNavigator({}); diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts index 9e10ca8ac..12964e1ea 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.ts @@ -22,27 +22,27 @@ export interface DesktopPermissionSnapshot { microphone: DesktopPermissionStatus; } -type NotificationConstructorLike = { +interface NotificationConstructorLike { permission?: string; requestPermission?: () => Promise; -}; +} -type MediaStreamTrackLike = { +interface MediaStreamTrackLike { stop?: () => void; -}; +} -type MediaStreamLike = { +interface MediaStreamLike { getTracks?: () => MediaStreamTrackLike[]; -}; +} -type NavigatorLike = { +interface NavigatorLike { mediaDevices?: { getUserMedia?: (constraints: { audio: boolean }) => Promise; }; permissions?: { query?: (descriptor: { name: string }) => Promise<{ state?: string }>; }; -}; +} export function shouldShowDesktopPermissionSection(): boolean { return isWeb && getDesktopHost() !== null; diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx index 26c804f09..6a39bd7bb 100644 --- a/packages/app/src/desktop/updates/update-callout-source.tsx +++ b/packages/app/src/desktop/updates/update-callout-source.tsx @@ -10,6 +10,57 @@ import { openExternalUrl } from "@/utils/open-external-url"; const CHECK_INTERVAL_MS = 30 * 60 * 1000; const CHANGELOG_URL = "https://paseo.sh/changelog"; +function resolveUpdateCalloutTitle(args: { + isInstalled: boolean; + isInstalling: boolean; + isError: boolean; +}): string { + if (args.isInstalled) return "Update installed"; + if (args.isInstalling) return "Installing update"; + if (args.isError) return "Update failed"; + return "Update available"; +} + +function resolveUpdateCalloutDescription(args: { + isInstalled: boolean; + isInstalling: boolean; + isError: boolean; + errorMessage: string | null; + latestVersion: string | undefined; +}): ReactNode { + if (args.isInstalled) return "Restart to use the new version."; + if (args.isInstalling) return "Installing and restarting..."; + if (args.isError) return args.errorMessage ?? "Something went wrong."; + if (args.latestVersion) { + return ( + + ); + } + return ; +} + +function buildUpdateCalloutActions(args: { + isInstalled: boolean; + isInstalling: boolean; + isError: boolean; + openChangelog: () => void; + retry: () => void; + install: () => void; +}): SidebarCalloutAction[] { + const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }]; + if (args.isError) { + actions.push({ label: "Retry", onPress: args.retry, variant: "primary" }); + } else if (!args.isInstalled) { + actions.push({ + label: args.isInstalling ? "Installing..." : "Install & restart", + onPress: args.install, + variant: "primary", + disabled: args.isInstalling, + }); + } + return actions; +} + export function UpdateCalloutSource() { const callouts = useSidebarCallouts(); const { theme } = useUnistyles(); @@ -66,42 +117,22 @@ export function UpdateCalloutSource() { const isError = status === "error"; const isAvailable = !isInstalled && !isInstalling && !isError; - let title = "Update available"; - if (isInstalled) { - title = "Update installed"; - } else if (isInstalling) { - title = "Installing update"; - } else if (isError) { - title = "Update failed"; - } - - let description: ReactNode = ; - if (isInstalled) { - description = "Restart to use the new version."; - } else if (isInstalling) { - description = "Installing and restarting..."; - } else if (isError) { - description = errorMessage ?? "Something went wrong."; - } else if (availableUpdate?.latestVersion) { - description = ( - - ); - } - - const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: openChangelog }]; - - if (isError) { - actions.push({ label: "Retry", onPress: retry, variant: "primary" }); - } else if (!isInstalled) { - actions.push({ - label: isInstalling ? "Installing..." : "Install & restart", - onPress: install, - variant: "primary", - disabled: isInstalling, - }); - } + const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError }); + const description = resolveUpdateCalloutDescription({ + isInstalled, + isInstalling, + isError, + errorMessage, + latestVersion: availableUpdate?.latestVersion ?? undefined, + }); + const actions = buildUpdateCalloutActions({ + isInstalled, + isInstalling, + isError, + openChangelog, + retry, + install, + }); return callouts.show({ id: "desktop-update", diff --git a/packages/app/src/dictation/dictation-stream-sender.test.ts b/packages/app/src/dictation/dictation-stream-sender.test.ts index d7c6d9eaf..ee5707a8d 100644 --- a/packages/app/src/dictation/dictation-stream-sender.test.ts +++ b/packages/app/src/dictation/dictation-stream-sender.test.ts @@ -1,10 +1,22 @@ import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@server/client/daemon-client"; import { DictationStreamSender } from "@/dictation/dictation-stream-sender"; -type FakeFinish = { dictationId: string; finalSeq: number }; -type FakeStart = { dictationId: string; format: string }; -type FakeChunk = { dictationId: string; seq: number; audio: string; format: string }; +interface FakeFinish { + dictationId: string; + finalSeq: number; +} +interface FakeStart { + dictationId: string; + format: string; +} +interface FakeChunk { + dictationId: string; + seq: number; + audio: string; + format: string; +} class FakeDaemonClient { isConnected = true; @@ -44,7 +56,7 @@ describe("DictationStreamSender", () => { const client = new FakeDaemonClient(); const ids = ["d1"]; const sender = new DictationStreamSender({ - client: client as any, + client: client as unknown as DaemonClient, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); @@ -65,7 +77,7 @@ describe("DictationStreamSender", () => { const client = new FakeDaemonClient(); const ids = ["d1", "d2"]; const sender = new DictationStreamSender({ - client: client as any, + client: client as unknown as DaemonClient, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); @@ -88,7 +100,7 @@ describe("DictationStreamSender", () => { const client = new FakeDaemonClient(); const ids = ["d1"]; const sender = new DictationStreamSender({ - client: client as any, + client: client as unknown as DaemonClient, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); @@ -109,7 +121,7 @@ describe("DictationStreamSender", () => { client.isConnected = false; const ids = ["d1"]; const sender = new DictationStreamSender({ - client: client as any, + client: client as unknown as DaemonClient, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); diff --git a/packages/app/src/dictation/dictation-stream-sender.ts b/packages/app/src/dictation/dictation-stream-sender.ts index a4bbf218e..8729a8b3b 100644 --- a/packages/app/src/dictation/dictation-stream-sender.ts +++ b/packages/app/src/dictation/dictation-stream-sender.ts @@ -1,13 +1,16 @@ import { generateMessageId } from "@/types/stream"; import type { DaemonClient } from "@server/client/daemon-client"; -export type DictationStreamSenderParams = { +export interface DictationStreamSenderParams { client: DaemonClient | null; format: string; createDictationId?: () => string; -}; +} -type DictationFinishResult = { dictationId: string; text: string }; +interface DictationFinishResult { + dictationId: string; + text: string; +} /** * Small, non-React state machine for dictation streaming. diff --git a/packages/app/src/hooks/image-attachment-picker.test.ts b/packages/app/src/hooks/image-attachment-picker.test.ts index ad7ea2238..3ae194dc0 100644 --- a/packages/app/src/hooks/image-attachment-picker.test.ts +++ b/packages/app/src/hooks/image-attachment-picker.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const desktopHostState = vi.hoisted(() => ({ - api: null as any, + api: null as unknown, })); vi.mock("@/desktop/host", () => ({ diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 4abc88dd2..4657f1ca0 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -96,6 +96,71 @@ function mapDirectorySuggestionsToEntries(payload: { })); } +type AutocompleteMode = "command" | "file" | null; + +function resolveAutocompleteMode(args: { + showFileAutocomplete: boolean; + showCommandAutocomplete: boolean; +}): AutocompleteMode { + if (args.showFileAutocomplete) { + return "file"; + } + if (args.showCommandAutocomplete) { + return "command"; + } + return null; +} + +function resolveAutocompleteIsVisible(args: { + mode: AutocompleteMode; + canLoadCommands: boolean; + serverId: string; + autocompleteCwd: string; +}): boolean { + if (args.mode === "command") { + return args.canLoadCommands; + } + if (args.mode === "file") { + return Boolean(args.serverId) && args.autocompleteCwd.length > 0; + } + return false; +} + +function resolveAutocompleteIsLoading(args: { + mode: AutocompleteMode; + isCommandsLoading: boolean; + fileSuggestionsIsPending: boolean; + fileSuggestionsIsLoading: boolean; + optionsLength: number; +}): boolean { + if (args.mode === "command") { + return args.isCommandsLoading; + } + if (args.mode === "file") { + return ( + args.fileSuggestionsIsPending || (args.fileSuggestionsIsLoading && args.optionsLength === 0) + ); + } + return false; +} + +function resolveAutocompleteErrorMessage(args: { + mode: AutocompleteMode; + isCommandError: boolean; + commandError: Error | null; + fileSuggestionsError: unknown; +}): string | undefined { + if (args.mode === "command") { + return args.isCommandError ? (args.commandError?.message ?? "Failed to load") : undefined; + } + if (args.mode === "file") { + return args.fileSuggestionsError instanceof Error + ? args.fileSuggestionsError.message + : undefined; + } + return undefined; +} + export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult { const { userInput, @@ -143,17 +208,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const mode: "command" | "file" | null = showFileAutocomplete - ? "file" - : showCommandAutocomplete - ? "command" - : null; - const isVisible = - mode === "command" - ? canLoadCommands - : mode === "file" - ? Boolean(serverId) && autocompleteCwd.length > 0 - : false; + const mode = resolveAutocompleteMode({ showFileAutocomplete, showCommandAutocomplete }); + const isVisible = resolveAutocompleteIsVisible({ + mode, + canLoadCommands, + serverId, + autocompleteCwd, + }); const { commands, @@ -258,22 +319,19 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut onEscape: mode === "command" ? () => setUserInput("") : undefined, }); - const isLoading = - mode === "command" - ? isCommandsLoading - : mode === "file" - ? fileSuggestionsQuery.isPending || (fileSuggestionsQuery.isLoading && options.length === 0) - : false; - const errorMessage = - mode === "command" - ? isError - ? (error?.message ?? "Failed to load") - : undefined - : mode === "file" - ? fileSuggestionsQuery.error instanceof Error - ? fileSuggestionsQuery.error.message - : undefined - : undefined; + const isLoading = resolveAutocompleteIsLoading({ + mode, + isCommandsLoading, + fileSuggestionsIsPending: fileSuggestionsQuery.isPending, + fileSuggestionsIsLoading: fileSuggestionsQuery.isLoading, + optionsLength: options.length, + }); + const errorMessage = resolveAutocompleteErrorMessage({ + mode, + isCommandError: isError, + commandError: error, + fileSuggestionsError: fileSuggestionsQuery.error, + }); const loadingText = mode === "file" ? "Searching workspace..." : "Loading commands..."; const emptyText = mode === "file" ? "No files or directories found" : "No commands found"; diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index e39ae2b97..4f71a6b19 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -55,16 +55,16 @@ interface FormState { workingDir: string; } -type UseAgentFormStateOptions = { +interface UseAgentFormStateOptions { initialServerId?: string | null; initialValues?: FormInitialValues; isVisible?: boolean; isCreateFlow?: boolean; isTargetDaemonReady?: boolean; onlineServerIds?: string[]; -}; +} -export type UseAgentFormStateResult = { +export interface UseAgentFormStateResult { selectedServerId: string | null; setSelectedServerId: (value: string | null) => void; setSelectedServerIdFromUser: (value: string | null) => void; @@ -95,7 +95,7 @@ export type UseAgentFormStateResult = { setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; -}; +} function normalizeSelectedModelId(modelId: string | null | undefined): string { const normalized = typeof modelId === "string" ? modelId.trim() : ""; @@ -175,6 +175,112 @@ function mergeSelectedComposerPreferences(args: { * * Only resolves fields that haven't been user-modified. */ +function resolveProvider(input: { + currentProvider: AgentProvider | null; + userModified: boolean; + initialValues: FormInitialValues | undefined; + preferences: FormPreferences | null; + allowedProviderMap: Map; +}): AgentProvider | null { + const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input; + if (userModified) { + if ( + currentProvider && + allowedProviderMap.size > 0 && + !allowedProviderMap.has(currentProvider) + ) { + return null; + } + return currentProvider; + } + if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) { + return initialValues.provider; + } + if (preferences?.provider && allowedProviderMap.has(preferences.provider as AgentProvider)) { + return preferences.provider as AgentProvider; + } + if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) { + return null; + } + return currentProvider; +} + +function resolveModeId(input: { + provider: AgentProvider | null; + userModified: boolean; + currentModeId: string; + initialValues: FormInitialValues | undefined; + providerDef: AgentProviderDefinition | undefined; + providerPrefs: NonNullable[AgentProvider] | undefined; +}): string { + const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } = + input; + if (userModified) return currentModeId; + if (!provider) return ""; + const validModeIds = providerDef?.modes.map((m) => m.id) ?? []; + if ( + typeof initialValues?.modeId === "string" && + initialValues.modeId.length > 0 && + validModeIds.includes(initialValues.modeId) + ) { + return initialValues.modeId; + } + if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) { + return providerPrefs.mode; + } + return providerDef?.defaultModeId ?? validModeIds[0] ?? ""; +} + +function resolveModelField(input: { + provider: AgentProvider | null; + userModified: boolean; + currentModel: string; + initialValues: FormInitialValues | undefined; + providerPrefs: NonNullable[AgentProvider] | undefined; + availableModels: AgentModelDefinition[] | null; +}): string { + const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } = + input; + if (userModified) return currentModel; + if (!provider) return ""; + const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false; + const initialModel = normalizeSelectedModelId(initialValues?.model); + const preferredModel = normalizeSelectedModelId(providerPrefs?.model); + const defaultModelId = resolveDefaultModelId(availableModels); + if (initialModel) { + return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId; + } + if (preferredModel) { + return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId; + } + return ""; +} + +function resolveThinkingOption(input: { + provider: AgentProvider | null; + userModified: boolean; + currentThinkingOptionId: string; + modelId: string; + initialValues: FormInitialValues | undefined; + providerPrefs: NonNullable[AgentProvider] | undefined; +}): string { + const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } = + input; + if (!provider) return ""; + if (userModified) return currentThinkingOptionId; + const initialThinkingOptionId = + typeof initialValues?.thinkingOptionId === "string" + ? initialValues.thinkingOptionId.trim() + : ""; + const effectiveModelId = modelId.trim(); + const preferredThinking = effectiveModelId + ? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "") + : ""; + if (initialThinkingOptionId.length > 0) return initialThinkingOptionId; + if (preferredThinking.length > 0) return preferredThinking; + return ""; +} + function resolveFormState( initialValues: FormInitialValues | undefined, preferences: FormPreferences | null, @@ -184,106 +290,48 @@ function resolveFormState( validServerIds: Set, allowedProviderMap: Map, ): FormState { - // Start with current state - we only update non-user-modified fields const result = { ...currentState }; - // 1. Resolve provider first (other fields depend on it) - if (!userModified.provider) { - if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) { - result.provider = initialValues.provider; - } else if ( - preferences?.provider && - allowedProviderMap.has(preferences.provider as AgentProvider) - ) { - result.provider = preferences.provider as AgentProvider; - } else if ( - result.provider && - allowedProviderMap.size > 0 && - !allowedProviderMap.has(result.provider) - ) { - result.provider = null; - } - } else if ( - result.provider && - allowedProviderMap.size > 0 && - !allowedProviderMap.has(result.provider) - ) { - result.provider = null; - } + + result.provider = resolveProvider({ + currentProvider: result.provider, + userModified: userModified.provider, + initialValues, + preferences, + allowedProviderMap, + }); const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined; const providerPrefs = result.provider ? preferences?.providerPreferences?.[result.provider] : undefined; - // 2. Resolve modeId (depends on provider) - if (!userModified.modeId) { - const validModeIds = providerDef?.modes.map((m) => m.id) ?? []; + result.modeId = resolveModeId({ + provider: result.provider, + userModified: userModified.modeId, + currentModeId: result.modeId, + initialValues, + providerDef, + providerPrefs, + }); - if (!result.provider) { - result.modeId = ""; - } else if ( - typeof initialValues?.modeId === "string" && - initialValues.modeId.length > 0 && - validModeIds.includes(initialValues.modeId) - ) { - result.modeId = initialValues.modeId; - } else if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) { - result.modeId = providerPrefs.mode; - } else { - result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? ""; - } - } + result.model = resolveModelField({ + provider: result.provider, + userModified: userModified.model, + currentModel: result.model, + initialValues, + providerPrefs, + availableModels, + }); - // 3. Resolve model (depends on provider + availableModels) - if (!userModified.model) { - const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false; - const initialModel = normalizeSelectedModelId(initialValues?.model); - const preferredModel = normalizeSelectedModelId(providerPrefs?.model); - const defaultModelId = resolveDefaultModelId(availableModels); + result.thinkingOptionId = resolveThinkingOption({ + provider: result.provider, + userModified: userModified.thinkingOptionId, + currentThinkingOptionId: result.thinkingOptionId, + modelId: result.model, + initialValues, + providerPrefs, + }); - if (!result.provider) { - result.model = ""; - } else if (initialModel) { - if (!availableModels || isValidModel(initialModel)) { - result.model = initialModel; - } else { - result.model = defaultModelId; - } - } else if (preferredModel) { - if (!availableModels || isValidModel(preferredModel)) { - result.model = preferredModel; - } else { - result.model = defaultModelId; - } - } else { - result.model = ""; - } - } - - // 4. Resolve thinking option (depends on effective model) - const initialThinkingOptionId = - typeof initialValues?.thinkingOptionId === "string" - ? initialValues.thinkingOptionId.trim() - : ""; - - if (!result.provider) { - result.thinkingOptionId = ""; - } else if (!userModified.thinkingOptionId) { - const effectiveModelId = result.model.trim(); - const preferredThinking = effectiveModelId - ? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "") - : ""; - - if (initialThinkingOptionId.length > 0) { - result.thinkingOptionId = initialThinkingOptionId; - } else if (preferredThinking.length > 0) { - result.thinkingOptionId = preferredThinking; - } else { - result.thinkingOptionId = ""; - } - } - - // Validate thinking option once model metadata is available. if (result.provider && availableModels) { result.thinkingOptionId = resolveThinkingOptionId({ availableModels, @@ -292,20 +340,12 @@ function resolveFormState( }); } - // 5. Resolve serverId (independent) - if (!userModified.serverId) { - if (initialValues?.serverId !== undefined) { - result.serverId = initialValues.serverId; - } - // else keep current + if (!userModified.serverId && initialValues?.serverId !== undefined) { + result.serverId = initialValues.serverId; } - // 6. Resolve workingDir (independent) - if (!userModified.workingDir) { - if (initialValues?.workingDir !== undefined) { - result.workingDir = initialValues.workingDir; - } - // else keep current (empty string) + if (!userModified.workingDir && initialValues?.workingDir !== undefined) { + result.workingDir = initialValues.workingDir; } return result; @@ -365,13 +405,146 @@ function buildProviderDefinitionMapForStatuses(args: { ); } +type ProviderPrefs = NonNullable[AgentProvider]; + +function shouldAutoSelectServerId(input: { + isVisible: boolean; + isCreateFlow: boolean; + isPreferencesLoading: boolean; + hasResolved: boolean; + userModifiedServerId: boolean; + initialServerId: string | null | undefined; + currentServerId: string | null; +}): boolean { + const { + isVisible, + isCreateFlow, + isPreferencesLoading, + hasResolved, + userModifiedServerId, + initialServerId, + currentServerId, + } = input; + if (!isVisible || !isCreateFlow) return false; + if (isPreferencesLoading) return false; + if (!hasResolved) return false; + if (userModifiedServerId) return false; + if (initialServerId !== undefined) return false; + if (currentServerId) return false; + return true; +} + +function hasFormStateChanged(prev: FormState, next: FormState): boolean { + return ( + prev.serverId !== next.serverId || + prev.provider !== next.provider || + prev.modeId !== next.modeId || + prev.model !== next.model || + prev.thinkingOptionId !== next.thinkingOptionId || + prev.workingDir !== next.workingDir + ); +} + +function pickNextModelForProvider(input: { + providerModels: AgentModelDefinition[] | null; + providerPrefs: ProviderPrefs | undefined; +}): string { + const { providerModels, providerPrefs } = input; + const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false; + const preferredModel = normalizeSelectedModelId(providerPrefs?.model); + const defaultModelId = resolveDefaultModelId(providerModels); + if (preferredModel && (!providerModels || isValidModel(preferredModel))) { + return preferredModel; + } + return defaultModelId; +} + +function pickNextModeForProvider(input: { + providerDef: AgentProviderDefinition | undefined; + providerPrefs: ProviderPrefs | undefined; +}): string { + const { providerDef, providerPrefs } = input; + const validModeIds = providerDef?.modes.map((m) => m.id) ?? []; + if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) { + return providerPrefs.mode; + } + return providerDef?.defaultModeId ?? ""; +} + +function pickNextThinkingOptionForProvider(input: { + providerModels: AgentModelDefinition[] | null; + providerPrefs: ProviderPrefs | undefined; + modelId: string; +}): string { + const { providerModels, providerPrefs, modelId } = input; + const preferredThinking = modelId + ? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "") + : ""; + return resolveThinkingOptionId({ + availableModels: providerModels, + modelId, + requestedThinkingOptionId: preferredThinking, + }); +} + +function resolveSelectedProviderModes(input: { + selectedEntry: ProviderSnapshotEntry | null; + provider: AgentProvider | null; + providerDefinitionMap: Map; +}): AgentMode[] { + const { selectedEntry, provider, providerDefinitionMap } = input; + if (selectedEntry?.modes) { + return selectedEntry.modes; + } + if (provider) { + return providerDefinitionMap.get(provider)?.modes ?? []; + } + return []; +} + +function buildAllProviderModels( + snapshotEntries: ProviderSnapshotEntry[] | undefined, +): Map { + const map = new Map(); + for (const entry of snapshotEntries ?? []) { + map.set(entry.provider, entry.models ?? []); + } + return map; +} + +async function persistProviderPreferences(input: { + provider: AgentProvider; + formState: FormState; + availableModels: AgentModelDefinition[] | null; + updatePreferences: ( + updates: Partial | ((current: FormPreferences) => FormPreferences), + ) => Promise; +}): Promise { + const { provider, formState, availableModels, updatePreferences } = input; + const resolvedModel = resolveEffectiveModel(availableModels, formState.model); + const modelId = resolvedModel?.id ?? formState.model; + await updatePreferences((current) => + mergeProviderPreferences({ + preferences: current, + provider, + updates: { + model: modelId || undefined, + mode: formState.modeId || undefined, + ...(modelId && formState.thinkingOptionId + ? { thinkingByModel: { [modelId]: formState.thinkingOptionId } } + : {}), + }, + }), + ); +} + export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { const { initialServerId = null, initialValues, isVisible = true, isCreateFlow = true, - isTargetDaemonReady = true, + isTargetDaemonReady: _isTargetDaemonReady = true, onlineServerIds = [], } = options; @@ -445,13 +618,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg statuses: SELECTABLE_PROVIDER_STATUSES, }); }, [snapshotEntries, snapshotProviderDefinitions]); - const snapshotAllProviderModels = useMemo(() => { - const map = new Map(); - for (const entry of snapshotEntries ?? []) { - map.set(entry.provider, entry.models ?? []); - } - return map; - }, [snapshotEntries]); + const snapshotAllProviderModels = useMemo( + () => buildAllProviderModels(snapshotEntries), + [snapshotEntries], + ); const snapshotSelectedEntry = useMemo( () => formState.provider @@ -461,10 +631,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null; const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading"; - const snapshotSelectedProviderModes = - snapshotSelectedEntry?.modes ?? - (formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ?? - []; + const snapshotSelectedProviderModes = resolveSelectedProviderModes({ + selectedEntry: snapshotSelectedEntry, + provider: formState.provider, + providerDefinitionMap: snapshotProviderDefinitionMap, + }); const providerDefinitions = snapshotProviderDefinitions; const providerDefinitionMap = snapshotProviderDefinitionMap; const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap; @@ -503,15 +674,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg snapshotResolvableProviderDefinitionMap, ); - // Only update if something changed - if ( - resolved.serverId !== formStateRef.current.serverId || - resolved.provider !== formStateRef.current.provider || - resolved.modeId !== formStateRef.current.modeId || - resolved.model !== formStateRef.current.model || - resolved.thinkingOptionId !== formStateRef.current.thinkingOptionId || - resolved.workingDir !== formStateRef.current.workingDir - ) { + if (hasFormStateChanged(formStateRef.current, resolved)) { setFormState(resolved); } @@ -532,13 +695,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg // - no URL override // - no stored preference applied // - user hasn't manually picked a host in this session + const onlineServerIdsKey = onlineServerIds.join("|"); useEffect(() => { - if (!isVisible || !isCreateFlow) return; - if (isPreferencesLoading) return; - if (!hasResolvedRef.current) return; - if (userModified.serverId) return; - if (combinedInitialValues?.serverId !== undefined) return; - if (formStateRef.current.serverId) return; + const canAutoSelectServerId = shouldAutoSelectServerId({ + isVisible, + isCreateFlow, + isPreferencesLoading, + hasResolved: hasResolvedRef.current, + userModifiedServerId: userModified.serverId, + initialServerId: combinedInitialValues?.serverId, + currentServerId: formStateRef.current.serverId, + }); + if (!canAutoSelectServerId) return; const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null; if (!candidate) return; @@ -549,7 +717,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg isCreateFlow, isPreferencesLoading, isVisible, - onlineServerIds.join("|"), + onlineServerIds, + onlineServerIdsKey, userModified.serverId, validServerIds, ]); @@ -569,27 +738,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const providerDef = selectableProviderDefinitionMap.get(provider); const providerPrefs = preferences?.providerPreferences?.[provider]; - const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false; - const preferredModel = normalizeSelectedModelId(providerPrefs?.model); - const defaultModelId = resolveDefaultModelId(providerModels); - const nextModelId = - preferredModel && (!providerModels || isValidModel(preferredModel)) - ? preferredModel - : defaultModelId; - - const validModeIds = providerDef?.modes.map((m) => m.id) ?? []; - const nextModeId = - providerPrefs?.mode && validModeIds.includes(providerPrefs.mode) - ? providerPrefs.mode - : (providerDef?.defaultModeId ?? ""); - - const preferredThinking = nextModelId - ? (providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? "") - : ""; - const nextThinkingOptionId = resolveThinkingOptionId({ - availableModels: providerModels, + const nextModelId = pickNextModelForProvider({ providerModels, providerPrefs }); + const nextModeId = pickNextModeForProvider({ providerDef, providerPrefs }); + const nextThinkingOptionId = pickNextThinkingOptionForProvider({ + providerModels, + providerPrefs, modelId: nextModelId, - requestedThinkingOptionId: preferredThinking, }); setUserModified((prev) => ({ ...prev, provider: true })); @@ -748,42 +902,24 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg if (!formState.provider) { return; } - - const provider = formState.provider; - const resolvedModel = resolveEffectiveModel(availableModels, formState.model); - const modelId = resolvedModel?.id ?? formState.model; - await updatePreferences((current) => - mergeProviderPreferences({ - preferences: current, - provider, - updates: { - model: modelId || undefined, - mode: formState.modeId || undefined, - ...(modelId && formState.thinkingOptionId - ? { - thinkingByModel: { - [modelId]: formState.thinkingOptionId, - }, - } - : {}), - } satisfies Partial, - }), - ); - }, [ - availableModels, - formState.model, - formState.modeId, - formState.provider, - formState.thinkingOptionId, - updatePreferences, - ]); + await persistProviderPreferences({ + provider: formState.provider, + formState, + availableModels, + updatePreferences, + }); + }, [availableModels, formState, updatePreferences]); const agentDefinition = formState.provider ? providerDefinitionMap.get(formState.provider) : undefined; const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; - const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; + const availableThinkingOptionsRaw = effectiveModel?.thinkingOptions; + const availableThinkingOptions = useMemo( + () => availableThinkingOptionsRaw ?? [], + [availableThinkingOptionsRaw], + ); const isModelLoading = snapshotIsLoading || selectedProviderIsLoading; const modelError = snapshotError; diff --git a/packages/app/src/hooks/use-agent-history.test.tsx b/packages/app/src/hooks/use-agent-history.test.tsx index 6a4d2b258..a0520f001 100644 --- a/packages/app/src/hooks/use-agent-history.test.tsx +++ b/packages/app/src/hooks/use-agent-history.test.tsx @@ -14,13 +14,13 @@ import { useSessionStore, type Agent } from "@/stores/session-store"; import { useAgentHistory } from "./use-agent-history"; const { mockClient, mockRuntimeStore } = vi.hoisted(() => { - const mockClient = { + const hoistedClient = { fetchAgentHistory: vi.fn(), }; - const mockRuntimeStore = { + const hoistedRuntimeStore = { refreshAgentDirectory: vi.fn(), }; - return { mockClient, mockRuntimeStore }; + return { mockClient: hoistedClient, mockRuntimeStore: hoistedRuntimeStore }; }); vi.mock("@/runtime/host-runtime", () => ({ @@ -159,6 +159,10 @@ afterEach(() => { useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); }); +function agentIds(agents: ReadonlyArray<{ id: string }>): string[] { + return agents.map((agent) => agent.id); +} + describe("useAgentHistory", () => { it("loads history one page at a time without refreshing active agents", async () => { mockClient.fetchAgentHistory @@ -210,7 +214,7 @@ describe("useAgentHistory", () => { }, ] satisfies FetchAgentHistoryOptions[]); await waitFor(() => { - expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1"]); + expect(agentIds(result.current.agents)).toEqual(["history-1"]); }); expect(result.current.hasMore).toBe(true); @@ -227,7 +231,7 @@ describe("useAgentHistory", () => { page: { limit: 200, cursor: "cursor-2" }, } satisfies FetchAgentHistoryOptions); await waitFor(() => { - expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1", "history-2"]); + expect(agentIds(result.current.agents)).toEqual(["history-1", "history-2"]); }); expect(result.current.hasMore).toBe(false); expect( diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index b65064c0b..1b17c5bee 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -26,10 +26,10 @@ export interface AgentHistoryResult { loadMore: () => void; } -type AgentHistoryPage = { +interface AgentHistoryPage { agents: AggregatedAgent[]; pageInfo: FetchAgentHistoryPageInfo; -}; +} async function fetchAgentHistoryPage(input: { client: DaemonClient; @@ -126,10 +126,11 @@ export function useAgentHistory(options: { () => (data?.pages ?? []) .flatMap((page) => page.agents) - .map((agent) => ({ - ...agent, - serverLabel: serverLabel ?? agent.serverLabel, - })), + .map((agent) => + Object.assign({}, agent, { + serverLabel: serverLabel ?? agent.serverLabel, + }), + ), [data?.pages, serverLabel], ); const isInitialLoad = isLoading && agents.length === 0; diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 081090307..73e6e08be 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -16,25 +16,25 @@ type AttachmentUpdater = | ComposerAttachment[] | ((prev: ComposerAttachment[]) => ComposerAttachment[]); -type AgentInputDraftComposerOptions = { +interface AgentInputDraftComposerOptions { initialServerId: string | null; initialValues?: CreateAgentInitialValues; isVisible?: boolean; onlineServerIds?: string[]; lockedWorkingDir?: string; -}; +} -type DraftKeyContext = { +interface DraftKeyContext { selectedServerId: string | null; -}; +} type DraftKeyInput = string | ((context: DraftKeyContext) => string); -type UseAgentInputDraftInput = { +interface UseAgentInputDraftInput { draftKey: DraftKeyInput; initialCwd?: string; composer?: AgentInputDraftComposerOptions; -}; +} type DraftComposerState = UseAgentFormStateResult & { workingDir: string; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index e9b33d76f..3113c81ba 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -77,6 +77,83 @@ export type AgentScreenViewState = isArchiving: boolean; }; +function updateInitialSyncFailureMemory(args: { + input: AgentScreenMachineInput; + nextMemory: AgentScreenMachineMemory; +}): void { + if (args.input.hasHydratedHistoryBefore) { + args.nextMemory.hadInitialSyncFailure = false; + } + if (args.input.missingAgentState.kind === "error" && !args.input.hasHydratedHistoryBefore) { + args.nextMemory.hadInitialSyncFailure = true; + } +} + +function shouldUseOptimisticCreateFlowAgent(input: AgentScreenMachineInput): boolean { + return ( + input.shouldUseOptimisticStream && + Boolean(input.placeholderAgent) && + (!input.agent || input.agent.status === "initializing" || input.agent.status === "idle") + ); +} + +function resolveCandidateAgent(args: { + input: AgentScreenMachineInput; + useOptimisticCreateFlowAgent: boolean; +}): AgentScreenAgent | null { + const { input, useOptimisticCreateFlowAgent } = args; + if (input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent) { + return { ...input.agent, status: input.placeholderAgent.status }; + } + return input.agent ?? input.placeholderAgent; +} + +function resolveAgentScreenSource(args: { + useOptimisticCreateFlowAgent: boolean; + hasAgent: boolean; + shouldUseOptimisticStream: boolean; +}): "authoritative" | "optimistic" | "stale" { + if (args.useOptimisticCreateFlowAgent) return "optimistic"; + if (args.hasAgent) return "authoritative"; + if (args.shouldUseOptimisticStream) return "optimistic"; + return "stale"; +} + +function resolveCatchingUpUi(args: { + shouldUseOptimisticStream: boolean; + hasHydratedHistoryBefore: boolean; + hadInitialSyncFailure: boolean; +}): "overlay" | "silent" { + if (args.shouldUseOptimisticStream) return "silent"; + if (args.hasHydratedHistoryBefore) return "silent"; + if (args.hadInitialSyncFailure) return "silent"; + return "overlay"; +} + +function resolveAgentScreenSync(args: { + input: AgentScreenMachineInput; + hadInitialSyncFailure: boolean; +}): AgentScreenReadySyncState { + const { input, hadInitialSyncFailure } = args; + if (!input.isConnected) { + return { status: "reconnecting" }; + } + if (input.missingAgentState.kind === "error") { + return { status: "sync_error" }; + } + if (input.needsAuthoritativeSync || input.isHistorySyncing) { + return { + status: "catching_up", + ui: resolveCatchingUpUi({ + shouldUseOptimisticStream: input.shouldUseOptimisticStream, + hasHydratedHistoryBefore: input.hasHydratedHistoryBefore, + hadInitialSyncFailure, + }), + }; + } + return { status: "idle" }; +} + export function deriveAgentScreenViewState({ input, memory, @@ -90,23 +167,10 @@ export function deriveAgentScreenViewState({ hadInitialSyncFailure: memory.hadInitialSyncFailure, }; - if (input.hasHydratedHistoryBefore) { - nextMemory.hadInitialSyncFailure = false; - } + updateInitialSyncFailureMemory({ input, nextMemory }); - if (input.missingAgentState.kind === "error" && !input.hasHydratedHistoryBefore) { - nextMemory.hadInitialSyncFailure = true; - } - - const useOptimisticCreateFlowAgent = - input.shouldUseOptimisticStream && - Boolean(input.placeholderAgent) && - (!input.agent || input.agent.status === "initializing" || input.agent.status === "idle"); - - const candidateAgent = - input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent - ? { ...input.agent, status: input.placeholderAgent.status } - : (input.agent ?? input.placeholderAgent); + const useOptimisticCreateFlowAgent = shouldUseOptimisticCreateFlowAgent(input); + const candidateAgent = resolveCandidateAgent({ input, useOptimisticCreateFlowAgent }); const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input); if (input.missingAgentState.kind === "not_found") { @@ -158,35 +222,16 @@ export function deriveAgentScreenViewState({ }; } - const source: "authoritative" | "optimistic" | "stale" = useOptimisticCreateFlowAgent - ? "optimistic" - : input.agent - ? "authoritative" - : input.shouldUseOptimisticStream - ? "optimistic" - : "stale"; + const source = resolveAgentScreenSource({ + useOptimisticCreateFlowAgent, + hasAgent: Boolean(input.agent), + shouldUseOptimisticStream: input.shouldUseOptimisticStream, + }); - let sync: AgentScreenReadySyncState; - if (!input.isConnected) { - sync = { status: "reconnecting" }; - } else if (input.missingAgentState.kind === "error") { - sync = { status: "sync_error" }; - } else if (input.needsAuthoritativeSync || input.isHistorySyncing) { - let ui: "overlay" | "silent"; - if (input.shouldUseOptimisticStream) { - ui = "silent"; - } else if (input.hasHydratedHistoryBefore) { - ui = "silent"; - } else if (nextMemory.hadInitialSyncFailure) { - ui = "silent"; - } else { - ui = "overlay"; - } - - sync = { status: "catching_up", ui }; - } else { - sync = { status: "idle" }; - } + const sync = resolveAgentScreenSync({ + input, + hadInitialSyncFailure: nextMemory.hadInitialSyncFailure, + }); return { state: { diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index f77736e85..3bd73393a 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -45,6 +45,8 @@ export function useAggregatedAgents(options?: { }, [runtime]); const result = useMemo(() => { + // runtimeVersion is referenced so the memo recomputes when runtime state changes. + void runtimeVersion; const allAgents: AggregatedAgent[] = []; const serverLabelById = new Map( daemons.map((daemon) => [daemon.serverId, daemon.label] as const), diff --git a/packages/app/src/hooks/use-all-agents-list.test.ts b/packages/app/src/hooks/use-all-agents-list.test.ts index 1353dcd62..21272dd14 100644 --- a/packages/app/src/hooks/use-all-agents-list.test.ts +++ b/packages/app/src/hooks/use-all-agents-list.test.ts @@ -2,43 +2,46 @@ import { describe, expect, it } from "vitest"; import { __private__ } from "./use-all-agents-list"; import type { Agent } from "@/stores/session-store"; +const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z"); + +const AGENT_DEFAULTS: Agent = { + serverId: "server-1", + id: "agent-1", + provider: "codex", + status: "idle", + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + lastUserMessageAt: null, + lastActivityAt: AGENT_TIMESTAMP, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: "Agent", + cwd: "/tmp/project", + model: null, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + labels: {}, + projectPlacement: null, +}; + function makeAgent(input?: Partial): Agent { - const timestamp = new Date("2026-03-08T10:00:00.000Z"); - return { - serverId: "server-1", - id: input?.id ?? "agent-1", - provider: input?.provider ?? "codex", - status: input?.status ?? "idle", - createdAt: input?.createdAt ?? timestamp, - updatedAt: input?.updatedAt ?? timestamp, - lastUserMessageAt: input?.lastUserMessageAt ?? null, - lastActivityAt: input?.lastActivityAt ?? timestamp, - capabilities: input?.capabilities ?? { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: input?.currentModeId ?? null, - availableModes: input?.availableModes ?? [], - pendingPermissions: input?.pendingPermissions ?? [], - persistence: input?.persistence ?? null, - runtimeInfo: input?.runtimeInfo, - lastUsage: input?.lastUsage, - lastError: input?.lastError ?? null, - title: input?.title ?? "Agent", - cwd: input?.cwd ?? "/tmp/project", - model: input?.model ?? null, - thinkingOptionId: input?.thinkingOptionId, - requiresAttention: input?.requiresAttention ?? false, - attentionReason: input?.attentionReason ?? null, - attentionTimestamp: input?.attentionTimestamp ?? null, - archivedAt: input?.archivedAt ?? null, - labels: input?.labels ?? {}, - projectPlacement: input?.projectPlacement ?? null, - }; + return { ...AGENT_DEFAULTS, ...input }; } describe("useAllAgentsList", () => { diff --git a/packages/app/src/hooks/use-audio-recorder.native.ts b/packages/app/src/hooks/use-audio-recorder.native.ts index 8babad104..14952f4ad 100644 --- a/packages/app/src/hooks/use-audio-recorder.native.ts +++ b/packages/app/src/hooks/use-audio-recorder.native.ts @@ -219,17 +219,20 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { await recorder.record(); attemptGuardRef.current.assertCurrent(attemptId); - } catch (error: any) { + } catch (error) { setRecordingStartTime(null); if (error instanceof AttemptCancelledError) { return; } - if (error?.message !== "Recording cancelled") { + if ((error as { message?: string })?.message !== "Recording cancelled") { console.error("[AudioRecorder] Failed to start recording:", error); } - throw new Error(`Failed to start audio recording: ${error.message}`, { cause: error }); + throw new Error( + `Failed to start audio recording: ${(error as { message?: string })?.message ?? String(error)}`, + { cause: error }, + ); } - }, [recordingOptions.sampleRate, recordingOptions.numberOfChannels, recordingOptions.bitRate]); + }, []); const stop = useCallback(async (): Promise => { const recorder = recorderRef.current; @@ -290,10 +293,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { })(); startStopMutexRef.current = stopPromise; return await stopPromise; - } catch (error: any) { + } catch (error) { setRecordingStartTime(null); console.error("[AudioRecorder] Failed to stop recording:", error); - throw new Error(`Failed to stop audio recording: ${error.message}`, { cause: error }); + throw new Error( + `Failed to stop audio recording: ${(error as { message?: string })?.message ?? String(error)}`, + { cause: error }, + ); } finally { startStopMutexRef.current = null; } diff --git a/packages/app/src/hooks/use-audio-recorder.web.ts b/packages/app/src/hooks/use-audio-recorder.web.ts index 2e783bac3..4f4883c47 100644 --- a/packages/app/src/hooks/use-audio-recorder.web.ts +++ b/packages/app/src/hooks/use-audio-recorder.web.ts @@ -18,6 +18,37 @@ const MIME_TYPE_CANDIDATES = [ "audio/ogg", ]; +function assertMicrophoneEnvironment(): void { + const missingNavigator = + typeof navigator === "undefined" || + !navigator.mediaDevices || + typeof navigator.mediaDevices.getUserMedia !== "function"; + + const secureContext = + typeof window !== "undefined" && typeof window.isSecureContext === "boolean" + ? window.isSecureContext + : true; + const currentOrigin = + typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; + const isDesktopApp = isElectronRuntime(); + + if (missingNavigator) { + throw new Error("Microphone capture is not supported in this environment"); + } + + if (!secureContext && !isDesktopApp) { + throw new Error( + `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`, + ); + } + if (!secureContext && isDesktopApp) { + console.warn( + "[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway", + { currentOrigin }, + ); + } +} + export function useAudioRecorder(config?: AudioCaptureConfig) { const [isRecording, setIsRecording] = useState(false); @@ -146,34 +177,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { throw new Error("Already recording"); } - const missingNavigator = - typeof navigator === "undefined" || - !navigator.mediaDevices || - typeof navigator.mediaDevices.getUserMedia !== "function"; - - const secureContext = - typeof window !== "undefined" && typeof window.isSecureContext === "boolean" - ? window.isSecureContext - : true; - const currentOrigin = - typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; - const isDesktopApp = isElectronRuntime(); - - if (missingNavigator) { - throw new Error("Microphone capture is not supported in this environment"); - } - - if (!secureContext && !isDesktopApp) { - throw new Error( - `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`, - ); - } - if (!secureContext && isDesktopApp) { - console.warn( - "[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway", - { currentOrigin }, - ); - } + assertMicrophoneEnvironment(); const options = configRef.current; const constraints: MediaStreamConstraints = { @@ -189,8 +193,11 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia(constraints); - } catch (error: any) { - throw new Error(`Failed to access microphone: ${error?.message ?? error}`, { cause: error }); + } catch (error) { + throw new Error( + `Failed to access microphone: ${(error as { message?: string })?.message ?? String(error)}`, + { cause: error }, + ); } try { @@ -223,11 +230,14 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { let recorder: MediaRecorder; try { recorder = new MediaRecorder(stream, recorderOptions); - } catch (error: any) { + } catch (error) { cleanupStream(); - throw new Error(`Failed to initialize recorder: ${error?.message ?? error}`, { - cause: error, - }); + throw new Error( + `Failed to initialize recorder: ${(error as { message?: string })?.message ?? String(error)}`, + { + cause: error, + }, + ); } mediaRecorderRef.current = recorder; @@ -242,10 +252,10 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { } }; - recorder.onerror = (event) => { + recorder.addEventListener("error", (event) => { const error = (event as { error?: Error }).error; console.error("[AudioRecorder][Web] Recorder error", error ?? event); - }; + }); const timeslice = options?.enableContinuousRecording ? 1000 : undefined; @@ -326,10 +336,12 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }, [cleanupStream, detectSupportedMimeType, stopMetering]); useEffect(() => { + const attemptGuard = attemptGuardRef.current; + const mediaRecorder = mediaRecorderRef; return () => { - attemptGuardRef.current.cancel(); + attemptGuard.cancel(); try { - void mediaRecorderRef.current?.stop(); + void mediaRecorder.current?.stop(); } catch { // Ignore stop during unmount. } diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index 0665fb86b..1506b7470 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -77,6 +77,35 @@ export function useBranchSwitcher({ ]); }, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]); + const maybeRestoreStashForBranch = useCallback( + async (branchId: string) => { + if (!client) return; + try { + const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true }); + const targetStash = stashPayload.entries.find((e) => e.branch === branchId); + if (!targetStash) return; + const shouldRestore = await confirmDialog({ + title: "Restore stashed changes?", + message: + "This branch has stashed changes from a previous session. Would you like to restore them?", + confirmLabel: "Restore", + cancelLabel: "Later", + }); + if (!shouldRestore) return; + const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index); + if (popPayload.error) { + toast.error(popPayload.error.message); + } else { + toast.show("Stashed changes restored"); + } + await invalidateStashAndCheckout(); + } catch { + // Non-critical — user can still restore on next branch switch + } + }, + [client, invalidateStashAndCheckout, normalizedWorkspaceId, toast], + ); + const stashAndSwitch = useCallback( async (branchId: string) => { if (!client) return; @@ -129,30 +158,7 @@ export function useBranchSwitcher({ } // Success — refresh and check for stashes on the target branch await invalidateStashAndCheckout(); - try { - const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true }); - const targetStash = stashPayload.entries.find((e) => e.branch === branchId); - if (targetStash) { - const shouldRestore = await confirmDialog({ - title: "Restore stashed changes?", - message: - "This branch has stashed changes from a previous session. Would you like to restore them?", - confirmLabel: "Restore", - cancelLabel: "Later", - }); - if (shouldRestore) { - const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index); - if (popPayload.error) { - toast.error(popPayload.error.message); - } else { - toast.show("Stashed changes restored"); - } - await invalidateStashAndCheckout(); - } - } - } catch { - // Non-critical — user can still restore on next branch switch - } + await maybeRestoreStashForBranch(branchId); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to switch branch"); } @@ -162,6 +168,7 @@ export function useBranchSwitcher({ client, currentBranchName, invalidateStashAndCheckout, + maybeRestoreStashForBranch, normalizedWorkspaceId, stashAndSwitch, toast, diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index e2673f619..bd5c431b7 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -145,6 +145,7 @@ export function useCheckoutDiffQuery({ error: payload.error, requestId: payload.requestId, }); + return; }) .catch((error) => { if (cancelled) { diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index 18a7d4f36..97e4d8ed0 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -50,15 +50,15 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null return null; } + let state: "merged" | "open" | "closed"; + if (status.isMerged || status.state === "merged") state = "merged"; + else if (status.state === "open") state = "open"; + else state = "closed"; + return { url: status.url, number, - state: - status.isMerged || status.state === "merged" - ? "merged" - : status.state === "open" - ? "open" - : "closed", + state, checks: status.checks, checksStatus: status.checksStatus as PrHint["checksStatus"], reviewDecision: status.reviewDecision as PrHint["reviewDecision"], diff --git a/packages/app/src/hooks/use-checkout-status-query.test.ts b/packages/app/src/hooks/use-checkout-status-query.test.ts index 17026bb2b..6db41f3e4 100644 --- a/packages/app/src/hooks/use-checkout-status-query.test.ts +++ b/packages/app/src/hooks/use-checkout-status-query.test.ts @@ -14,25 +14,25 @@ import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-s type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { - const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>(); - const mockClient = { + const hoistedHandlers = new Set<(message: unknown) => void>(); + const hoistedClient = { getCheckoutStatus: vi.fn(), on: vi.fn((type: string, handler: (message: unknown) => void) => { if (type !== "checkout_status_update") { return () => {}; } - checkoutStatusUpdateHandlers.add(handler); + hoistedHandlers.add(handler); return () => { - checkoutStatusUpdateHandlers.delete(handler); + hoistedHandlers.delete(handler); }; }), }; return { - mockClient, - checkoutStatusUpdateHandlers, + mockClient: hoistedClient, + checkoutStatusUpdateHandlers: hoistedHandlers, mockRuntime: { - client: mockClient, + client: hoistedClient, isConnected: true, }, }; diff --git a/packages/app/src/hooks/use-client-activity.test.tsx b/packages/app/src/hooks/use-client-activity.test.tsx index b89eab1fc..b541424cc 100644 --- a/packages/app/src/hooks/use-client-activity.test.tsx +++ b/packages/app/src/hooks/use-client-activity.test.tsx @@ -4,13 +4,13 @@ import { JSDOM } from "jsdom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClientActivity } from "./use-client-activity"; -type HeartbeatPayload = { +interface HeartbeatPayload { deviceType: "web" | "mobile"; focusedAgentId: string | null; lastActivityAt: string; appVisible: boolean; appVisibilityChangedAt?: string; -}; +} const { platformState, getDesktopSystemIdleTimeMs } = vi.hoisted(() => ({ platformState: { @@ -76,10 +76,10 @@ async function renderActivityHook({ client?: ReturnType; focusedAgentId?: string | null; } = {}) { - function Probe({ focusedAgentId }: { focusedAgentId: string | null }) { + function Probe({ focusedAgentId: probeFocusedAgentId }: { focusedAgentId: string | null }) { useClientActivity({ client: client as unknown as Parameters[0]["client"], - focusedAgentId, + focusedAgentId: probeFocusedAgentId, }); return null; } diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 33f9c2606..8f022804c 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -51,14 +51,14 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); } -type CommandCenterActionDefinition = { +interface CommandCenterActionDefinition { id: string; title: string; icon?: "plus" | "settings"; actionId?: string; keywords: string[]; routeKind: "settings" | "none"; -}; +} const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ { @@ -87,14 +87,14 @@ function matchesActionQuery(query: string, action: CommandCenterActionDefinition return action.keywords.some((keyword) => keyword.includes(normalized)); } -export type CommandCenterActionItem = { +export interface CommandCenterActionItem { kind: "action"; id: string; title: string; icon?: "plus" | "settings"; route?: Href; shortcutKeys?: ShortcutKey[][]; -}; +} export type CommandCenterItem = | { @@ -123,7 +123,7 @@ function resolveActionShortcutKeys( } export function useCommandCenter() { - const pathname = usePathname(); + const _pathname = usePathname(); const routeActiveServerId = useActiveServerId(); const { overrides } = useKeyboardShortcutOverrides(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); @@ -209,7 +209,7 @@ export function useCommandCenter() { workspaceDirectory: agent.cwd, }); if (!workspaceId) { - router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any); + router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as Href); return; } const route = prepareWorkspaceTab({ diff --git a/packages/app/src/hooks/use-dictation-audio-source.types.ts b/packages/app/src/hooks/use-dictation-audio-source.types.ts index 08d4e124b..331c53945 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.types.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.types.ts @@ -1,10 +1,10 @@ -export type DictationAudioSourceConfig = { +export interface DictationAudioSourceConfig { onPcmSegment: (pcm16Base64: string) => void; onError?: (error: Error) => void; -}; +} -export type DictationAudioSource = { +export interface DictationAudioSource { start: () => Promise; stop: () => Promise; volume: number; -}; +} diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index b982597de..27c4d3cfc 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -82,13 +82,90 @@ const int16ToBase64 = (pcm: Int16Array): string => { return btoa(binary); }; -type RecorderRefs = { +interface RecorderRefs { recorder: MediaRecorder | null; audioChunks: Blob[]; stoppedPromise: Promise | null; stoppedResolve: ((blob: Blob) => void) | null; stoppedReject: ((error: unknown) => void) | null; -}; +} + +function safeDisconnectNode(node: AudioNode | null): void { + if (!node) return; + try { + node.disconnect(); + } catch { + // no-op + } +} + +function disconnectDictationAudioGraph(graph: { + processor: ScriptProcessorNode | null; + source: AudioNode | null; + gain: AudioNode | null; + stream: MediaStream | null; +}): void { + if (graph.processor) { + try { + graph.processor.onaudioprocess = null; + } catch { + // no-op + } + safeDisconnectNode(graph.processor); + } + safeDisconnectNode(graph.source); + safeDisconnectNode(graph.gain); + if (graph.stream) { + graph.stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // no-op + } + }); + } +} + +function stopMediaRecorderIfActive(recorder: MediaRecorder | null): void { + if (!recorder) return; + try { + if (recorder.state === "recording") { + recorder.stop(); + } + } catch { + // ignore stop errors; we still need to stop tracks + } +} + +async function finalizeRecorderStoppedPromise(input: { + stoppedPromise: Promise; + context: AudioContext | null; + decodeAudioData: (context: AudioContext, arrayBuffer: ArrayBuffer) => Promise; + emitPcmSegments: (pcm: Int16Array) => void; + onError: ((err: Error) => void) | undefined; +}): Promise { + const { stoppedPromise, context, decodeAudioData, emitPcmSegments, onError } = input; + try { + const blob = await stoppedPromise; + const arrayBuffer = await blob.arrayBuffer(); + if (arrayBuffer.byteLength === 0) { + return; + } + const parsedWav = parsePcm16Wav(arrayBuffer); + if (parsedWav) { + const floatPcm = int16ToFloat32(parsedWav.samples); + emitPcmSegments(resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000)); + return; + } + if (context) { + const decoded = await decodeAudioData(context, arrayBuffer); + const floatPcm = decoded.getChannelData(0); + emitPcmSegments(resampleToPcm16(floatPcm, decoded.sampleRate, 16000)); + } + } catch (err) { + onError?.(err instanceof Error ? err : new Error(String(err))); + } +} export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource { const [volume, setVolume] = useState(0); @@ -260,7 +337,10 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic // isn't available (e.g., Playwright tests with a stubbed getUserMedia). } - const RecorderCtor = typeof window !== "undefined" ? (window as any).MediaRecorder : undefined; + const RecorderCtor = + typeof window !== "undefined" + ? (window as Window & { MediaRecorder?: typeof MediaRecorder }).MediaRecorder + : undefined; if (!RecorderCtor) { throw new Error("MediaRecorder unavailable"); } @@ -277,15 +357,15 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic stoppedReject: null, }; - recorder.ondataavailable = (event: any) => { + recorder.ondataavailable = (event: BlobEvent) => { const data: Blob | undefined = event?.data; if (data) { recorderRefs.audioChunks.push(data); } }; - recorder.onerror = (event: any) => { + recorder.addEventListener("error", (event: Event) => { recorderRefs.stoppedReject?.(event); - }; + }); recorder.addEventListener("stop", () => { try { const blob = @@ -329,71 +409,18 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic const { processor, source, gain, context, stream, pending, mode, recorder } = refs.current; - if (processor) { - try { - processor.onaudioprocess = null; - } catch { - // no-op - } - try { - processor.disconnect(); - } catch { - // no-op - } - } - if (source) { - try { - source.disconnect(); - } catch { - // no-op - } - } - if (gain) { - try { - gain.disconnect(); - } catch { - // no-op - } - } - if (stream) { - stream.getTracks().forEach((track) => { - try { - track.stop(); - } catch { - // no-op - } - }); - } + disconnectDictationAudioGraph({ processor, source, gain, stream }); - if (mode === "recorder" && recorder.recorder) { - try { - if (recorder.recorder.state === "recording") { - recorder.recorder.stop(); - } - } catch { - // ignore stop errors; we still need to stop tracks - } - } - - if (mode === "recorder" && recorder.stoppedPromise) { - try { - const blob = await recorder.stoppedPromise; - const arrayBuffer = await blob.arrayBuffer(); - if (arrayBuffer.byteLength > 0) { - const parsedWav = parsePcm16Wav(arrayBuffer); - if (parsedWav) { - const floatPcm = int16ToFloat32(parsedWav.samples); - const pcm16 = resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000); - emitPcmSegments(pcm16); - } else if (context) { - const decoded = await decodeAudioData(context, arrayBuffer); - const floatPcm = decoded.getChannelData(0); - const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000); - emitPcmSegments(pcm16); - } - } - } catch (err) { - onErrorRef.current?.(err instanceof Error ? err : new Error(String(err))); + if (mode === "recorder") { + stopMediaRecorderIfActive(recorder.recorder); + if (recorder.stoppedPromise) { + await finalizeRecorderStoppedPromise({ + stoppedPromise: recorder.stoppedPromise, + context, + decodeAudioData, + emitPcmSegments, + onError: onErrorRef.current, + }); } } diff --git a/packages/app/src/hooks/use-dictation.shared.ts b/packages/app/src/hooks/use-dictation.shared.ts index bd74c836b..14d3eaa32 100644 --- a/packages/app/src/hooks/use-dictation.shared.ts +++ b/packages/app/src/hooks/use-dictation.shared.ts @@ -1,6 +1,6 @@ export type DictationStatus = "idle" | "recording" | "uploading" | "failed"; -export type UseDictationOptions = { +export interface UseDictationOptions { client: import("@server/client/daemon-client").DaemonClient | null; onTranscript: (text: string, meta: { requestId: string }) => void; onPartialTranscript?: (text: string, meta: { requestId: string }) => void; @@ -10,9 +10,9 @@ export type UseDictationOptions = { canConfirm?: () => boolean; autoStopWhenHidden?: { isVisible: boolean }; enableDuration?: boolean; -}; +} -export type UseDictationResult = { +export interface UseDictationResult { isRecording: boolean; isProcessing: boolean; partialTranscript: string; @@ -26,7 +26,7 @@ export type UseDictationResult = { retryFailedDictation: () => Promise; discardFailedDictation: () => void; reset: () => void; -}; +} export const DURATION_TICK_MS = 1000; export const PCM_DICTATION_FORMAT = "audio/pcm;rate=16000;bits=16"; diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index eff44f045..b36f769c1 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -155,8 +155,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { if (!isRecordingRef.current) { return; } - void startNewStream("reconnect").catch((error) => { - reportError(error, "Failed to restart dictation stream after reconnect"); + void startNewStream("reconnect").catch((err) => { + reportError(err, "Failed to restart dictation stream after reconnect"); }); }); }, [client, reportError, startNewStream]); @@ -286,7 +286,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { clearStreamingState, client, enableDuration, - isProcessing, reportError, startDurationTracking, startNewStream, @@ -324,7 +323,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { clearStreamingState(); actionGateRef.current.cancelling = false; } - }, [audio, clearStreamingState, client, reportError, stopDurationTracking]); + }, [audio, clearStreamingState, reportError, stopDurationTracking]); const confirmDictation = useCallback(async () => { if (actionGateRef.current.confirming) { @@ -374,7 +373,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { }, [ audio, canConfirm, - isProcessing, handleDictationFailure, handleStreamingTranscriptionSuccess, stopDurationTracking, @@ -468,10 +466,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { }, [autoStopWhenHidden?.isVisible, clearStreamingState, stopDurationTracking]); useEffect(() => { + const attemptGuard = attemptGuardRef.current; + const audioStop = audioStopRef; return () => { - attemptGuardRef.current.cancel(); + attemptGuard.cancel(); stopDurationTracking(); - void audioStopRef.current().catch(() => undefined); + void audioStop.current().catch(() => undefined); }; }, [stopDurationTracking]); diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts index a346818bd..ea5eba739 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -11,12 +11,12 @@ import type { AgentAttachment } from "@server/shared/messages"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; -type CreateAttempt = { +interface CreateAttempt { clientMessageId: string; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; -}; +} type DraftAgentMachineState = | { tag: "draft"; errorMessage: string } @@ -56,24 +56,24 @@ function reducer( } } -type CreateRequestResult = { +interface CreateRequestResult { agentId: string | null; result: TCreateResult; -}; +} -type SubmitContext = { +interface SubmitContext { text: string; attachments: ComposerAttachment[]; cwd: string; -}; +} -type CreateRequestContext = { +interface CreateRequestContext { attempt: CreateAttempt; text: string; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; cwd: string; -}; +} interface UseDraftAgentCreateFlowOptions { draftId: string; diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index 24aa9dc34..c0eae3c99 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -71,7 +71,8 @@ export function useDraftAgentFeatures(input: { return payload.features ?? []; }, }); - const availableFeatures = featuresQuery.data ?? []; + const availableFeaturesRaw = featuresQuery.data; + const availableFeatures = useMemo(() => availableFeaturesRaw ?? [], [availableFeaturesRaw]); const featureValues = useMemo( () => resolveFeatureValues({ diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index 65ce91426..23ebfc69b 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -46,9 +46,9 @@ type DesktopDragDropPayload = type: "leave"; }; -type DesktopDragDropEvent = { +interface DesktopDragDropEvent { payload: DesktopDragDropPayload; -}; +} function isImageFile(file: File): boolean { return file.type.startsWith("image/"); @@ -167,6 +167,7 @@ export function useFileDropZone({ return; } onFilesDroppedRef.current(attachments); + return; }) .catch((error) => { console.error("[useFileDropZone] Failed to persist dropped files:", error); diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index f23c4056e..932d07f6b 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -91,7 +91,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor } const normalizedPath = path && path.length > 0 ? path : "."; const shouldSetCurrentPath = options?.setCurrentPath ?? true; - const shouldRecordHistory = options?.recordHistory ?? (shouldSetCurrentPath ? true : false); + const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath; updateExplorerState((state) => ({ ...state, diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 9d6bd3d1d..2016a971e 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -1,9 +1,18 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; import { useRouter } from "expo-router"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; -import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; -import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; +import { + type CheckoutGitActionStatus, + useCheckoutGitActionsStore, +} from "@/stores/checkout-git-actions-store"; +import { + type CheckoutStatusPayload, + useCheckoutStatusQuery, +} from "@/hooks/use-checkout-status-query"; +import { + type CheckoutPrStatusPayload, + useCheckoutPrStatusQuery, +} from "@/hooks/use-checkout-pr-status-query"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -15,6 +24,177 @@ function openURLInNewTab(url: string): void { void openExternalUrl(url); } +function isActionDisabled(actionsDisabled: boolean, status: CheckoutGitActionStatus): boolean { + return actionsDisabled || status === "pending"; +} + +function resolveBranchLabel(input: { + currentBranch: string | null | undefined; + notGit: boolean; +}): string { + if (input.currentBranch && input.currentBranch !== "HEAD") { + return input.currentBranch; + } + if (input.notGit) { + return "Not a git repository"; + } + return "Unknown"; +} + +function formatBaseRefLabel(baseRef: string | undefined): string { + if (!baseRef) return "base"; + const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); + return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; +} + +function useGitActionStatuses( + serverId: string, + cwd: string, +): { + commitStatus: CheckoutGitActionStatus; + pullStatus: CheckoutGitActionStatus; + pushStatus: CheckoutGitActionStatus; + prCreateStatus: CheckoutGitActionStatus; + mergeStatus: CheckoutGitActionStatus; + mergeFromBaseStatus: CheckoutGitActionStatus; + archiveStatus: CheckoutGitActionStatus; +} { + const commitStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "commit" }), + ); + const pullStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "pull" }), + ); + const pushStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "push" }), + ); + const prCreateStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "create-pr" }), + ); + const mergeStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "merge-branch" }), + ); + const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), + ); + const archiveStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), + ); + return { + commitStatus, + pullStatus, + pushStatus, + prCreateStatus, + mergeStatus, + mergeFromBaseStatus, + archiveStatus, + }; +} + +type PrStatusValue = NonNullable | null; + +interface DeriveGitActionsStateArgs { + isGit: boolean; + status: CheckoutStatusPayload | null; + gitStatus: CheckoutStatusPayload | null; + prStatus: PrStatusValue; + hasUncommittedChanges: boolean; + postShipArchiveSuggested: boolean; + isStatusLoading: boolean; + baseRefLabel: string; +} + +interface DerivedGitActionsState { + actionsDisabled: boolean; + aheadCount: number; + behindBaseCount: number; + aheadOfOrigin: number; + behindOfOrigin: number; + hasPullRequest: boolean; + hasRemote: boolean; + isPaseoOwnedWorktree: boolean; + isOnBaseBranch: boolean; + shouldPromoteArchive: boolean; +} + +interface GitCommitCounts { + aheadCount: number; + behindBaseCount: number; + aheadOfOrigin: number; + behindOfOrigin: number; +} + +function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCommitCounts { + return { + aheadCount: gitStatus?.aheadBehind?.ahead ?? 0, + behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0, + aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0, + behindOfOrigin: gitStatus?.behindOfOrigin ?? 0, + }; +} + +function computeShouldPromoteArchive(input: { + isPaseoOwnedWorktree: boolean; + hasUncommittedChanges: boolean; + postShipArchiveSuggested: boolean; + isMergedPullRequest: boolean; +}): boolean { + return ( + input.isPaseoOwnedWorktree && + !input.hasUncommittedChanges && + (input.postShipArchiveSuggested || input.isMergedPullRequest) + ); +} + +function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActionsState { + const { + isGit, + status, + gitStatus, + prStatus, + hasUncommittedChanges, + postShipArchiveSuggested, + isStatusLoading, + baseRefLabel, + } = args; + const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; + const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; + const isMergedPullRequest = Boolean(prStatus?.isMerged); + return { + actionsDisabled, + ...extractGitCommitCounts(gitStatus), + hasPullRequest: Boolean(prStatus?.url), + hasRemote: gitStatus?.hasRemote ?? false, + isPaseoOwnedWorktree, + isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel, + shouldPromoteArchive: computeShouldPromoteArchive({ + isPaseoOwnedWorktree, + hasUncommittedChanges, + postShipArchiveSuggested, + isMergedPullRequest, + }), + }; +} + +function useGitActionRunners() { + const runCommit = useCheckoutGitActionsStore((state) => state.commit); + const runPull = useCheckoutGitActionsStore((state) => state.pull); + const runPush = useCheckoutGitActionsStore((state) => state.push); + const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); + const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); + const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); + const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + return { + runCommit, + runPull, + runPush, + runCreatePr, + runMergeBranch, + runMergeFromBase, + runArchiveWorktree, + }; +} + interface UseGitActionsInput { serverId: string; cwd: string; @@ -75,6 +255,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use if (value === "pr" || value === "merge") { setShipDefault(value); } + return; }) .catch(() => undefined); return () => { @@ -99,36 +280,25 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use setPostShipArchiveSuggested(false); }, [cwd]); - // Store selectors - const commitStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "commit" }), - ); - const pullStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "pull" }), - ); - const pushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "push" }), - ); - const prCreateStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "create-pr" }), - ); - const mergeStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-branch" }), - ); - const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), - ); - const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), - ); + const { + commitStatus, + pullStatus, + pushStatus, + prCreateStatus, + mergeStatus, + mergeFromBaseStatus, + archiveStatus, + } = useGitActionStatuses(serverId, cwd); - const runCommit = useCheckoutGitActionsStore((state) => state.commit); - const runPull = useCheckoutGitActionsStore((state) => state.pull); - const runPush = useCheckoutGitActionsStore((state) => state.push); - const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); - const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); - const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); - const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const { + runCommit, + runPull, + runPush, + runCreatePr, + runMergeBranch, + runMergeFromBase, + runArchiveWorktree, + } = useGitActionRunners(); const toastActionError = useCallback( (error: unknown, fallback: string) => { @@ -150,6 +320,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void runCommit({ serverId, cwd }) .then(() => { toastActionSuccess("Committed"); + return; }) .catch((err) => { toastActionError(err, "Failed to commit"); @@ -160,6 +331,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void runPull({ serverId, cwd }) .then(() => { toastActionSuccess("Pulled"); + return; }) .catch((err) => { toastActionError(err, "Failed to pull"); @@ -170,6 +342,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void runPush({ serverId, cwd }) .then(() => { toastActionSuccess("Pushed"); + return; }) .catch((err) => { toastActionError(err, "Failed to push"); @@ -181,6 +354,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void runCreatePr({ serverId, cwd }) .then(() => { toastActionSuccess("PR created"); + return; }) .catch((err) => { toastActionError(err, "Failed to create PR"); @@ -197,6 +371,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .then(() => { setPostShipArchiveSuggested(true); toastActionSuccess("Merged"); + return; }) .catch((err) => { toastActionError(err, "Failed to merge"); @@ -220,6 +395,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void runMergeFromBase({ serverId, cwd, baseRef }) .then(() => { toastActionSuccess("Updated"); + return; }) .catch((err) => { toastActionError(err, "Failed to merge from base"); @@ -236,48 +412,57 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); + return; }) .catch((err) => { toastActionError(err, "Failed to archive worktree"); }); }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]); - // Derived state - const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; - const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; - const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0; - const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; - const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; - const baseRefLabel = useMemo(() => { - if (!baseRef) return "base"; - const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); - return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; - }, [baseRef]); - const hasPullRequest = Boolean(prStatus?.url); - const hasRemote = gitStatus?.hasRemote ?? false; - const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; - const isMergedPullRequest = Boolean(prStatus?.isMerged); - const currentBranch = gitStatus?.currentBranch; - const isOnBaseBranch = currentBranch === baseRefLabel; - const shouldPromoteArchive = - isPaseoOwnedWorktree && - !hasUncommittedChanges && - (postShipArchiveSuggested || isMergedPullRequest); + const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]); + const derived = deriveGitActionsState({ + isGit, + status, + gitStatus, + prStatus, + hasUncommittedChanges, + postShipArchiveSuggested, + isStatusLoading, + baseRefLabel, + }); + const { + actionsDisabled, + aheadCount, + behindBaseCount, + aheadOfOrigin, + behindOfOrigin, + hasPullRequest, + hasRemote, + isPaseoOwnedWorktree, + isOnBaseBranch, + shouldPromoteArchive, + } = derived; - const commitDisabled = actionsDisabled || commitStatus === "pending"; - const pullDisabled = actionsDisabled || pullStatus === "pending"; - const prDisabled = actionsDisabled || prCreateStatus === "pending"; - const mergeDisabled = actionsDisabled || mergeStatus === "pending"; - const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; - const pushDisabled = actionsDisabled || pushStatus === "pending"; - const archiveDisabled = actionsDisabled || archiveStatus === "pending"; + const commitDisabled = isActionDisabled(actionsDisabled, commitStatus); + const pullDisabled = isActionDisabled(actionsDisabled, pullStatus); + const prDisabled = isActionDisabled(actionsDisabled, prCreateStatus); + const mergeDisabled = isActionDisabled(actionsDisabled, mergeStatus); + const mergeFromBaseDisabled = isActionDisabled(actionsDisabled, mergeFromBaseStatus); + const pushDisabled = isActionDisabled(actionsDisabled, pushStatus); + const archiveDisabled = isActionDisabled(actionsDisabled, archiveStatus); - const branchLabel = - gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" - ? gitStatus.currentBranch - : notGit - ? "Not a git repository" - : "Unknown"; + const branchLabel = resolveBranchLabel({ + currentBranch: gitStatus?.currentBranch, + notGit, + }); + + const handlePrAction = useCallback(() => { + if (prStatus?.url) { + openURLInNewTab(prStatus.url); + return; + } + handleCreatePr(); + }, [prStatus?.url, handleCreatePr]); // Build actions const gitActions: GitActions = useMemo(() => { @@ -321,13 +506,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use disabled: prDisabled, status: hasPullRequest ? "idle" : prCreateStatus, icon: hasPullRequest ? icons.viewPr : icons.createPr, - handler: () => { - if (prStatus?.url) { - openURLInNewTab(prStatus.url); - return; - } - handleCreatePr(); - }, + handler: handlePrAction, }, "merge-branch": { disabled: mergeDisabled, @@ -382,7 +561,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use handleCommit, handlePull, handlePush, - handleCreatePr, + handlePrAction, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, diff --git a/packages/app/src/hooks/use-hover-safe-zone.test.tsx b/packages/app/src/hooks/use-hover-safe-zone.test.tsx index 616e9fd80..3c4d31c9d 100644 --- a/packages/app/src/hooks/use-hover-safe-zone.test.tsx +++ b/packages/app/src/hooks/use-hover-safe-zone.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import React, { useRef, type RefObject } from "react"; +import React, { useCallback, useRef, type RefObject } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import type { View } from "react-native"; @@ -12,12 +12,12 @@ vi.mock("@/constants/platform", () => ({ isWeb: true, })); -type RectInput = { +interface RectInput { left: number; right: number; top: number; bottom: number; -}; +} let root: Root | null = null; let container: HTMLElement | null = null; @@ -73,20 +73,20 @@ function Harness({ onLeaveSafeZone, }); + const handleTriggerRef = useCallback((node: HTMLDivElement | null) => { + triggerRef.current = node; + installRect(node, { left: 0, right: 100, top: 20, bottom: 60 }); + }, []); + + const handleContentRef = useCallback((node: HTMLDivElement | null) => { + contentRef.current = node; + installRect(node, { left: 120, right: 240, top: 20, bottom: 120 }); + }, []); + return ( <> -
{ - triggerRef.current = node; - installRect(node, { left: 0, right: 100, top: 20, bottom: 60 }); - }} - /> -
{ - contentRef.current = node; - installRect(node, { left: 120, right: 240, top: 20, bottom: 120 }); - }} - /> +
+
); } diff --git a/packages/app/src/hooks/use-is-local-daemon.ts b/packages/app/src/hooks/use-is-local-daemon.ts index 76601739a..4e47c71bf 100644 --- a/packages/app/src/hooks/use-is-local-daemon.ts +++ b/packages/app/src/hooks/use-is-local-daemon.ts @@ -24,7 +24,7 @@ export function useLocalDaemonServerId(): string | null { enabled: isDesktopApp, staleTime: Infinity, gcTime: Infinity, - refetchInterval: (query) => (query.state.data?.serverId ? false : 1000), + refetchInterval: (activeQuery) => (activeQuery.state.data?.serverId ? false : 1000), refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, diff --git a/packages/app/src/hooks/use-keyboard-action-handler.ts b/packages/app/src/hooks/use-keyboard-action-handler.ts index e014b578e..dda2bd85d 100644 --- a/packages/app/src/hooks/use-keyboard-action-handler.ts +++ b/packages/app/src/hooks/use-keyboard-action-handler.ts @@ -6,14 +6,14 @@ import { type KeyboardActionId, } from "@/keyboard/keyboard-action-dispatcher"; -type UseKeyboardActionHandlerInput = { +interface UseKeyboardActionHandlerInput { handlerId: string; actions: readonly KeyboardActionId[]; enabled: boolean; priority: number; isActive?: () => boolean; handle: (action: KeyboardActionDefinition) => boolean; -}; +} export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) { useEffect(() => { diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 364397195..857006152 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -32,6 +32,13 @@ import { getNavigationActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; +function hasPayloadKey( + payload: KeyboardShortcutPayload, + key: K, +): payload is KeyboardShortcutPayload & Record { + return !!payload && typeof payload === "object" && key in payload; +} + export function useKeyboardShortcuts({ enabled, isMobile, @@ -156,34 +163,16 @@ export function useKeyboardShortcuts({ return false; } }; - const handleAction = (input: { - action: string; - payload: KeyboardShortcutPayload; - event: KeyboardEvent; - }): boolean => { - switch (input.action) { + const handleDispatchOnlyAction = (action: string): boolean | null => { + switch (action) { case "agent.interrupt": - return keyboardActionDispatcher.dispatch({ - id: "agent.interrupt", - scope: "global", - }); - case "agent.new": - return openProjectPicker(); + return keyboardActionDispatcher.dispatch({ id: "agent.interrupt", scope: "global" }); case "workspace.tab.new": - return keyboardActionDispatcher.dispatch({ - id: "workspace.tab.new", - scope: "workspace", - }); + return keyboardActionDispatcher.dispatch({ id: "workspace.tab.new", scope: "workspace" }); case "worktree.archive": - return keyboardActionDispatcher.dispatch({ - id: "worktree.archive", - scope: "sidebar", - }); + return keyboardActionDispatcher.dispatch({ id: "worktree.archive", scope: "sidebar" }); case "worktree.new": - return keyboardActionDispatcher.dispatch({ - id: "worktree.new", - scope: "sidebar", - }); + return keyboardActionDispatcher.dispatch({ id: "worktree.new", scope: "sidebar" }); case "workspace.terminal.new": return keyboardActionDispatcher.dispatch({ id: "workspace.terminal.new", @@ -194,23 +183,10 @@ export function useKeyboardShortcuts({ id: "workspace.tab.close-current", scope: "workspace", }); - case "workspace.tab.navigate.index": - if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) { - return false; - } + case "sidebar.toggle.right": return keyboardActionDispatcher.dispatch({ - id: "workspace.tab.navigate-index", - scope: "workspace", - index: input.payload.index, - }); - case "workspace.tab.navigate.relative": - if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) { - return false; - } - return keyboardActionDispatcher.dispatch({ - id: "workspace.tab.navigate-relative", - scope: "workspace", - delta: input.payload.delta, + id: "sidebar.toggle.right", + scope: "sidebar", }); case "workspace.pane.split.right": case "workspace.pane.split.down": @@ -223,52 +199,104 @@ export function useKeyboardShortcuts({ case "workspace.pane.move-tab.up": case "workspace.pane.move-tab.down": case "workspace.pane.close": + return keyboardActionDispatcher.dispatch({ id: action, scope: "workspace" }); + default: + return null; + } + }; + + const handlePayloadAction = ( + action: string, + payload: KeyboardShortcutPayload, + ): boolean | null => { + switch (action) { + case "workspace.tab.navigate.index": + if (!hasPayloadKey(payload, "index")) return false; return keyboardActionDispatcher.dispatch({ - id: input.action, + id: "workspace.tab.navigate-index", scope: "workspace", + index: payload.index, + }); + case "workspace.tab.navigate.relative": + if (!hasPayloadKey(payload, "delta")) return false; + return keyboardActionDispatcher.dispatch({ + id: "workspace.tab.navigate-relative", + scope: "workspace", + delta: payload.delta, }); case "workspace.navigate.index": - if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) { - return false; - } - return navigateToWorkspaceShortcut(input.payload.index); + if (!hasPayloadKey(payload, "index")) return false; + return navigateToWorkspaceShortcut(payload.index); case "workspace.navigate.relative": - if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) { - return false; + if (!hasPayloadKey(payload, "delta")) return false; + return navigateRelativeWorkspace(payload.delta); + case "message-input.action": + if (!hasPayloadKey(payload, "kind")) return false; + return dispatchMessageInputAction(payload.kind); + default: + return null; + } + }; + + const handleSettingsToggle = (): boolean => { + if (pathname.startsWith("/settings")) { + if (!isMobile) { + const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection(); + if (lastWorkspaceRoute) { + router.replace( + buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId), + ); + return true; } - return navigateRelativeWorkspace(input.payload.delta); + } + router.back(); + return true; + } + router.push(buildSettingsRoute()); + return true; + }; + + const handleCommandCenterToggle = (event: KeyboardEvent): boolean => { + const store = useKeyboardShortcutsStore.getState(); + if (!store.commandCenterOpen) { + const target = event.target instanceof Element ? (event.target as Element) : null; + const targetEl = + target?.closest?.("textarea, input, [contenteditable='true']") ?? + (target instanceof HTMLElement ? target : null); + const active = document.activeElement; + const activeEl = active instanceof HTMLElement ? active : null; + setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null); + } + store.setCommandCenterOpen(!store.commandCenterOpen); + return true; + }; + + const handleAction = (input: { + action: string; + payload: KeyboardShortcutPayload; + event: KeyboardEvent; + }): boolean => { + const dispatchOnlyResult = handleDispatchOnlyAction(input.action); + if (dispatchOnlyResult !== null) { + return dispatchOnlyResult; + } + const payloadResult = handlePayloadAction(input.action, input.payload); + if (payloadResult !== null) { + return payloadResult; + } + switch (input.action) { + case "agent.new": + return openProjectPicker(); case "sidebar.toggle.left": toggleAgentList(); return true; case "settings.toggle": - if (pathname.startsWith("/settings")) { - if (!isMobile) { - const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection(); - if (lastWorkspaceRoute) { - router.replace( - buildHostWorkspaceRoute( - lastWorkspaceRoute.serverId, - lastWorkspaceRoute.workspaceId, - ), - ); - return true; - } - } - router.back(); - return true; - } - router.push(buildSettingsRoute()); - return true; + return handleSettingsToggle(); case "sidebar.toggle.both": if (toggleBothSidebars) { toggleBothSidebars(); } return true; - case "sidebar.toggle.right": - return keyboardActionDispatcher.dispatch({ - id: "sidebar.toggle.right", - scope: "sidebar", - }); case "view.toggle.focus": if (toggleFocusMode) { toggleFocusMode(); @@ -279,33 +307,13 @@ export function useKeyboardShortcuts({ cycleTheme(); } return true; - case "command-center.toggle": { - const store = useKeyboardShortcutsStore.getState(); - if (!store.commandCenterOpen) { - const target = - input.event.target instanceof Element ? (input.event.target as Element) : null; - const targetEl = - target?.closest?.("textarea, input, [contenteditable='true']") ?? - (target instanceof HTMLElement ? target : null); - const active = document.activeElement; - const activeEl = active instanceof HTMLElement ? active : null; - setCommandCenterFocusRestoreElement( - (targetEl as HTMLElement | null) ?? activeEl ?? null, - ); - } - store.setCommandCenterOpen(!store.commandCenterOpen); - return true; - } + case "command-center.toggle": + return handleCommandCenterToggle(input.event); case "shortcuts.dialog.toggle": { const store = useKeyboardShortcutsStore.getState(); store.setShortcutsDialogOpen(!store.shortcutsDialogOpen); return true; } - case "message-input.action": - if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) { - return false; - } - return dispatchMessageInputAction(input.payload.kind); default: return false; } @@ -432,7 +440,9 @@ export function useKeyboardShortcuts({ openProjectPickerAction, pathname, resetModifiers, + router, toggleAgentList, + toggleBothSidebars, toggleFocusMode, ]); } diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index c9879d6e0..4e5e4d174 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { router } from "expo-router"; +import { router, type Href } from "expo-router"; import type { DaemonClient } from "@server/client/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { @@ -76,7 +76,7 @@ export function useOpenProject(serverId: string | null): (path: string) => Promi draftId: generateDraftId(), }), replaceRoute: (route) => { - router.replace(route as any); + router.replace(route as Href); }, }); }, diff --git a/packages/app/src/hooks/use-pr-pane-data.test.ts b/packages/app/src/hooks/use-pr-pane-data.test.ts index a647ad986..18977644a 100644 --- a/packages/app/src/hooks/use-pr-pane-data.test.ts +++ b/packages/app/src/hooks/use-pr-pane-data.test.ts @@ -24,26 +24,26 @@ type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { - const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>(); - const mockClient = { + const hoistedHandlers = new Set<(message: unknown) => void>(); + const hoistedClient = { checkoutPrStatus: vi.fn(), pullRequestTimeline: vi.fn(), on: vi.fn((type: string, handler: (message: unknown) => void) => { if (type !== "checkout_status_update") { return () => {}; } - checkoutStatusUpdateHandlers.add(handler); + hoistedHandlers.add(handler); return () => { - checkoutStatusUpdateHandlers.delete(handler); + hoistedHandlers.delete(handler); }; }), }; return { - mockClient, - checkoutStatusUpdateHandlers, + mockClient: hoistedClient, + checkoutStatusUpdateHandlers: hoistedHandlers, mockRuntime: { - client: mockClient, + client: hoistedClient, isConnected: true, }, }; @@ -766,9 +766,15 @@ function createDeferred(): { return { promise, resolve }; } -function countTimelineCalls({ cwd, prNumber }: { cwd: string; prNumber: number }): number { +function countTimelineCalls({ + cwd: targetCwd, + prNumber, +}: { + cwd: string; + prNumber: number; +}): number { return mockClient.pullRequestTimeline.mock.calls.filter(([input]) => { const request = input as { cwd?: string; prNumber?: number | null }; - return request.cwd === cwd && request.prNumber === prNumber; + return request.cwd === targetCwd && request.prNumber === prNumber; }).length; } diff --git a/packages/app/src/hooks/use-pr-pane-data.ts b/packages/app/src/hooks/use-pr-pane-data.ts index 4db6e7cb5..c374dcbd2 100644 --- a/packages/app/src/hooks/use-pr-pane-data.ts +++ b/packages/app/src/hooks/use-pr-pane-data.ts @@ -28,6 +28,53 @@ export interface UsePrPaneDataResult { githubFeaturesEnabled: boolean; } +interface PrRepoIdentity { + prNumber: number | null; + repoOwner: string | null; + repoName: string | null; +} + +function extractPrRepoIdentity(status: CheckoutPrStatusLike): PrRepoIdentity { + const prNumber = status?.number ?? null; + const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null; + const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null; + return { prNumber, repoOwner, repoName }; +} + +type CheckoutPrStatusLike = ReturnType["status"]; + +interface ShouldFetchTimelineArgs { + daemonClient: unknown; + isConnected: boolean; + timelineEnabled: boolean; + githubFeaturesEnabled: boolean; + cwd: string; + identity: PrRepoIdentity; + timelineUnsupported: boolean; +} + +function shouldFetchTimelineFrom({ + daemonClient, + isConnected, + timelineEnabled, + githubFeaturesEnabled, + cwd, + identity, + timelineUnsupported, +}: ShouldFetchTimelineArgs): boolean { + return ( + !!daemonClient && + isConnected && + timelineEnabled && + githubFeaturesEnabled && + !!cwd && + identity.prNumber !== null && + identity.repoOwner !== null && + identity.repoName !== null && + !timelineUnsupported + ); +} + export function usePrPaneData({ serverId, cwd, @@ -38,23 +85,20 @@ export function usePrPaneData({ const isConnected = useHostRuntimeIsConnected(serverId); const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); const status = checkoutPrStatus.status; - const prNumber = status?.number ?? null; - const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null; - const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null; + const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status); const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false; const unsupportedKey = prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber }); const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false; - const shouldFetchTimeline = - !!daemonClient && - isConnected && - timelineEnabled && - githubFeaturesEnabled && - !!cwd && - prNumber !== null && - repoOwner !== null && - repoName !== null && - !timelineUnsupported; + const shouldFetchTimeline = shouldFetchTimelineFrom({ + daemonClient, + isConnected, + timelineEnabled, + githubFeaturesEnabled, + cwd, + identity: { prNumber, repoOwner, repoName }, + timelineUnsupported, + }); const timelineQuery = useQuery({ queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }), diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index cdfdef63f..abfca7c1e 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -11,39 +11,39 @@ import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types import { useSessionStore } from "@/stores/session-store"; import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot"; -type ProviderSnapshotUpdateMessage = { +interface ProviderSnapshotUpdateMessage { type: "providers_snapshot_update"; payload: { cwd: string; entries: ProviderSnapshotEntry[]; generatedAt: string; }; -}; +} type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void; -type ProvidersSnapshot = { +interface ProvidersSnapshot { entries: ProviderSnapshotEntry[]; generatedAt: string; requestId: string; -}; +} type HookResult = ReturnType["result"]; const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => { - const snapshotUpdateListeners: ProviderSnapshotUpdateListener[] = []; - const mockClient = { + const hoistedListeners: ProviderSnapshotUpdateListener[] = []; + const hoistedClient = { getProvidersSnapshot: vi.fn(), refreshProvidersSnapshot: vi.fn(), on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => { - snapshotUpdateListeners.push(listener); + hoistedListeners.push(listener); return () => {}; }), }; return { - mockClient, + mockClient: hoistedClient, mockRuntime: { - client: mockClient, + client: hoistedClient, isConnected: true, }, - snapshotUpdateListeners, + snapshotUpdateListeners: hoistedListeners, }; }); diff --git a/packages/app/src/hooks/use-push-token-registration.ts b/packages/app/src/hooks/use-push-token-registration.ts index 6b641f71d..78271cfdc 100644 --- a/packages/app/src/hooks/use-push-token-registration.ts +++ b/packages/app/src/hooks/use-push-token-registration.ts @@ -9,10 +9,14 @@ import { isWeb } from "@/constants/platform"; const STORAGE_PREFIX = "@paseo:expo-push-token:"; function getExpoProjectId(): string | null { - const fromEas = (Constants as any)?.easConfig?.projectId; + const constants = Constants as unknown as { + easConfig?: { projectId?: unknown }; + expoConfig?: { extra?: { eas?: { projectId?: unknown } } }; + }; + const fromEas = constants?.easConfig?.projectId; if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim(); - const fromExtra = (Constants as any)?.expoConfig?.extra?.eas?.projectId; + const fromExtra = constants?.expoConfig?.extra?.eas?.projectId; if (typeof fromExtra === "string" && fromExtra.trim()) return fromExtra.trim(); return null; diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 5859977d3..ab3f684af 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -185,7 +185,7 @@ describe("buildSidebarProjectsFromStructure", () => { ], }); - expect(projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]); + expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]); }); it("preserves the structure hook workspace order", () => { diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 88d57b8e7..0adc733e4 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -58,14 +58,14 @@ export type KeyboardActionDefinition = | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "worktree.archive"; scope: KeyboardActionScope }; -export type KeyboardActionHandler = { +export interface KeyboardActionHandler { handlerId: string; actions: readonly KeyboardActionId[]; enabled: boolean; priority: number; isActive?: () => boolean; handle: (action: KeyboardActionDefinition) => boolean; -}; +} type KeyboardActionRegistryEntry = KeyboardActionHandler & { registeredAt: number; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index ddcffc0c2..18cdcc9b7 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -94,7 +94,7 @@ function expectNoShortcutResolution(input: { expect(result.nextChordState).toEqual(initialChordState()); } -type MatchingShortcutCase = { +interface MatchingShortcutCase { name: string; event: Partial; context?: Partial; @@ -102,22 +102,22 @@ type MatchingShortcutCase = { payload?: unknown; preventDefault?: boolean; stopPropagation?: boolean; -}; +} -type NonMatchingShortcutCase = { +interface NonMatchingShortcutCase { name: string; event: Partial; context?: Partial; -}; +} -type HelpSectionCase = { +interface HelpSectionCase { name: string; context: { isMac: boolean; isDesktop: boolean; }; expectedKeys: Record; -}; +} describe("keyboard-shortcuts", () => { const matchingCases: MatchingShortcutCase[] = [ diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 7c2c93b4a..6c48662d8 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -11,41 +11,41 @@ export type { KeyCombo } from "@/keyboard/shortcut-string"; // --- Public types --- -export type KeyboardShortcutContext = { +export interface KeyboardShortcutContext { isMac: boolean; isDesktop: boolean; focusScope: KeyboardFocusScope; commandCenterOpen: boolean; -}; +} -export type KeyboardShortcutMatch = { +export interface KeyboardShortcutMatch { action: KeyboardActionId; payload: KeyboardShortcutPayload; preventDefault: boolean; stopPropagation: boolean; -}; +} -export type KeyboardShortcutHelpRow = { +export interface KeyboardShortcutHelpRow { id: string; label: string; keys: ShortcutKey[]; note?: string; -}; +} export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "panels" | "agent-input"; -export type KeyboardShortcutHelpSection = { +export interface KeyboardShortcutHelpSection { id: ShortcutSectionId; title: string; rows: KeyboardShortcutHelpRow[]; -}; +} // --- Binding definition types --- -type KeyboardShortcutPlatformContext = { +interface KeyboardShortcutPlatformContext { isMac: boolean; isDesktop: boolean; -}; +} interface ShortcutWhen { /** true = mac only, false = non-mac only */ @@ -1090,78 +1090,84 @@ function helpMatchesPlatform( // --- Public API --- -export function resolveKeyboardShortcut(input: { +function buildMatchFromBinding( + binding: ParsedShortcutBinding, + event: KeyboardEvent, +): KeyboardShortcutMatch { + return { + action: binding.action, + payload: resolvePayload(binding.payload, event), + preventDefault: binding.preventDefault ?? true, + stopPropagation: binding.stopPropagation ?? true, + }; +} + +function resolveInitialChordStep(input: { event: KeyboardEvent; context: KeyboardShortcutContext; chordState: ChordState; onChordReset: () => void; - bindings?: readonly ParsedShortcutBinding[]; -}): { - match: KeyboardShortcutMatch | null; - nextChordState: ChordState; - preventDefault: boolean; -}; -export function resolveKeyboardShortcut(input: { - event: KeyboardEvent; - context: KeyboardShortcutContext; - chordState: ChordState; - onChordReset: () => void; - bindings?: readonly ParsedShortcutBinding[]; + bindings: readonly ParsedShortcutBinding[]; }): { match: KeyboardShortcutMatch | null; nextChordState: ChordState; preventDefault: boolean; } { - const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input; + const { event, context, chordState, onChordReset, bindings } = input; + const advancingCandidateIndices: number[] = []; + let singleComboMatch: KeyboardShortcutMatch | null = null; - if (chordState.step === 0) { - const advancingCandidateIndices: number[] = []; - let singleComboMatch: KeyboardShortcutMatch | null = null; - - for (const [index, binding] of bindings.entries()) { - const firstCombo = binding.parsedChord[0]; - if (!firstCombo) { - continue; - } - if (!matchesCombo(firstCombo, event, context.isMac)) { - continue; - } - if (!matchesWhen(binding.when, context)) { - continue; - } - if (binding.parsedChord.length > 1) { - advancingCandidateIndices.push(index); - continue; - } - if (!singleComboMatch) { - singleComboMatch = { - action: binding.action, - payload: resolvePayload(binding.payload, event), - preventDefault: binding.preventDefault ?? true, - stopPropagation: binding.stopPropagation ?? true, - }; - } + for (const [index, binding] of bindings.entries()) { + const firstCombo = binding.parsedChord[0]; + if (!firstCombo) { + continue; } - - if (advancingCandidateIndices.length > 0) { - return { - match: null, - nextChordState: { - candidateIndices: advancingCandidateIndices, - step: 1, - timeoutId: createChordTimeout(onChordReset), - }, - preventDefault: true, - }; + if (!matchesCombo(firstCombo, event, context.isMac)) { + continue; } + if (!matchesWhen(binding.when, context)) { + continue; + } + if (binding.parsedChord.length > 1) { + advancingCandidateIndices.push(index); + continue; + } + if (!singleComboMatch) { + singleComboMatch = buildMatchFromBinding(binding, event); + } + } + if (advancingCandidateIndices.length > 0) { return { - match: singleComboMatch, - nextChordState: resetChordState(chordState), - preventDefault: false, + match: null, + nextChordState: { + candidateIndices: advancingCandidateIndices, + step: 1, + timeoutId: createChordTimeout(onChordReset), + }, + preventDefault: true, }; } + return { + match: singleComboMatch, + nextChordState: resetChordState(chordState), + preventDefault: false, + }; +} + +function resolveAdvancingChordStep(input: { + event: KeyboardEvent; + context: KeyboardShortcutContext; + chordState: ChordState; + onChordReset: () => void; + bindings: readonly ParsedShortcutBinding[]; +}): { + match: KeyboardShortcutMatch | null; + nextChordState: ChordState; + preventDefault: boolean; +} { + const { event, context, chordState, onChordReset, bindings } = input; const matchingCandidateIndices: number[] = []; let completedMatch: KeyboardShortcutMatch | null = null; @@ -1181,12 +1187,7 @@ export function resolveKeyboardShortcut(input: { continue; } if (chordState.step + 1 === binding.parsedChord.length) { - completedMatch = { - action: binding.action, - payload: resolvePayload(binding.payload, event), - preventDefault: binding.preventDefault ?? true, - stopPropagation: binding.stopPropagation ?? true, - }; + completedMatch = buildMatchFromBinding(binding, event); break; } matchingCandidateIndices.push(index); @@ -1220,6 +1221,24 @@ export function resolveKeyboardShortcut(input: { }; } +export function resolveKeyboardShortcut(input: { + event: KeyboardEvent; + context: KeyboardShortcutContext; + chordState: ChordState; + onChordReset: () => void; + bindings?: readonly ParsedShortcutBinding[]; +}): { + match: KeyboardShortcutMatch | null; + nextChordState: ChordState; + preventDefault: boolean; +} { + const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input; + if (chordState.step === 0) { + return resolveInitialChordStep({ event, context, chordState, onChordReset, bindings }); + } + return resolveAdvancingChordStep({ event, context, chordState, onChordReset, bindings }); +} + export function getBindingIdForAction( actionId: string, platform: { isMac: boolean; isDesktop: boolean }, diff --git a/packages/app/src/panels/agent-panel.test.tsx b/packages/app/src/panels/agent-panel.test.tsx index e4c024445..9a124b046 100644 --- a/packages/app/src/panels/agent-panel.test.tsx +++ b/packages/app/src/panels/agent-panel.test.tsx @@ -15,7 +15,7 @@ import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; -type PanelTestTheme = { +interface PanelTestTheme { colors: { foreground: string; foregroundMuted: string; @@ -31,7 +31,7 @@ type PanelTestTheme = { fontSize: Record; fontWeight: Record; iconSize: Record; -}; +} type PanelTestStyles = Record; type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles; @@ -294,6 +294,19 @@ function seedReadyAgent(agent: Agent = makeAgent()) { store.setAgentAuthoritativeHistoryApplied("server", "agent", true); } +function buildTestPaneValue() { + return { + serverId: "server", + workspaceId: "workspace", + tabId: "agent-agent", + target: { kind: "agent" as const, agentId: "agent" }, + openTab: vi.fn(), + closeCurrentTab: vi.fn(), + retargetCurrentTab: vi.fn(), + openFileInWorkspace: vi.fn(), + }; +} + async function renderAgentPanel( root: Root, focus: PaneFocusContextValue = { @@ -309,21 +322,11 @@ async function renderAgentPanel( mutations: { retry: false }, }, }); + const paneValue = buildTestPaneValue(); await act(async () => { root.render( - + @@ -392,6 +395,16 @@ async function updateCurrentAgentCwd(cwd: string) { }); } +function archiveSeededAgent(previous: Map): Map { + const current = previous.get("agent"); + if (!current) { + throw new Error("Expected seeded agent"); + } + const next = new Map(previous); + next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") }); + return next; +} + describe("AgentPanel render isolation", () => { let root: Root | null = null; let container: HTMLElement | null = null; @@ -470,15 +483,7 @@ describe("AgentPanel render isolation", () => { expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline); await act(async () => { - useSessionStore.getState().setAgents("server", (previous) => { - const current = previous.get("agent"); - if (!current) { - throw new Error("Expected seeded agent"); - } - const next = new Map(previous); - next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") }); - return next; - }); + useSessionStore.getState().setAgents("server", archiveSeededAgent); await Promise.resolve(); }); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index abaaf4901..1d6bce30d 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -21,6 +21,7 @@ import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { type AgentScreenAgent, type AgentScreenMissingState, + type AgentScreenViewState, useAgentScreenStateMachine, } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; @@ -50,6 +51,114 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +interface ChatAgentStateShape { + serverId: string | null; + id: string | null; + status: Agent["status"] | null; + cwd: string | null; + lastError?: Agent["lastError"] | null; +} + +interface ChatAgentSelectedState extends ChatAgentStateShape { + archivedAt: Date | null; + requiresAttention: boolean; + attentionReason: Agent["attentionReason"] | null; +} + +function resolveChatAgentFromSession( + state: ReturnType, + serverId: string, + agentId: string | undefined, +): Agent | null { + if (!agentId) return null; + const session = state.sessions[serverId]; + return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null; +} + +const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = { + serverId: null, + id: null, + status: null, + cwd: null, + lastError: null, + archivedAt: null, + requiresAttention: false, + attentionReason: null, +}; + +function selectChatAgentState( + state: ReturnType, + serverId: string, + agentId: string | undefined, +): ChatAgentSelectedState { + const agent = resolveChatAgentFromSession(state, serverId, agentId); + if (!agent) return EMPTY_CHAT_AGENT_STATE; + return { + serverId: agent.serverId, + id: agent.id, + status: agent.status, + cwd: agent.cwd, + lastError: agent.lastError ?? null, + archivedAt: agent.archivedAt ?? null, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + }; +} + +function buildChatAgentFromState( + state: ChatAgentStateShape, + projectPlacement: Agent["projectPlacement"] | null, +): AgentScreenAgent | null { + if (!state.serverId || !state.id || !state.status || !state.cwd) { + return null; + } + return { + serverId: state.serverId, + id: state.id, + status: state.status, + cwd: state.cwd, + lastError: state.lastError ?? null, + projectPlacement, + }; +} + +function renderChatAgentNonReadyView(args: { + viewState: AgentScreenViewState; + effectiveAgent: AgentScreenAgent | null; + theme: ReturnType["theme"]; +}): React.ReactElement | null { + const { viewState, effectiveAgent, theme } = args; + if (viewState.tag === "not_found") { + return ( + + + Agent not found + + + ); + } + if (viewState.tag === "error") { + return ( + + + Failed to load agent + {viewState.message} + + + ); + } + if (viewState.tag === "boot" || !effectiveAgent) { + return ( + + + + + + ); + } + return null; +} + function formatProviderLabel(provider: Agent["provider"]): string { if (!provider) { return "Agent"; @@ -311,7 +420,7 @@ function AgentPanelBody({ onOpenWorkspaceFile?: (input: { filePath: string }) => void; }) { const { theme } = useUnistyles(); - const { isArchivingAgent } = useArchiveAgent(); + const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const projectPlacement = useStoreWithEqualityFn( useSessionStore, @@ -388,6 +497,7 @@ function AgentPanelBody({ storeFetchedAgentDetail({ serverId, result }); setLookupState({ tag: "idle" }); + return; }) .catch((error) => { if (attemptToken !== lookupAttemptTokenRef.current) { @@ -445,8 +555,6 @@ function AgentPanelBody({ ); } - const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); - return ( { - const session = state.sessions[serverId]; - const agent = agentId - ? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null) - : null; - return { - serverId: agent?.serverId ?? null, - id: agent?.id ?? null, - status: agent?.status ?? null, - cwd: agent?.cwd ?? null, - lastError: agent?.lastError ?? null, - archivedAt: agent?.archivedAt ?? null, - requiresAttention: agent?.requiresAttention ?? false, - attentionReason: agent?.attentionReason ?? null, - }; - }), + useShallow((state) => selectChatAgentState(state, serverId, agentId)), ); const projectPlacement = useStoreWithEqualityFn( useSessionStore, @@ -675,25 +768,8 @@ function ChatAgentContent({ const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; const agent = useMemo( - () => - agentState.serverId && agentState.id && agentState.status && agentState.cwd - ? { - serverId: agentState.serverId, - id: agentState.id, - status: agentState.status, - cwd: agentState.cwd, - lastError: agentState.lastError ?? null, - projectPlacement, - } - : null, - [ - agentState.serverId, - agentState.id, - agentState.status, - agentState.cwd, - agentState.lastError, - projectPlacement, - ], + () => buildChatAgentFromState(agentState, projectPlacement), + [agentState, projectPlacement], ); const placeholderAgent: AgentScreenAgent | null = useMemo(() => { @@ -836,6 +912,7 @@ function ChatAgentContent({ return; } setMissingAgentState({ kind: "idle" }); + return; }) .catch((error) => { if (attemptToken !== initAttemptTokenRef.current) { @@ -860,43 +937,25 @@ function ChatAgentContent({ shouldUseOptimisticStream, ]); - if (viewState.tag === "not_found") { - return ( - - - Agent not found - - - ); - } + const animatedContentStyle = useMemo( + () => [styles.content, animatedKeyboardStyle], + [animatedKeyboardStyle], + ); - if (viewState.tag === "error") { - return ( - - - Failed to load agent - {viewState.message} - - - ); - } - - if (viewState.tag === "boot" || !effectiveAgent) { - return ( - - - - - - ); - } + const nonReadyView = renderChatAgentNonReadyView({ + viewState, + effectiveAgent, + theme, + }); + if (nonReadyView) return nonReadyView; + invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); return ( - + [styles.inputAreaWrapper, { paddingBottom: insets.bottom }], + [insets.bottom], + ); + return ( - + { + openFileInWorkspace(filePath); + }, + [openFileInWorkspace], + ); + + const handleCreated = useCallback( + (agentSnapshot: Parameters[0]) => { + const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + useSessionStore.getState().setAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentSnapshot.id, normalized); + return next; + }); + }, + [retargetCurrentTab, serverId], + ); + return ( { - openFileInWorkspace(filePath); - }} - onCreated={(agentSnapshot) => { - const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); - retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, normalized); - return next; - }); - }} + onOpenWorkspaceFile={handleOpenWorkspaceFile} + onCreated={handleCreated} /> ); } diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index d830ab259..a9bcf9d71 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -6,8 +6,15 @@ import { usePaneContext } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; +const CENTERED_PADDED_STYLE = { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 16, +} as const; + function useFilePanelDescriptor(target: { kind: "file"; path: string }) { - const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path; + const fileName = target.path.split("/").findLast(Boolean) ?? target.path; return { label: fileName, subtitle: target.path, @@ -26,7 +33,7 @@ function FilePanel() { invariant(target.kind === "file", "FilePanel requires file target"); if (!workspaceDirectory) { return ( - + Workspace execution directory not found. ); diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 3931ab2ec..e5ca777e6 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -1,13 +1,23 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { + ActivityIndicator, + Pressable, + type PressableStateCallbackType, + ScrollView, + Text, + View, +} from "react-native"; import invariant from "tiny-invariant"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; -import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { + useWorkspaceSetupStore, + type WorkspaceSetupSnapshot, +} from "@/stores/workspace-setup-store"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; function useSetupPanelDescriptor( @@ -88,6 +98,58 @@ function processCarriageReturns(text: string): string { .join("\n"); } +type SetupCommand = WorkspaceSetupSnapshot["detail"]["commands"][number]; + +const EMPTY_COMMANDS: SetupCommand[] = []; + +function resolveAutoExpandIndex(commands: { index: number; status: string }[]): number | null { + const running = commands.find((c) => c.status === "running"); + if (running) return running.index; + if (commands.length > 0) return commands[commands.length - 1].index; + return null; +} + +function resolveSetupStatusLabel(status: string | undefined): string { + if (status === "running") return "Running"; + if (status === "completed") return "Completed"; + if (status === "failed") return "Failed"; + return "Waiting for setup output"; +} + +function resolveCommandLog( + command: SetupCommand, + autoExpandIndex: number | null, + log: string, +): string { + if ("log" in command && typeof command.log === "string") { + return command.log; + } + if (command.index === autoExpandIndex) return log; + return ""; +} + +interface BuildCommandRowPropsArgs { + command: SetupCommand; + autoExpandIndex: number | null; + log: string; + expandedIndices: Set; + manuallyCollapsed: Set; + snapshotError: string | null | undefined; +} + +function buildCommandRowState(args: BuildCommandRowPropsArgs) { + const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args; + const isExpanded = expandedIndices.has(command.index); + const hasError = command.status === "failed" && Boolean(snapshotError); + const commandLog = resolveCommandLog(command, autoExpandIndex, log); + const hasLog = commandLog.trim().length > 0; + const isExpandable = command.status !== "running" || hasLog || hasError; + const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); + const showDetail = isExpanded || isAutoExpanded; + const processedLog = hasLog ? processCarriageReturns(commandLog) : ""; + return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog }; +} + function SetupPanel() { const { theme } = useUnistyles(); const { serverId, target } = usePaneContext(); @@ -115,13 +177,14 @@ function SetupPanel() { payload: { workspaceId: response.workspaceId, ...response.snapshot }, }); } + return; }) .catch(() => { // Server may not support this yet — ignore }); }, [client, snapshot, serverId, target.workspaceId, upsertProgress]); - const commands = snapshot?.detail.commands ?? []; + const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS; const log = snapshot?.detail.log ?? ""; const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; @@ -143,31 +206,17 @@ function SetupPanel() { next.add(index); // If the user re-expands, remove from manually collapsed setManuallyCollapsed((mc) => { - const next = new Set(mc); - next.delete(index); - return next; + const updated = new Set(mc); + updated.delete(index); + return updated; }); } return next; }); }, []); - // Determine which command should auto-expand (running or last completed). - const autoExpandIndex = (() => { - const running = commands.find((c) => c.status === "running"); - if (running) return running.index; - if (commands.length > 0) return commands[commands.length - 1].index; - return null; - })(); - - const statusLabel = - snapshot?.status === "running" - ? "Running" - : snapshot?.status === "completed" - ? "Completed" - : snapshot?.status === "failed" - ? "Failed" - : "Waiting for setup output"; + const autoExpandIndex = resolveAutoExpandIndex(commands); + const statusLabel = resolveSetupStatusLabel(snapshot?.status); return ( Setting up workspace... - ) : hasNoSetupCommands ? ( + ) : null} + {!isWaiting && hasNoSetupCommands ? ( - ) : ( + ) : null} + {!isWaiting && !hasNoSetupCommands ? ( {commands.map((command) => { - const isExpanded = expandedIndices.has(command.index); - const hasError = command.status === "failed" && snapshot?.error; - - // Per-command log: use command.log if available, fall back to detail.log for the auto-expand target - const commandLog = (() => { - if ("log" in command && typeof command.log === "string") { - return command.log; - } - // Fallback: show detail.log on the auto-expand target command - if (command.index === autoExpandIndex) return log; - return ""; - })(); - const hasLog = commandLog.trim().length > 0; - - // All non-running commands are expandable (completed/failed) - const isExpandable = command.status !== "running" || hasLog || !!hasError; - - // Auto-expand the active command unless the user manually collapsed it - const isAutoExpanded = - command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); - const showDetail = isExpanded || isAutoExpanded; - - const processedLog = hasLog ? processCarriageReturns(commandLog) : ""; + const rowState = buildCommandRowState({ + command, + autoExpandIndex, + log, + expandedIndices, + manuallyCollapsed, + snapshotError: snapshot?.error, + }); return ( - - toggleExpanded(command.index, isAutoExpanded)} - style={({ pressed }) => [ - styles.commandRow, - showDetail && styles.commandRowExpanded, - pressed && styles.commandRowPressed, - ]} - accessibilityRole="button" - accessibilityState={{ expanded: showDetail }} - > - - - - - {command.command} - - {command.durationMs != null ? ( - {formatDuration(command.durationMs)} - ) : null} - - - {showDetail ? ( - - {hasLog ? ( - - - {processedLog} - - - ) : ( - - No output - - )} - {hasError ? ( - - - {snapshot.error} - - - ) : null} - - ) : null} - + ); })} - {/* If there's log but no commands yet, or log without a target command, show standalone */} - {commands.length === 0 && log.trim().length > 0 ? ( + + + + ) : null} + + ); +} + +interface SetupCommandRowProps { + command: SetupCommand; + showDetail: boolean; + isAutoExpanded: boolean; + isExpandable: boolean; + hasLog: boolean; + hasError: boolean; + processedLog: string; + errorMessage: string | null; + foregroundMutedColor: string; + onToggle: (index: number, isAutoExpanded: boolean) => void; +} + +function SetupCommandRow({ + command, + showDetail, + isAutoExpanded, + isExpandable, + hasLog, + hasError, + processedLog, + errorMessage, + foregroundMutedColor, + onToggle, +}: SetupCommandRowProps) { + const handlePress = useCallback(() => { + if (!isExpandable) return; + onToggle(command.index, isAutoExpanded); + }, [command.index, isAutoExpanded, isExpandable, onToggle]); + + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.commandRow, + showDetail && styles.commandRowExpanded, + pressed && styles.commandRowPressed, + ], + [showDetail], + ); + + const accessibilityState = useMemo(() => ({ expanded: showDetail }), [showDetail]); + + return ( + + + + + + + {command.command} + + {command.durationMs != null ? ( + {formatDuration(command.durationMs)} + ) : null} + + + {showDetail ? ( + + {hasLog ? ( - {log} + {processedLog} - ) : null} - - {/* Show error at top level if no commands failed but there's a setup error */} - {snapshot?.error && !commands.some((c) => c.status === "failed") ? ( + ) : ( + + No output + + )} + {hasError && errorMessage ? ( - {snapshot.error} + {errorMessage} ) : null} - )} - + ) : null} + ); } @@ -324,6 +388,50 @@ export const setupPanelRegistration: PanelRegistration<"setup"> = { useDescriptor: useSetupPanelDescriptor, }; +function SetupCommandChevron({ showDetail, color }: { showDetail: boolean; color: string }) { + const chevronStyle = useMemo( + () => [styles.chevron, showDetail && styles.chevronExpanded], + [showDetail], + ); + return ; +} + +function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) { + if (commands.length !== 0 || log.trim().length === 0) return null; + return ( + + + {log} + + + ); +} + +function TopLevelSetupError({ + snapshotError, + commands, +}: { + snapshotError: string | null; + commands: SetupCommand[]; +}) { + if (!snapshotError) return null; + if (commands.some((c) => c.status === "failed")) return null; + return ( + + + {snapshotError} + + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index cd6499b1e..9da878502 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -14,6 +14,14 @@ import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; +const FLEX_FILL_STYLE = { flex: 1 } as const; +const CENTERED_PADDED_STYLE = { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 16, +} as const; + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -84,12 +92,12 @@ function TerminalPanel() { invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); if (!isWorkspaceFocused) { - return ; + return ; } if (!workspaceDirectory) { return ( - + {workspaceAuthority.ok ? "Workspace execution directory not found." diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts index ca9707e55..c214cc2e1 100644 --- a/packages/app/src/polyfills/crypto.ts +++ b/packages/app/src/polyfills/crypto.ts @@ -7,23 +7,28 @@ declare global { } } +interface MutableGlobal { + TextEncoder?: typeof TextEncoder; + TextDecoder?: typeof TextDecoder; + crypto?: Crypto; +} + export function polyfillCrypto(): void { + const g = globalThis as unknown as MutableGlobal; + // Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport). // Hermes may not provide them in all configurations. - if (typeof (globalThis as any).TextEncoder !== "function") { + if (typeof g.TextEncoder !== "function") { class BufferTextEncoder { encode(input = ""): Uint8Array { return Uint8Array.from(Buffer.from(String(input), "utf8")); } } - (globalThis as any).TextEncoder = BufferTextEncoder as any; + g.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder; } - if (typeof (globalThis as any).TextDecoder !== "function") { + if (typeof g.TextDecoder !== "function") { class BufferTextDecoder { - constructor(_label?: string, _options?: unknown) { - // no-op - } decode(input?: ArrayBuffer | ArrayBufferView): string { if (input == null) return ""; if (input instanceof ArrayBuffer) { @@ -35,30 +40,24 @@ export function polyfillCrypto(): void { return Buffer.from(String(input), "utf8").toString("utf8"); } } - (globalThis as any).TextDecoder = BufferTextDecoder as any; + g.TextDecoder = BufferTextDecoder as unknown as typeof TextDecoder; } - const existing = (globalThis as any).crypto as Crypto | null | undefined; - let target = existing; - if (!target) { - target = {} as Crypto; - (globalThis as any).crypto = target; + if (!g.crypto) { + g.crypto = {} as Crypto; } - if (typeof (globalThis as any).crypto?.randomUUID !== "function") { - if (!globalThis.crypto) { - (globalThis as any).crypto = {} as Crypto; - } - globalThis.crypto.randomUUID = () => + if (typeof g.crypto.randomUUID !== "function") { + g.crypto.randomUUID = () => ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`; } - if (typeof (globalThis as any).crypto?.getRandomValues !== "function") { - if (!globalThis.crypto) { - (globalThis as any).crypto = {} as Crypto; - } - globalThis.crypto.getRandomValues = (array: T): T => { - return ExpoCrypto.getRandomValues(array as any) as T; + if (typeof g.crypto.getRandomValues !== "function") { + g.crypto.getRandomValues = (array: T): T => { + if (array === null) return array; + return ExpoCrypto.getRandomValues( + array as unknown as Parameters[0], + ) as unknown as T; }; } } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 82dd8718a..73a2635be 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -400,7 +400,7 @@ describe("HostRuntimeController", () => { createClient: () => { throw new Error("should adopt the probe client"); }, - connectToDaemon: async ({ host, connection }) => { + connectToDaemon: async ({ host: hostProfile, connection }) => { const client = makeConnectedProbeClient(connection.id === "direct:lan:6767" ? 12 : 30); if (connection.id === "relay:relay.paseo.sh:443") { client.ping = async () => ({ rttMs: await slowPing.promise }); @@ -408,8 +408,8 @@ describe("HostRuntimeController", () => { clients.push(client); return { client: client as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }; }, getClientId: async () => "cid_test_runtime", @@ -450,7 +450,7 @@ describe("HostRuntimeController", () => { createClient: () => { throw new Error("should adopt probe clients"); }, - connectToDaemon: async ({ host, connection }) => { + connectToDaemon: async ({ host: hostProfile, connection }) => { probeAttempts.push(connection.id); const value = latencies[connection.id]; if (value instanceof Error) { @@ -461,8 +461,8 @@ describe("HostRuntimeController", () => { } return { client: makeConnectedProbeClient(value) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }; }, getClientId: async () => "cid_test_runtime", @@ -873,10 +873,10 @@ describe("HostRuntimeController", () => { createdClients.push(client); return client as unknown as DaemonClient; }, - connectToDaemon: async ({ host }) => ({ + connectToDaemon: async ({ host: hostProfile }) => ({ client: makeConnectedProbeClient(10) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }), getClientId: async () => "cid_test_runtime", }; @@ -950,7 +950,7 @@ describe("HostRuntimeController", () => { host, deps: { createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => { + connectToDaemon: async ({ host: hostProfile }) => { probeCalls += 1; const client = new FakeDaemonClient(); client.connectCalls = 1; @@ -963,8 +963,8 @@ describe("HostRuntimeController", () => { }; return { client: client as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }; }, getClientId: async () => "cid_test_runtime", @@ -1005,12 +1005,12 @@ describe("HostRuntimeController", () => { createdClients.push(client); return client as unknown as DaemonClient; }, - connectToDaemon: async ({ host }) => { + connectToDaemon: async ({ host: hostProfile }) => { const client = makeConnectedProbeClient(10); return { client: client as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }; }, getClientId: async () => "cid_test_runtime", @@ -1173,10 +1173,10 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ + connectToDaemon: async ({ host: hostProfile }) => ({ client: fakeClient as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }), getClientId: async () => "cid_test_runtime", }, @@ -1224,10 +1224,10 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ + connectToDaemon: async ({ host: hostProfile }) => ({ client: fakeClient as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }), getClientId: async () => "cid_test_runtime", }, @@ -1295,10 +1295,10 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ + connectToDaemon: async ({ host: hostProfile }) => ({ client: fakeClient as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }), getClientId: async () => "cid_test_runtime", }, @@ -1364,10 +1364,10 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ + connectToDaemon: async ({ host: hostProfile }) => ({ client: fakeClient as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }), getClientId: async () => "cid_test_runtime", }, @@ -1435,10 +1435,10 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ + connectToDaemon: async ({ host: hostProfile }) => ({ client: fakeClient as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, }), getClientId: async () => "cid_test_runtime", }, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 7e03ea3d8..9a6d1600c 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -55,7 +55,7 @@ export type HostRuntimeAgentDirectoryStatus = | "error_before_first_success" | "error_after_ready"; -export type HostRuntimeSnapshot = { +export interface HostRuntimeSnapshot { serverId: string; activeConnectionId: string | null; activeConnection: ActiveConnection | null; @@ -68,7 +68,7 @@ export type HostRuntimeSnapshot = { hasEverLoadedAgentDirectory: boolean; probeByConnectionId: Map; clientGeneration: number; -}; +} type HostRuntimeSnapshotPatch = Partial>; @@ -112,7 +112,7 @@ function hashForLog(value: string): string { return `h_${Math.abs(hash).toString(16)}`; } -export type HostRuntimeControllerDeps = { +export interface HostRuntimeControllerDeps { createClient: (input: { host: HostProfile; connection: HostConnection; @@ -125,15 +125,15 @@ export type HostRuntimeControllerDeps = { hostname: string | null; }>; getClientId: () => Promise; -}; +} -export type HostRuntimeStartOptions = { +export interface HostRuntimeStartOptions { autoProbe?: boolean; initialConnection?: { connectionId: string; existingClient: DaemonClient; }; -}; +} const PROBE_TICK_MS = 2_000; const PROBE_STEADY_MS = 10_000; @@ -239,6 +239,82 @@ type HostRuntimeConnectionMachineEvent = | { type: "no_connections" } | { type: "stopped" }; +function extractPreviousConnectionRef(state: HostRuntimeConnectionMachineState): { + id: string | null; + connection: ActiveConnection | null; +} { + if ( + state.tag === "connecting" || + state.tag === "online" || + state.tag === "offline" || + state.tag === "error" + ) { + return { id: state.activeConnectionId, connection: state.activeConnection }; + } + return { id: null, connection: null }; +} + +function buildConnectionStateFromStatus( + previousActiveConnectionId: string, + previousActiveConnection: ActiveConnection, + event: Extract, +): HostRuntimeConnectionMachineState | null { + const status = event.state.status; + if (status === "connected") { + return { + tag: "online", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + lastOnlineAt: new Date().toISOString(), + }; + } + if (status === "connecting" || status === "idle") { + return { + tag: "connecting", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + if (status === "disposed") { + return { + tag: "offline", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + return null; +} + +function resolveConnectionStateResult( + previousActiveConnectionId: string, + previousActiveConnection: ActiveConnection, + event: Extract, +): HostRuntimeConnectionMachineState { + const statusResult = buildConnectionStateFromStatus( + previousActiveConnectionId, + previousActiveConnection, + event, + ); + if (statusResult) return statusResult; + + const disconnectedReason = + event.state.status === "disconnected" ? (event.state.reason ?? null) : null; + const reason = disconnectedReason ?? event.lastError ?? null; + if (!reason || reason === "client_closed") { + return { + tag: "offline", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + return { + tag: "error", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + message: reason, + }; +} + function nextConnectionMachineState(input: { state: HostRuntimeConnectionMachineState; event: HostRuntimeConnectionMachineEvent; @@ -254,20 +330,11 @@ function nextConnectionMachineState(input: { } if (event.type === "connect_failed") { + const failed = extractPreviousConnectionRef(state); return { tag: "error", - activeConnectionId: - state.tag === "connecting" || state.tag === "online" - ? state.activeConnectionId - : state.tag === "offline" || state.tag === "error" - ? state.activeConnectionId - : null, - activeConnection: - state.tag === "connecting" || state.tag === "online" - ? state.activeConnection - : state.tag === "offline" || state.tag === "error" - ? state.activeConnection - : null, + activeConnectionId: failed.id, + activeConnection: failed.connection, message: event.message, }; } @@ -280,20 +347,8 @@ function nextConnectionMachineState(input: { }; } - const previousActiveConnectionId = - state.tag === "connecting" || state.tag === "online" - ? state.activeConnectionId - : state.tag === "offline" || state.tag === "error" - ? state.activeConnectionId - : null; - const previousActiveConnection = - state.tag === "connecting" || state.tag === "online" - ? state.activeConnection - : state.tag === "offline" || state.tag === "error" - ? state.activeConnection - : null; - - if (!previousActiveConnectionId || !previousActiveConnection) { + const previous = extractPreviousConnectionRef(state); + if (!previous.id || !previous.connection) { return state.tag === "booting" ? state : { @@ -303,46 +358,7 @@ function nextConnectionMachineState(input: { }; } - if (event.state.status === "connected") { - return { - tag: "online", - activeConnectionId: previousActiveConnectionId, - activeConnection: previousActiveConnection, - lastOnlineAt: new Date().toISOString(), - }; - } - - if (event.state.status === "connecting" || event.state.status === "idle") { - return { - tag: "connecting", - activeConnectionId: previousActiveConnectionId, - activeConnection: previousActiveConnection, - }; - } - - if (event.state.status === "disposed") { - return { - tag: "offline", - activeConnectionId: previousActiveConnectionId, - activeConnection: previousActiveConnection, - }; - } - - const reason = event.state.reason ?? event.lastError ?? null; - if (!reason || reason === "client_closed") { - return { - tag: "offline", - activeConnectionId: previousActiveConnectionId, - activeConnection: previousActiveConnection, - }; - } - - return { - tag: "error", - activeConnectionId: previousActiveConnectionId, - activeConnection: previousActiveConnection, - message: reason, - }; + return resolveConnectionStateResult(previous.id, previous.connection, event); } function toSnapshotConnectionPatch( @@ -956,36 +972,28 @@ export class HostRuntimeController { return this.isCurrentProbeRequest(expectedProbeVersion); } - private async switchToConnection(input: { - connectionId: string; - expectedProbeVersion?: number; - existingClient?: DaemonClient; - }): Promise { - if (!this.canProceedForProbe(input.expectedProbeVersion)) { - if (input.existingClient) { - await input.existingClient.close().catch(() => undefined); - } - return; + private async abortSwitchWithClient(client: DaemonClient | undefined): Promise { + if (client) { + await client.close().catch(() => undefined); } - const { connectionId, expectedProbeVersion, existingClient } = input; - const connection = findConnectionById(this.host, connectionId); - if (!connection) { - if (existingClient) { - await existingClient.close().catch(() => undefined); - } - return; - } - const requestVersion = ++this.switchRequestVersion; + } - let clientId: string; + private isSwitchStillValid(requestVersion: number, expectedProbeVersion?: number): boolean { + return ( + this.isCurrentSwitchRequest(requestVersion) && this.canProceedForProbe(expectedProbeVersion) + ); + } + + private async resolveClientIdForSwitch(args: { + existingClient: DaemonClient | undefined; + requestVersion: number; + }): Promise { try { - clientId = await this.resolveClientId(); + return await this.resolveClientId(); } catch (error) { - if (existingClient) { - await existingClient.close().catch(() => undefined); - } - if (!this.isCurrentSwitchRequest(requestVersion)) { - return; + await this.abortSwitchWithClient(args.existingClient); + if (!this.isCurrentSwitchRequest(args.requestVersion)) { + return null; } const message = toErrorMessage(error); this.applyConnectionEvent({ @@ -995,22 +1003,11 @@ export class HostRuntimeController { this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState), }); - return; - } - - if (!this.isCurrentSwitchRequest(requestVersion)) { - if (existingClient) { - await existingClient.close().catch(() => undefined); - } - return; - } - if (!this.canProceedForProbe(expectedProbeVersion)) { - if (existingClient) { - await existingClient.close().catch(() => undefined); - } - return; + return null; } + } + private async disposePreviousActiveClient(): Promise { if (this.unsubscribeClientStatus) { this.unsubscribeClientStatus(); this.unsubscribeClientStatus = null; @@ -1020,16 +1017,52 @@ export class HostRuntimeController { this.activeClient = null; await previousClient.close().catch(() => undefined); } - if (!this.isCurrentSwitchRequest(requestVersion)) { - if (existingClient) { - await existingClient.close().catch(() => undefined); - } + } + + private buildAgentDirectoryStatusPatch(): Partial { + if (this.snapshot.hasEverLoadedAgentDirectory) return {}; + const tag = this.connectionMachineState.tag; + if (tag === "connecting" || tag === "online") { + return { agentDirectoryStatus: "initial_loading", agentDirectoryError: null }; + } + if (tag === "error") { + return { + agentDirectoryStatus: "error_before_first_success", + agentDirectoryError: this.connectionMachineState.message, + }; + } + return { agentDirectoryStatus: "idle", agentDirectoryError: null }; + } + + private async switchToConnection(input: { + connectionId: string; + expectedProbeVersion?: number; + existingClient?: DaemonClient; + }): Promise { + const { connectionId, expectedProbeVersion, existingClient } = input; + if (!this.canProceedForProbe(expectedProbeVersion)) { + await this.abortSwitchWithClient(existingClient); return; } - if (!this.canProceedForProbe(expectedProbeVersion)) { - if (existingClient) { - await existingClient.close().catch(() => undefined); - } + const connection = findConnectionById(this.host, connectionId); + if (!connection) { + await this.abortSwitchWithClient(existingClient); + return; + } + const requestVersion = ++this.switchRequestVersion; + + const clientId = await this.resolveClientIdForSwitch({ existingClient, requestVersion }); + if (clientId === null) return; + + if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { + await this.abortSwitchWithClient(existingClient); + return; + } + + await this.disposePreviousActiveClient(); + + if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { + await this.abortSwitchWithClient(existingClient); return; } @@ -1045,14 +1078,12 @@ export class HostRuntimeController { clientId, runtimeGeneration: nextGeneration, }); - if (!this.isCurrentSwitchRequest(requestVersion)) { - await client.close().catch(() => undefined); - return; - } - if (!this.canProceedForProbe(expectedProbeVersion)) { + + if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { await client.close().catch(() => undefined); return; } + this.activeClient = client; this.applyConnectionEvent({ type: "select_connection", @@ -1081,24 +1112,8 @@ export class HostRuntimeController { }); const patch: HostRuntimeSnapshotPatch = { ...toSnapshotConnectionPatch(this.connectionMachineState), + ...this.buildAgentDirectoryStatusPatch(), }; - - if (!this.snapshot.hasEverLoadedAgentDirectory) { - if ( - this.connectionMachineState.tag === "connecting" || - this.connectionMachineState.tag === "online" - ) { - patch.agentDirectoryStatus = "initial_loading"; - patch.agentDirectoryError = null; - } else if (this.connectionMachineState.tag === "error") { - patch.agentDirectoryStatus = "error_before_first_success"; - patch.agentDirectoryError = this.connectionMachineState.message; - } else { - patch.agentDirectoryStatus = "idle"; - patch.agentDirectoryError = null; - } - } - this.updateSnapshot(patch); }); diff --git a/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts index 393326158..7b2bedf13 100644 --- a/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts +++ b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts @@ -1,9 +1,9 @@ import type { BottomAnchorRouteRequest } from "@/components/use-bottom-anchor-controller"; -export type RouteBottomAnchorIntent = { +export interface RouteBottomAnchorIntent { routeKey: string; reason: BottomAnchorRouteRequest["reason"]; -}; +} export function deriveRouteBottomAnchorIntent(input: { cachedIntent: RouteBottomAnchorIntent | null; diff --git a/packages/app/src/screens/new-workspace-screen.test.tsx b/packages/app/src/screens/new-workspace-screen.test.tsx index 0ac9386ee..93d9e99f3 100644 --- a/packages/app/src/screens/new-workspace-screen.test.tsx +++ b/packages/app/src/screens/new-workspace-screen.test.tsx @@ -17,7 +17,7 @@ const { saveDraftInputMock, clearDraftInputMock, queueDraftSubmissionMock, - createdAgent, + createdAgent: _createdAgent, createdWorkspace, prItem, prItemB, @@ -25,7 +25,7 @@ const { initialAttachments, initialDraftState, } = vi.hoisted(() => { - const theme = { + const hoistedTheme = { spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, iconSize: { sm: 14, md: 18, lg: 22 }, borderWidth: { 1: 1 }, @@ -48,7 +48,7 @@ const { }, }; - const prItem: GitHubSearchItem = { + const hoistedPrItem: GitHubSearchItem = { kind: "pr", number: 202, title: "Refactor picker", @@ -60,7 +60,7 @@ const { headRefName: "feature/picker", }; - const prItemB: GitHubSearchItem = { + const hoistedPrItemB: GitHubSearchItem = { kind: "pr", number: 303, title: "Polish composer chip", @@ -72,7 +72,7 @@ const { headRefName: "feature/composer-chip", }; - const issueItem: GitHubSearchItem = { + const hoistedIssueItem: GitHubSearchItem = { kind: "issue", number: 44, title: "Keep manual attachment", @@ -82,50 +82,50 @@ const { labels: [], }; - const initialAttachments: ComposerAttachment[] = []; - const initialDraftState = { text: "" }; + const hoistedInitialAttachments: ComposerAttachment[] = []; + const hoistedInitialDraftState = { text: "" }; - const createdWorkspace = { + const hoistedCreatedWorkspace = { id: "workspace-1", workspaceDirectory: "/repo/.paseo/worktrees/workspace-1", }; - const createdAgent = { + const hoistedCreatedAgent = { id: "agent-1", - cwd: createdWorkspace.workspaceDirectory, + cwd: hoistedCreatedWorkspace.workspaceDirectory, }; - const mockClient = { + const hoistedMockClient = { isConnected: true, getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })), getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })), searchGitHub: vi.fn(async () => ({ - items: [prItem, prItemB], + items: [hoistedPrItem, hoistedPrItemB], githubFeaturesEnabled: true, error: null, })), createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({ - workspace: createdWorkspace, + workspace: hoistedCreatedWorkspace, error: null, })), - createAgent: vi.fn(async () => createdAgent), + createAgent: vi.fn(async () => hoistedCreatedAgent), }; return { - theme, - mockClient, + theme: hoistedTheme, + mockClient: hoistedMockClient, mergeWorkspacesMock: vi.fn(), navigateMock: vi.fn(), saveDraftInputMock: vi.fn(), clearDraftInputMock: vi.fn(), queueDraftSubmissionMock: vi.fn(), - createdAgent, - createdWorkspace, - prItem, - prItemB, - issueItem, - initialAttachments, - initialDraftState, + createdAgent: hoistedCreatedAgent, + createdWorkspace: hoistedCreatedWorkspace, + prItem: hoistedPrItem, + prItemB: hoistedPrItemB, + issueItem: hoistedIssueItem, + initialAttachments: hoistedInitialAttachments, + initialDraftState: hoistedInitialDraftState, }; }); @@ -163,6 +163,13 @@ vi.mock("lucide-react-native", () => { }; }); +function flattenReanimatedStyle(style: unknown) { + if (!Array.isArray(style)) { + return style; + } + return Object.assign({}, ...style.filter(Boolean)); +} + vi.mock("react-native-reanimated", () => ({ default: { View: ({ @@ -170,9 +177,7 @@ vi.mock("react-native-reanimated", () => ({ style, ...props }: React.HTMLAttributes & { testID?: string; style?: unknown }) => { - const flattenedStyle = Array.isArray(style) - ? Object.assign({}, ...style.filter(Boolean)) - : style; + const flattenedStyle = flattenReanimatedStyle(style); return
; }, }, @@ -274,30 +279,77 @@ vi.mock("@/hooks/use-keyboard-shift-style", () => ({ useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }), })); -vi.mock("@/components/composer", () => ({ - Composer: ({ - onSubmitMessage, - submitBehavior, - submitIcon, - isSubmitLoading, - value, - onChangeText, - attachments, - onChangeAttachments, - }: { - onSubmitMessage: (payload: { - text: string; - attachments: ComposerAttachment[]; - cwd: string; - }) => void; - submitBehavior?: "clear" | "preserve-and-lock"; - submitIcon?: "arrow" | "return"; - isSubmitLoading?: boolean; - value: string; - onChangeText: (text: string) => void; +interface ComposerMockProps { + onSubmitMessage: (payload: { + text: string; attachments: ComposerAttachment[]; - onChangeAttachments: (attachments: ComposerAttachment[]) => void; - }) => ( + cwd: string; + }) => void; + submitBehavior?: "clear" | "preserve-and-lock"; + submitIcon?: "arrow" | "return"; + isSubmitLoading?: boolean; + value: string; + onChangeText: (text: string) => void; + attachments: ComposerAttachment[]; + onChangeAttachments: (attachments: ComposerAttachment[]) => void; +} + +interface AttachmentPillProps { + attachment: Extract; + attachments: ComposerAttachment[]; + isDisabled: boolean; + onChangeAttachments: (attachments: ComposerAttachment[]) => void; +} + +function ComposerMockAttachmentPill({ + attachment, + attachments, + isDisabled, + onChangeAttachments, +}: AttachmentPillProps) { + const handleRemove = React.useCallback(() => { + onChangeAttachments( + attachments.filter( + (candidate) => + candidate.kind !== attachment.kind || candidate.item.number !== attachment.item.number, + ), + ); + }, [attachment, attachments, onChangeAttachments]); + return ( +
+ #{attachment.item.number} {attachment.item.title} + +
+ ); +} + +function ComposerMock({ + onSubmitMessage, + submitBehavior, + submitIcon, + isSubmitLoading, + value, + onChangeText, + attachments, + onChangeAttachments, +}: ComposerMockProps) { + const isDisabled = submitBehavior === "preserve-and-lock" && Boolean(isSubmitLoading); + const handleTextareaChange = React.useCallback( + (event: React.ChangeEvent) => onChangeText(event.currentTarget.value), + [onChangeText], + ); + const handleSubmit = React.useCallback( + () => onSubmitMessage({ text: value, attachments, cwd: "/repo" }), + [onSubmitMessage, value, attachments], + ); + return (
({ >