Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
-21
View File
@@ -1,21 +0,0 @@
# Dependencies
node_modules
# Build outputs
dist
.next
.expo
build
*.tsbuildinfo
# Coverage
coverage
# Lock files
*.lock
package-lock.json
# Generated
android
ios
.turbo
-7
View File
@@ -1,7 +0,0 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!*.lock"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"linter": {
"enabled": false
}
}
+200 -37
View File
@@ -24,10 +24,10 @@
"@modelcontextprotocol/sdk": "^1.27.1" "@modelcontextprotocol/sdk": "^1.27.1"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.8",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"knip": "^5.82.1", "knip": "^5.82.1",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react": "19.1.4", "react": "19.1.4",
"react-dom": "19.1.4", "react-dom": "19.1.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
@@ -2559,11 +2559,10 @@
} }
}, },
"node_modules/@biomejs/biome": { "node_modules/@biomejs/biome": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz",
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", "integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"bin": { "bin": {
"biome": "bin/biome" "biome": "bin/biome"
@@ -2576,20 +2575,20 @@
"url": "https://opencollective.com/biome" "url": "https://opencollective.com/biome"
}, },
"optionalDependencies": { "optionalDependencies": {
"@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-arm64": "2.4.8",
"@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-darwin-x64": "2.4.8",
"@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64": "2.4.8",
"@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-arm64-musl": "2.4.8",
"@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64": "2.4.8",
"@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-linux-x64-musl": "2.4.8",
"@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-arm64": "2.4.8",
"@biomejs/cli-win32-x64": "1.9.4" "@biomejs/cli-win32-x64": "2.4.8"
} }
}, },
"node_modules/@biomejs/cli-darwin-arm64": { "node_modules/@biomejs/cli-darwin-arm64": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz",
"integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", "integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2604,9 +2603,9 @@
} }
}, },
"node_modules/@biomejs/cli-darwin-x64": { "node_modules/@biomejs/cli-darwin-x64": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz",
"integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", "integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2621,9 +2620,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64": { "node_modules/@biomejs/cli-linux-arm64": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz",
"integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", "integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2638,9 +2637,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64-musl": { "node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz",
"integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", "integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2655,9 +2654,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64": { "node_modules/@biomejs/cli-linux-x64": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz",
"integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", "integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2672,9 +2671,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64-musl": { "node_modules/@biomejs/cli-linux-x64-musl": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz",
"integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", "integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2689,9 +2688,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-arm64": { "node_modules/@biomejs/cli-win32-arm64": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz",
"integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", "integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2706,9 +2705,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-x64": { "node_modules/@biomejs/cli-win32-x64": {
"version": "1.9.4", "version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz",
"integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", "integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -34907,6 +34906,170 @@
"react-native": "*" "react-native": "*"
} }
}, },
"packages/expo-two-way-audio/node_modules/@biomejs/biome": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
"dev": true,
"hasInstallScript": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "1.9.4",
"@biomejs/cli-darwin-x64": "1.9.4",
"@biomejs/cli-linux-arm64": "1.9.4",
"@biomejs/cli-linux-arm64-musl": "1.9.4",
"@biomejs/cli-linux-x64": "1.9.4",
"@biomejs/cli-linux-x64-musl": "1.9.4",
"@biomejs/cli-win32-arm64": "1.9.4",
"@biomejs/cli-win32-x64": "1.9.4"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-darwin-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
"integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-darwin-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
"integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
"integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
"integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
"integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-x64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
"integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-win32-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
"integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-win32-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
"integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@types/react": { "packages/expo-two-way-audio/node_modules/@types/react": {
"version": "18.3.28", "version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+3 -3
View File
@@ -23,8 +23,8 @@
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli", "typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"format": "prettier --write .", "format": "biome format --write .",
"format:check": "prettier --check .", "format:check": "biome format .",
"start": "npm run start --workspace=@getpaseo/server", "start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app", "android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app", "android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -57,10 +57,10 @@
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push" "release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.8",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"knip": "^5.82.1", "knip": "^5.82.1",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react": "19.1.4", "react": "19.1.4",
"react-dom": "19.1.4", "react-dom": "19.1.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
+2 -5
View File
@@ -65,8 +65,7 @@ export default {
ios: { ios: {
supportsTablet: true, supportsTablet: true,
infoPlist: { infoPlist: {
NSMicrophoneUsageDescription: NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
"This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false, ITSAppUsesNonExemptEncryption: false,
}, },
bundleIdentifier: variant.packageId, bundleIdentifier: variant.packageId,
@@ -92,9 +91,7 @@ export default {
"android.permission.CAMERA", "android.permission.CAMERA",
], ],
package: variant.packageId, package: variant.packageId,
...(variant.googleServicesFile ...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
? { googleServicesFile: variant.googleServicesFile }
: {}),
}, },
web: { web: {
output: "single", output: "single",
+23 -26
View File
@@ -1,15 +1,12 @@
import { test as base, expect, type Page } from '@playwright/test'; import { test as base, expect, type Page } from "@playwright/test";
import { import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
buildCreateAgentPreferences,
buildSeededHost,
} from './helpers/daemon-registry';
// Extend base test to provide dynamic baseURL from global-setup // Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({ const test = base.extend({
baseURL: async ({}, use) => { baseURL: async ({}, use) => {
const metroPort = process.env.E2E_METRO_PORT; const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) { if (!metroPort) {
throw new Error('E2E_METRO_PORT not set - globalSetup must run first'); throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
} }
await use(`http://localhost:${metroPort}`); await use(`http://localhost:${metroPort}`);
}, },
@@ -22,19 +19,19 @@ test.beforeEach(async ({ page }) => {
const metroPort = process.env.E2E_METRO_PORT; const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) { if (!daemonPort) {
throw new Error( throw new Error(
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' + "E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.' "Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
); );
} }
if (daemonPort === '6767') { if (daemonPort === "6767") {
throw new Error( throw new Error(
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' + "E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
'Fix Playwright globalSetup to start an isolated test daemon and export its port.' "Fix Playwright globalSetup to start an isolated test daemon and export its port.",
); );
} }
if (!metroPort) { if (!metroPort) {
throw new Error( throw new Error(
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.' "E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
); );
} }
@@ -42,17 +39,17 @@ test.beforeEach(async ({ page }) => {
// This blocks both HTTP and WS attempts to :6767 (before any navigation). // This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort()); await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => { await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' }); await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
}); });
const entries: string[] = []; const entries: string[] = [];
consoleEntries.set(page, entries); consoleEntries.set(page, entries);
page.on('console', (message) => { page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`); entries.push(`[console:${message.type()}] ${message.text()}`);
}); });
page.on('pageerror', (error) => { page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`); entries.push(`[pageerror] ${error.message}`);
}); });
@@ -60,7 +57,7 @@ test.beforeEach(async ({ page }) => {
const seedNonce = Math.random().toString(36).slice(2); const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID; const serverId = process.env.E2E_SERVER_ID;
if (!serverId) { if (!serverId) {
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.'); throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
} }
const testDaemon = buildSeededHost({ const testDaemon = buildSeededHost({
serverId, serverId,
@@ -74,7 +71,7 @@ test.beforeEach(async ({ page }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally // `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 // override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload. // setting this flag before the reload.
const disableOnceKey = '@paseo:e2e-disable-default-seed-once'; const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey); const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) { if (disableValue) {
localStorage.removeItem(disableOnceKey); localStorage.removeItem(disableOnceKey);
@@ -83,15 +80,15 @@ test.beforeEach(async ({ page }) => {
} }
} }
localStorage.setItem('@paseo:e2e', '1'); localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce); localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
// Hard-reset anything that could point to a developer's real daemon. // Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings'); localStorage.removeItem("@paseo:settings");
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
}, },
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce } { daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
); );
}); });
@@ -105,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
return; return;
} }
await testInfo.attach('browser-console', { await testInfo.attach("browser-console", {
body: entries.join('\n'), body: entries.join("\n"),
contentType: 'text/plain', contentType: "text/plain",
}); });
}); });
+128 -105
View File
@@ -1,11 +1,11 @@
import { spawn, type ChildProcess, execSync } from 'node:child_process'; import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from 'node:fs'; import { existsSync } from "node:fs";
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from 'node:os'; import { tmpdir } from "node:os";
import path from 'node:path'; import path from "node:path";
import net from 'node:net'; import net from "node:net";
import { Buffer } from 'node:buffer'; import { Buffer } from "node:buffer";
import dotenv from 'dotenv'; import dotenv from "dotenv";
type WaitForServerOptions = { type WaitForServerOptions = {
host?: string; host?: string;
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
async function getAvailablePort(): Promise<number> { async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = net.createServer(); const server = net.createServer();
server.once('error', reject); server.once("error", reject);
server.listen(0, () => { server.listen(0, () => {
const address = server.address(); const address = server.address();
if (!address || typeof address === 'string') { if (!address || typeof address === "string") {
server.close(() => reject(new Error('Failed to acquire port'))); server.close(() => reject(new Error("Failed to acquire port")));
return; return;
} }
server.close(() => resolve(address.port)); server.close(() => resolve(address.port));
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
} }
}, },
dump() { dump() {
return lines.join('\n'); return lines.join("\n");
}, },
}; };
} }
function formatRecentOutput(getRecentOutput?: () => string): string { function formatRecentOutput(getRecentOutput?: () => string): string {
if (!getRecentOutput) { if (!getRecentOutput) {
return ''; return "";
} }
const output = getRecentOutput().trim(); const output = getRecentOutput().trim();
if (!output) { if (!output) {
return ''; return "";
} }
return `\nRecent output:\n${output}`; return `\nRecent output:\n${output}`;
} }
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
} }
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> { async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
const { const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
host = '127.0.0.1',
timeoutMs = 15000,
label,
childProcess,
getRecentOutput,
} = options;
const start = Date.now(); const start = Date.now();
let lastConnectionError: unknown = null; let lastConnectionError: unknown = null;
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
if (childProcess && childProcess.exitCode !== null) { if (childProcess && childProcess.exitCode !== null) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : ''; const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
throw new Error( throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}` `${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
); );
} }
@@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
socket.destroy(); socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`)); reject(new Error(`Connection timed out to ${host}:${port}`));
}); });
socket.on('error', reject); socket.on("error", reject);
}); });
return; return;
} catch (error) { } catch (error) {
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
} }
const reason = const reason =
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : ''; lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
throw new Error( throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}` `${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
); );
} }
@@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) { if (child.exitCode !== null || child.signalCode !== null) {
return; return;
} }
child.kill('SIGTERM'); child.kill("SIGTERM");
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) { if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL'); child.kill("SIGKILL");
} }
resolve(); resolve();
}, 5000); }, 5000);
child.once('exit', () => { child.once("exit", () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(); resolve();
}); });
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
function summarizeOpenAiErrorBody(body: string): string { function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim(); const trimmed = body.trim();
if (!trimmed) { if (!trimmed) {
return 'empty response body'; return "empty response body";
} }
if (trimmed.length <= 240) { if (trimmed.length <= 240) {
return trimmed; return trimmed;
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
} }
try { try {
const response = await fetch('https://api.openai.com/v1/models?limit=1', { const response = await fetch("https://api.openai.com/v1/models?limit=1", {
method: 'GET', method: "GET",
headers: { headers: {
Authorization: `Bearer ${key}`, Authorization: `Bearer ${key}`,
}, },
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
} }
const body = await response.text(); const body = await response.text();
console.warn( console.warn(
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}` `[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
); );
return false; return false;
} catch (error) { } catch (error) {
console.warn( console.warn(
`[e2e] OPENAI_API_KEY probe request failed: ${ `[e2e] OPENAI_API_KEY probe request failed: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}` }`,
); );
return false; return false;
} }
@@ -196,42 +192,42 @@ type OfferPayload = {
}; };
function stripAnsi(input: string): string { function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, ''); return input.replace(/\u001b\[[0-9;]*m/g, "");
} }
function ensureRelayBuildArtifact(repoRoot: string): void { function ensureRelayBuildArtifact(repoRoot: string): void {
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js'); const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
if (existsSync(relayDistEntry)) { if (existsSync(relayDistEntry)) {
return; return;
} }
console.log('[e2e] Building @getpaseo/relay for daemon startup'); console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync('npm run build --workspace=@getpaseo/relay', { execSync("npm run build --workspace=@getpaseo/relay", {
cwd: repoRoot, cwd: repoRoot,
stdio: 'inherit', stdio: "inherit",
}); });
} }
function decodeOfferFromFragmentUrl(url: string): OfferPayload { function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer='; const marker = "#offer=";
const idx = url.indexOf(marker); const idx = url.indexOf(marker);
if (idx === -1) { if (idx === -1) {
throw new Error(`missing ${marker} fragment: ${url}`); throw new Error(`missing ${marker} fragment: ${url}`);
} }
const encoded = url.slice(idx + marker.length); const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, 'base64url').toString('utf8'); const json = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(json) as Partial<OfferPayload>; const offer = JSON.parse(json) as Partial<OfferPayload>;
if (offer.v !== 2) throw new Error('offer.v missing/invalid'); if (offer.v !== 2) throw new Error("offer.v missing/invalid");
if (!offer.serverId) throw new Error('offer.serverId missing'); if (!offer.serverId) throw new Error("offer.serverId missing");
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing'); if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing'); if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
return offer as OfferPayload; return offer as OfferPayload;
} }
export default async function globalSetup() { export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..'); const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot); ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, '.env.test'); const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) { if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath }); dotenv.config({ path: envTestPath });
} }
@@ -239,13 +235,17 @@ export default async function globalSetup() {
const port = await getAvailablePort(); const port = await getAvailablePort();
let relayPort = 0; let relayPort = 0;
const metroPort = await getAvailablePort(); const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-')); paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
let relayLineBuffer = createLineBuffer(); let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer(); const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer();
const cleanup = async () => { const cleanup = async () => {
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]); await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null; daemonProcess = null;
metroProcess = null; metroProcess = null;
relayProcess = null; relayProcess = null;
@@ -256,24 +256,30 @@ export default async function globalSetup() {
}; };
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY); const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech'); const defaultLocalModelsDir = path.join(
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir); process.env.HOME ?? "",
const dictationProvider = openAiUsable ? 'openai' : 'local'; ".paseo",
"models",
"local-speech",
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) { if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
throw new Error( throw new Error(
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' + "OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.' "Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
); );
} }
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null; const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log( console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}` `[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
); );
try { try {
const relayDir = path.resolve(__dirname, '..', '..', 'relay'); const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5; const maxRelayStartupAttempts = 5;
let relayStarted = false; let relayStarted = false;
let lastRelayStartupError: unknown = null; let lastRelayStartupError: unknown = null;
@@ -285,18 +291,21 @@ export default async function globalSetup() {
let relayReadyForSelectedPort = false; let relayReadyForSelectedPort = false;
relayProcess = spawn( relayProcess = spawn(
'npx', "npx",
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)], ["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{ {
cwd: relayDir, cwd: relayDir,
env: { ...process.env }, env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ["ignore", "pipe", "pipe"],
detached: false, detached: false,
} },
); );
relayProcess.stdout?.on('data', (data: Buffer) => { relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim()); const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) { for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`); relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line); const failure = parseRelayStartupFailure(line);
@@ -311,8 +320,11 @@ export default async function globalSetup() {
console.log(`[relay] ${line}`); console.log(`[relay] ${line}`);
} }
}); });
relayProcess.stderr?.on('data', (data: Buffer) => { relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim()); const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) { for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`); relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line); const failure = parseRelayStartupFailure(line);
@@ -330,7 +342,7 @@ export default async function globalSetup() {
try { try {
await waitForServer(relayPort, { await waitForServer(relayPort, {
label: 'Relay dev server', label: "Relay dev server",
timeoutMs: 30000, timeoutMs: 30000,
childProcess: relayProcess, childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump, getRecentOutput: relayLineBuffer.dump,
@@ -353,15 +365,15 @@ export default async function globalSetup() {
if (!relayReadyForSelectedPort) { if (!relayReadyForSelectedPort) {
throw new Error( throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput( `Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump relayLineBuffer.dump,
)}` )}`,
); );
} }
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) { if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error( throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput( `Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump relayLineBuffer.dump,
)}` )}`,
); );
} }
@@ -380,40 +392,46 @@ export default async function globalSetup() {
? lastRelayStartupError.message ? lastRelayStartupError.message
: String(lastRelayStartupError); : String(lastRelayStartupError);
throw new Error( throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}` `Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
); );
} }
// Start Metro bundler on dynamic port // Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, '..'); const appDir = path.resolve(__dirname, "..");
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], { metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir, cwd: appDir,
env: { env: {
...process.env, ...process.env,
BROWSER: 'none', // Don't auto-open browser BROWSER: "none", // Don't auto-open browser
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ["ignore", "pipe", "pipe"],
detached: false, detached: false,
}); });
metroProcess.stdout?.on('data', (data: Buffer) => { metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim()); const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) { for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`); metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`); console.log(`[metro] ${line}`);
} }
}); });
metroProcess.stderr?.on('data', (data: Buffer) => { metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim()); const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) { for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`); metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`); console.error(`[metro] ${line}`);
} }
}); });
const serverDir = path.resolve(__dirname, '../../..', 'packages/server'); const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync('which tsx').toString().trim(); const tsxBin = execSync("which tsx").toString().trim();
let offerPayload: OfferPayload | null = null; let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null; let offerResolve: (() => void) | null = null;
@@ -421,33 +439,33 @@ export default async function globalSetup() {
offerResolve = resolve; offerResolve = resolve;
}); });
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], { daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir, cwd: serverDir,
env: { env: {
...process.env, ...process.env,
PASEO_HOME: paseoHome, PASEO_HOME: paseoHome,
PASEO_SERVER_ID: 'srv_e2e_test_daemon', PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays. // Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: '1', PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: '1', PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider, PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: 'openai', PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: 'openai', PASEO_VOICE_TTS_PROVIDER: "openai",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}), ...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: 'development', NODE_ENV: "development",
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ["ignore", "pipe", "pipe"],
detached: false, detached: false,
}); });
let stdoutBuffer = ''; let stdoutBuffer = "";
daemonProcess.stdout?.on('data', (data: Buffer) => { daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString('utf8'); stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split('\n'); const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? ''; stdoutBuffer = lines.pop() ?? "";
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
@@ -456,13 +474,13 @@ export default async function globalSetup() {
const clean = stripAnsi(trimmed); const clean = stripAnsi(trimmed);
try { try {
const obj = JSON.parse(clean) as { msg?: string; url?: string }; const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') { if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
offerPayload = decodeOfferFromFragmentUrl(obj.url); offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.(); offerResolve?.();
} }
} catch { } catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/); const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes('pairing_offer')) { if (match && clean.includes("pairing_offer")) {
try { try {
offerPayload = decodeOfferFromFragmentUrl(match[0]); offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.(); offerResolve?.();
@@ -476,8 +494,11 @@ export default async function globalSetup() {
} }
}); });
daemonProcess.stderr?.on('data', (data: Buffer) => { daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim()); const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) { for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`); daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`); console.error(`[daemon] ${line}`);
@@ -487,12 +508,12 @@ export default async function globalSetup() {
// Wait for both daemon and Metro to be ready // Wait for both daemon and Metro to be ready
await Promise.all([ await Promise.all([
waitForServer(port, { waitForServer(port, {
label: 'Paseo daemon', label: "Paseo daemon",
childProcess: daemonProcess, childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump, getRecentOutput: daemonLineBuffer.dump,
}), }),
waitForServer(metroPort, { waitForServer(metroPort, {
label: 'Metro web server', label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess, childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump, getRecentOutput: metroLineBuffer.dump,
@@ -503,11 +524,11 @@ export default async function globalSetup() {
await Promise.race([ await Promise.race([
offerPromise, offerPromise,
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000) setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
), ),
]); ]);
if (!offerPayload) { if (!offerPayload) {
throw new Error('pairing_offer was not parsed from daemon logs'); throw new Error("pairing_offer was not parsed from daemon logs");
} }
const offer = offerPayload as OfferPayload; const offer = offerPayload as OfferPayload;
@@ -516,11 +537,13 @@ export default async function globalSetup() {
process.env.E2E_SERVER_ID = offer.serverId; process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64; process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort); process.env.E2E_METRO_PORT = String(metroPort);
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`); console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);
return async () => { return async () => {
await cleanup(); await cleanup();
console.log('[e2e] Test daemon stopped'); console.log("[e2e] Test daemon stopped");
}; };
} catch (error) { } catch (error) {
await cleanup(); await cleanup();
+19 -25
View File
@@ -34,10 +34,7 @@ export type DaemonClientInstance = {
initialPrompt: string; initialPrompt: string;
}): Promise<{ id: string }>; }): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>; sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish( waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
agentId: string,
timeout?: number
): Promise<{ status: string }>;
}; };
function getDaemonWsUrl(): string { function getDaemonWsUrl(): string {
@@ -86,14 +83,16 @@ export function createReplyTurn(label: string): {
}; };
} }
async function loadDaemonClientConstructor(): Promise<new (config: { async function loadDaemonClientConstructor(): Promise<
url: string; new (config: {
clientId: string; url: string;
clientType: "cli"; clientId: string;
}) => DaemonClientInstance> { clientType: "cli";
}) => DaemonClientInstance
> {
const repoRoot = path.resolve(process.cwd(), "../.."); const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL( const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js") path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href; ).href;
const mod = (await import(moduleUrl)) as { const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: { DaemonClient: new (config: {
@@ -138,7 +137,7 @@ export async function seedBottomAnchorAgent(input: {
const initialFinish = await input.client.waitForFinish(created.id, 120000); const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") { if (initialFinish.status !== "idle") {
throw new Error( throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.` `Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
); );
} }
@@ -150,7 +149,7 @@ export async function seedBottomAnchorAgent(input: {
const finish = await input.client.waitForFinish(created.id, 120000); const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") { if (finish.status !== "idle") {
throw new Error( throw new Error(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.` `Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
); );
} }
} }
@@ -183,18 +182,13 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
const scrollElement = const scrollElement =
candidates.sort( candidates.sort(
(left, right) => (left, right) =>
right.scrollHeight - right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
right.clientHeight -
(left.scrollHeight - left.clientHeight)
)[0] ?? (root as HTMLElement); )[0] ?? (root as HTMLElement);
const offsetY = Math.max(0, scrollElement.scrollTop); const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight); const contentHeight = Math.max(0, scrollElement.scrollHeight);
const viewportHeight = Math.max(0, scrollElement.clientHeight); const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max( const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
0,
contentHeight - (offsetY + viewportHeight)
);
return { return {
offsetY, offsetY,
@@ -218,7 +212,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise<vo
deltaY: -step, deltaY: -step,
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}) }),
); );
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step); scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true })); scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
@@ -267,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
export async function waitForContentGrowth( export async function waitForContentGrowth(
page: Page, page: Page,
previousContentHeight: number previousContentHeight: number,
): Promise<ScrollMetrics> { ): Promise<ScrollMetrics> {
await expect await expect
.poll(async () => { .poll(async () => {
@@ -280,8 +274,8 @@ export async function waitForContentGrowth(
export async function getChatContainerKey(page: Page): Promise<string | null> { export async function getChatContainerKey(page: Page): Promise<string | null> {
return getVisibleChatScroll(page).evaluate((element) => { return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id; const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-"; const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null; return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
}); });
} }
+185 -148
View File
@@ -1,20 +1,19 @@
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from "@playwright/test";
import { import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
buildCreateAgentPreferences,
buildSeededHost,
} from './daemon-registry';
function escapeRegex(value: string): string { function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
function getE2EDaemonPort(): string { function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT; const port = process.env.E2E_DAEMON_PORT;
if (!port) { if (!port) {
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).'); throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
} }
if (port === '6767') { if (port === "6767") {
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.'); throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
} }
return port; return port;
} }
@@ -24,25 +23,38 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`; const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID; const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) { if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).'); throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
} }
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => { const needsReset = await page.evaluate(
const raw = localStorage.getItem('@paseo:daemon-registry'); ({ expectedEndpoint, expectedServerId }) => {
if (!raw) return true; const raw = localStorage.getItem("@paseo:daemon-registry");
try { if (!raw) return true;
const parsed = JSON.parse(raw); try {
if (!Array.isArray(parsed) || parsed.length !== 1) return true; const parsed = JSON.parse(raw);
const entry = parsed[0] as any; if (!Array.isArray(parsed) || parsed.length !== 1) return true;
if (entry?.serverId !== expectedServerId) return true; const entry = parsed[0] as any;
const connections = entry?.connections; if (entry?.serverId !== expectedServerId) return true;
if (!Array.isArray(connections)) return true; const connections = entry?.connections;
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true; if (!Array.isArray(connections)) return true;
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint); if (
} catch { connections.some(
return true; (c: any) =>
} c?.type === "directTcp" &&
}, { expectedEndpoint, expectedServerId }); typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
)
)
return true;
return !connections.some(
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
);
} catch {
return true;
}
},
{ expectedEndpoint, expectedServerId },
);
if (!needsReset) { if (!needsReset) {
return; return;
@@ -57,12 +69,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const preferences = buildCreateAgentPreferences(expectedServerId); const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate( await page.evaluate(
({ daemon, preferences }) => { ({ daemon, preferences }) => {
localStorage.setItem('@paseo:e2e', '1'); localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings'); localStorage.removeItem("@paseo:settings");
}, },
{ daemon, preferences } { daemon, preferences },
); );
await page.reload(); await page.reload();
@@ -73,81 +85,98 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`; const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID; const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) { if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).'); throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
} }
const snapshot = await page.evaluate(() => { const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry'); const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw }; return { registryRaw, prefsRaw };
}); });
if (!snapshot.registryRaw) { if (!snapshot.registryRaw) {
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.'); throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
} }
let registry: any; let registry: any;
try { try {
registry = JSON.parse(snapshot.registryRaw); registry = JSON.parse(snapshot.registryRaw);
} catch { } catch {
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.'); throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
} }
if (!Array.isArray(registry) || registry.length !== 1) { if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error( throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).` `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
); );
} }
const daemon = registry[0]; const daemon = registry[0];
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 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)}).`); throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
} }
if (daemon.serverId !== expectedServerId) { if (daemon.serverId !== expectedServerId) {
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`); throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
} }
const connections: unknown = daemon?.connections; const connections: unknown = daemon?.connections;
if ( if (
!Array.isArray(connections) || !Array.isArray(connections) ||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint) !connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
) { ) {
throw new Error( throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).` `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))) { if (
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`); 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)}).`,
);
} }
if (!snapshot.prefsRaw) { if (!snapshot.prefsRaw) {
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.'); throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
} }
try { try {
const prefs = JSON.parse(snapshot.prefsRaw) as any; const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) { if (prefs?.serverId !== daemon.serverId) {
throw new Error( throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).` `E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error) throw error; if (error instanceof Error) throw error;
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.'); throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
} }
} }
export const gotoAppShell = async (page: Page) => { export const gotoAppShell = async (page: Page) => {
await page.goto('/'); await page.goto("/");
await ensureE2EStorageSeeded(page); await ensureE2EStorageSeeded(page);
}; };
export const gotoHome = async (page: Page) => { export const gotoHome = async (page: Page) => {
await gotoAppShell(page); await gotoAppShell(page);
const composer = page.getByRole('textbox', { name: 'Message agent...' }); const composer = page.getByRole("textbox", { name: "Message agent..." });
if (!(await composer.first().isVisible().catch(() => false))) { if (
const addProjectCta = page.getByText('Add a project', { exact: true }).first(); !(await composer
const addProjectSidebar = page.getByText('Add project', { exact: true }).first(); .first()
const newAgentButton = page.getByText('New agent', { exact: true }).first(); .isVisible()
.catch(() => false))
) {
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
const newAgentButton = page.getByText("New agent", { exact: true }).first();
await expect await expect
.poll( .poll(
@@ -155,7 +184,7 @@ export const gotoHome = async (page: Page) => {
(await addProjectCta.isVisible().catch(() => false)) || (await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) || (await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)), (await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 } { timeout: 10000 },
) )
.toBe(true); .toBe(true);
@@ -173,7 +202,7 @@ export const gotoHome = async (page: Page) => {
export const openSettings = async (page: Page) => { export const openSettings = async (page: Page) => {
const serverId = process.env.E2E_SERVER_ID; const serverId = process.env.E2E_SERVER_ID;
if (!serverId) { if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).'); throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
} }
// Navigate through the real app control so route changes stay aligned with UI behavior. // Navigate through the real app control so route changes stay aligned with UI behavior.
@@ -189,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first(); .first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 }); await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first(); const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first(); const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker'); const worktreePicker = page.getByTestId("worktree-attach-picker");
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first(); const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
const closeBottomSheet = async () => { const closeBottomSheet = async () => {
const bottomSheetBackdrop = page const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
.getByRole('button', { name: 'Bottom sheet backdrop' }) const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
.first();
const bottomSheetHandle = page
.getByRole('slider', { name: 'Bottom sheet handle' })
.first();
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
if (!(await bottomSheetBackdrop.isVisible())) { if (!(await bottomSheetBackdrop.isVisible())) {
return; return;
} }
await bottomSheetBackdrop.click({ force: true }); await bottomSheetBackdrop.click({ force: true });
await page.keyboard.press('Escape').catch(() => undefined); await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(200); await page.waitForTimeout(200);
} }
if (await bottomSheetBackdrop.isVisible()) { if (await bottomSheetBackdrop.isVisible()) {
@@ -225,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) { if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
return; return;
} }
const attachToggle = page.getByTestId('worktree-attach-toggle'); const attachToggle = page.getByTestId("worktree-attach-toggle");
if (await attachToggle.isVisible()) { if (await attachToggle.isVisible()) {
await attachToggle.click({ force: true }); await attachToggle.click({ force: true });
await page.waitForTimeout(200); await page.waitForTimeout(200);
@@ -246,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeBottomSheet(); await closeBottomSheet();
await workingDirectorySelect.click({ force: true }); await workingDirectorySelect.click({ force: true });
} }
await expect await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
.poll(async () => pickerInputVisible(), { timeout: 10000 })
.toBe(true);
} }
const trimmedDirectory = directory.replace(/\/+$/, ''); const trimmedDirectory = directory.replace(/\/+$/, "");
const activeInput = const activeInput = (await directorySearchInput.isVisible().catch(() => false))
(await directorySearchInput.isVisible().catch(() => false)) ? directorySearchInput
? directorySearchInput : legacyInput;
: legacyInput;
await activeInput.fill(trimmedDirectory); await activeInput.fill(trimmedDirectory);
if (activeInput === directorySearchInput) { if (activeInput === directorySearchInput) {
// Combobox custom rows can be either plain path labels or prefixed labels. // Combobox custom rows can be either plain path labels or prefixed labels.
const plainOption = page const plainOption = page
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i')) .getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
.first(); .first();
const prefixedUseOption = page const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i')) .getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
.first(); .first();
if (await plainOption.isVisible().catch(() => false)) { if (await plainOption.isVisible().catch(() => false)) {
@@ -274,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await prefixedUseOption.click({ force: true }); await prefixedUseOption.click({ force: true });
} else { } else {
// Fallback: accept highlighted option (directory suggestion). // Fallback: accept highlighted option (directory suggestion).
await activeInput.press('Enter'); await activeInput.press("Enter");
} }
} else { } else {
// Legacy path picker fallback. // Legacy path picker fallback.
await activeInput.press('Enter'); await activeInput.press("Enter");
} }
// Wait for picker to close. // Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 }); await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]); const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith('/var/')) { if (trimmedDirectory.startsWith("/var/")) {
directoryCandidates.add(`/private${trimmedDirectory}`); directoryCandidates.add(`/private${trimmedDirectory}`);
} }
if (trimmedDirectory.startsWith('/private/var/')) { if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, '')); directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
} }
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory; const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
await expect.poll(async () => { await expect
const text = await workingDirectorySelect.innerText().catch(() => ''); .poll(
if (text.includes(basename)) return true; async () => {
for (const candidate of directoryCandidates) { const text = await workingDirectorySelect.innerText().catch(() => "");
if (text.includes(candidate)) return true; if (text.includes(basename)) return true;
} for (const candidate of directoryCandidates) {
return false; if (text.includes(candidate)) return true;
}, { timeout: 30000 }).toBe(true); }
return false;
},
{ timeout: 30000 },
)
.toBe(true);
}; };
export const ensureHostSelected = async (page: Page) => { export const ensureHostSelected = async (page: Page) => {
@@ -318,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
} }
const fix = await page.evaluate(() => { const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry'); const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const; if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as any[]; const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any; const prefs = JSON.parse(prefsRaw) as any;
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const; if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId; const serverId = registry[0]?.serverId;
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const; if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
prefs.serverId = serverId; prefs.serverId = serverId;
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs)); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload. // Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const; return { ok: true } as const;
}); });
@@ -342,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
await assertE2EUsesSeededTestDaemon(page); await assertE2EUsesSeededTestDaemon(page);
} }
const input = page.getByRole('textbox', { name: 'Message agent...' }); const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible(); await expect(input).toBeVisible();
if (await input.isEditable()) { if (await input.isEditable()) {
return; return;
} }
const selectHostLabel = page.getByText('Select host', { exact: true }); const selectHostLabel = page.getByText("Select host", { exact: true });
if (await selectHostLabel.isVisible()) { if (await selectHostLabel.isVisible()) {
await selectHostLabel.click(); await selectHostLabel.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous. // E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText('localhost', { exact: true }).first(); const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first(); const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
.first();
if (await localhostOption.isVisible()) { if (await localhostOption.isVisible()) {
await localhostOption.click(); await localhostOption.click();
@@ -369,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
}; };
export const createAgent = async (page: Page, message: string) => { export const createAgent = async (page: Page, message: string) => {
const input = page.getByRole('textbox', { name: 'Message agent...' }); const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable(); await expect(input).toBeEditable();
await preferFastThinkingOption(page); await preferFastThinkingOption(page);
await input.fill(message); await input.fill(message);
await input.press('Enter'); await input.press("Enter");
// The composer may remain on the draft screen briefly while the initial run starts, // The composer may remain on the draft screen briefly while the initial run starts,
// so assert the user-visible result instead of forcing one route shape here. // so assert the user-visible result instead of forcing one route shape here.
@@ -385,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => {
async function preferFastThinkingOption(page: Page): Promise<void> { async function preferFastThinkingOption(page: Page): Promise<void> {
const providerTrigger = page const providerTrigger = page
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible') .locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first(); .first();
if (await providerTrigger.isVisible().catch(() => false)) { if (await providerTrigger.isVisible().catch(() => false)) {
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim(); const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
if (!/codex/i.test(providerText)) { if (!/codex/i.test(providerText)) {
return; return;
} }
} }
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first(); const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
if (!(await thinkingTrigger.isVisible().catch(() => false))) { if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return; return;
} }
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '') const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) { if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
@@ -407,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
} }
await thinkingTrigger.click(); await thinkingTrigger.click();
const menu = page.getByTestId('agent-thinking-menu').first(); const menu = page.getByTestId("agent-thinking-menu").first();
if (!(await menu.isVisible().catch(() => false))) { if (!(await menu.isVisible().catch(() => false))) {
return; return;
} }
const preferredLabels = ['low', 'minimal', 'off', 'medium']; const preferredLabels = ["low", "minimal", "off", "medium"];
let selected = false; let selected = false;
for (const label of preferredLabels) { for (const label of preferredLabels) {
const option = menu const option = menu
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') }) .getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
.first(); .first();
if (await option.isVisible().catch(() => false)) { if (await option.isVisible().catch(() => false)) {
await option.click({ force: true }); await option.click({ force: true });
@@ -426,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
} }
if (!selected) { if (!selected) {
const options = menu.getByRole('button'); const options = menu.getByRole("button");
const count = await options.count(); const count = await options.count();
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const option = options.nth(index); const option = options.nth(index);
const label = ((await option.innerText().catch(() => '')) ?? '').trim(); const label = ((await option.innerText().catch(() => "")) ?? "").trim();
if (!label) { if (!label) {
continue; continue;
} }
@@ -444,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
} }
if (!selected) { if (!selected) {
await page.keyboard.press('Escape').catch(() => undefined); await page.keyboard.press("Escape").catch(() => undefined);
return; return;
} }
@@ -462,15 +495,17 @@ export interface AgentConfig {
export const selectProvider = async (page: Page, provider: string) => { export const selectProvider = async (page: Page, provider: string) => {
const normalizedProvider = provider.trim(); const normalizedProvider = provider.trim();
if (!normalizedProvider) { if (!normalizedProvider) {
throw new Error('Provider must be a non-empty string.'); throw new Error("Provider must be a non-empty string.");
} }
const providerTrigger = page const providerTrigger = page
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible') .locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first(); .first();
if ( if (
await providerTrigger await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i')) .getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
.first() .first()
.isVisible() .isVisible()
.catch(() => false) .catch(() => false)
@@ -481,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
if (await providerTrigger.isVisible().catch(() => false)) { if (await providerTrigger.isVisible().catch(() => false)) {
await providerTrigger.click(); await providerTrigger.click();
} else { } else {
const providerLabel = page.getByText('PROVIDER', { exact: true }).first(); const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
await expect(providerLabel).toBeVisible(); await expect(providerLabel).toBeVisible();
await providerLabel.click(); await providerLabel.click();
} }
const dialog = page.getByRole('dialog').last(); const dialog = page.getByRole("dialog").last();
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first(); const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
if (await searchInput.isVisible().catch(() => false)) { if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(normalizedProvider); await searchInput.fill(normalizedProvider);
} }
const option = dialog const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first();
await expect(option).toBeVisible(); await expect(option).toBeVisible();
await option.click(); await option.click();
}; };
@@ -502,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
export const selectModel = async (page: Page, model: string) => { export const selectModel = async (page: Page, model: string) => {
const normalizedModel = model.trim(); const normalizedModel = model.trim();
if (!normalizedModel) { if (!normalizedModel) {
throw new Error('Model must be a non-empty string.'); throw new Error("Model must be a non-empty string.");
} }
const modelTrigger = page const modelTrigger = page
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible') .locator(
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
)
.first(); .first();
if ( if (
await modelTrigger await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i')) .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first() .first()
.isVisible() .isVisible()
.catch(() => false) .catch(() => false)
@@ -521,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
if (await modelTrigger.isVisible().catch(() => false)) { if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click(); await modelTrigger.click();
} else { } else {
const modelLabel = page.getByText('MODEL', { exact: true }).first(); const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible(); await expect(modelLabel).toBeVisible();
await modelLabel.click(); await modelLabel.click();
} }
// Wait for the model dropdown to open // Wait for the model dropdown to open
const searchInput = page.getByRole('textbox', { name: /search model/i }); const searchInput = page.getByRole("textbox", { name: /search model/i });
await expect(searchInput).toBeVisible({ timeout: 10000 }); await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to search/filter models // Type to search/filter models
await searchInput.fill(normalizedModel); await searchInput.fill(normalizedModel);
const dialog = page.getByRole('dialog'); const dialog = page.getByRole("dialog");
const exactOption = dialog const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i')) .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first(); .first();
const exactVisible = await exactOption.isVisible().catch(() => false); const exactVisible = await exactOption.isVisible().catch(() => false);
if (exactVisible) { if (exactVisible) {
@@ -543,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
} else { } else {
// Modern labels include version suffixes (for example "Haiku 4.5"), so // Modern labels include version suffixes (for example "Haiku 4.5"), so
// select the first filtered result using keyboard confirm. // select the first filtered result using keyboard confirm.
await searchInput.press('Enter'); await searchInput.press("Enter");
} }
// Wait for dropdown to close // Wait for dropdown to close
if (await searchInput.isVisible().catch(() => false)) { if (await searchInput.isVisible().catch(() => false)) {
await page.keyboard.press('Escape').catch(() => undefined); await page.keyboard.press("Escape").catch(() => undefined);
} }
await expect(searchInput).not.toBeVisible({ timeout: 5000 }); await expect(searchInput).not.toBeVisible({ timeout: 5000 });
}; };
export const selectMode = async (page: Page, mode: string) => { export const selectMode = async (page: Page, mode: string) => {
const modeTrigger = page const modeTrigger = page
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible') .locator(
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
)
.first(); .first();
if (await modeTrigger.isVisible().catch(() => false)) { if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click(); await modeTrigger.click();
} else { } else {
const modeLabel = page.getByText('MODE', { exact: true }).first(); const modeLabel = page.getByText("MODE", { exact: true }).first();
await expect(modeLabel).toBeVisible(); await expect(modeLabel).toBeVisible();
await modeLabel.click(); await modeLabel.click();
} }
// Wait for the mode dropdown to open // Wait for the mode dropdown to open
const searchInput = page.getByRole('textbox', { name: /search mode/i }); const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10000 }); await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to filter modes // Type to filter modes
await searchInput.fill(mode); await searchInput.fill(mode);
const dialog = page.getByRole('dialog'); const dialog = page.getByRole("dialog");
const option = dialog const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
.first();
await expect(option).toBeVisible(); await expect(option).toBeVisible();
await option.click({ force: true }); await option.click({ force: true });
@@ -605,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
export const createAgentInRepo = async ( export const createAgentInRepo = async (
page: Page, page: Page,
config: Pick<AgentConfig, 'directory' | 'prompt'> config: Pick<AgentConfig, "directory" | "prompt">,
) => { ) => {
await gotoHome(page); await gotoHome(page);
await ensureHostSelected(page); await ensureHostSelected(page);
@@ -614,25 +649,25 @@ export const createAgentInRepo = async (
}; };
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => { export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId('permission-request-question').first(); const promptText = page.getByTestId("permission-request-question").first();
await expect(promptText).toBeVisible({ timeout }); await expect(promptText).toBeVisible({ timeout });
}; };
export const allowPermission = async (page: Page) => { export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId('permission-request-accept').first(); const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5000 }); await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click(); await acceptButton.click();
}; };
export const denyPermission = async (page: Page) => { export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId('permission-request-deny').first(); const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5000 }); await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click(); await denyButton.click();
}; };
export async function waitForAgentFinishUI(page: Page, timeout = 30000) { export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear // Wait for the stop button to disappear
const stopButton = page.getByRole('button', { name: /stop|cancel/i }); const stopButton = page.getByRole("button", { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state // First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
@@ -649,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i); const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear // Wait for the tool call result to appear
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => { await expect(toolCallResult)
// If no specific message, just wait for the button to disappear .toBeVisible({ timeout: 10000 })
}); .catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear // Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout }); await expect(stopButton).not.toBeVisible({ timeout });
+1 -1
View File
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
export async function sampleWorkspaceTabIds( export async function sampleWorkspaceTabIds(
page: Page, page: Page,
options: { durationMs?: number; intervalMs?: number } = {} options: { durationMs?: number; intervalMs?: number } = {},
): Promise<string[][]> { ): Promise<string[][]> {
const durationMs = options.durationMs ?? 2_500; const durationMs = options.durationMs ?? 2_500;
const intervalMs = options.intervalMs ?? 50; const intervalMs = options.intervalMs ?? 50;
+17 -17
View File
@@ -1,38 +1,38 @@
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from '@/utils/host-routes'; import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from './app'; import { gotoHome } from "./app";
export async function openNewAgentComposer(page: Page): Promise<void> { export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page); await gotoHome(page);
} }
export function workspaceLabelFromPath(value: string): string { export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split('/').filter(Boolean); const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized; return parts[parts.length - 1] ?? normalized;
} }
function escapeRegex(value: string): string { function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
function candidateWorkspaceIds(inputPath: string): string[] { function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, ''); const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]); const candidates = new Set<string>([trimmed]);
if (trimmed.startsWith('/var/')) { if (trimmed.startsWith("/var/")) {
candidates.add(`/private${trimmed}`); candidates.add(`/private${trimmed}`);
} }
if (trimmed.startsWith('/private/var/')) { if (trimmed.startsWith("/private/var/")) {
candidates.add(trimmed.replace(/^\/private/, '')); candidates.add(trimmed.replace(/^\/private/, ""));
} }
return Array.from(candidates); return Array.from(candidates);
} }
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) { function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
const ids = candidateWorkspaceIds(workspacePath).map( const ids = candidateWorkspaceIds(workspacePath).map(
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]` (id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
); );
return page.locator(ids.join(',')).first(); return page.locator(ids.join(",")).first();
} }
export async function switchWorkspaceViaSidebar(input: { export async function switchWorkspaceViaSidebar(input: {
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
export async function expectWorkspaceHeader( export async function expectWorkspaceHeader(
page: Page, page: Page,
input: { title: string; subtitle: string } input: { title: string; subtitle: string },
): Promise<void> { ): Promise<void> {
const titleLocator = page.getByTestId('workspace-header-title'); const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId('workspace-header-subtitle'); const subtitleLocator = page.getByTestId("workspace-header-subtitle");
await expect(titleLocator.first()).toHaveText(input.title, { await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000, timeout: 30_000,
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
} }
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> { export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole('textbox', { name: 'Message agent...' }); const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable({ timeout: 30_000 }); await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(marker); await input.fill(marker);
await input.press('Enter'); await input.press("Enter");
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
} }
+18 -18
View File
@@ -1,7 +1,7 @@
import { execSync } from 'node:child_process'; import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'; import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from 'node:os'; import { tmpdir } from "node:os";
import path from 'node:path'; import path from "node:path";
type TempRepo = { type TempRepo = {
path: string; path: string;
@@ -9,29 +9,29 @@ type TempRepo = {
}; };
export const createTempGitRepo = async ( export const createTempGitRepo = async (
prefix = 'paseo-e2e-', prefix = "paseo-e2e-",
options?: { withRemote?: boolean } options?: { withRemote?: boolean },
): Promise<TempRepo> => { ): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'; const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const repoPath = await mkdtemp(path.join(tempRoot, prefix)); const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false; const withRemote = options?.withRemote ?? false;
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' }); execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' }); execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' }); execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' }); execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n'); await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' }); execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' }); execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) { if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e. // Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, 'remote.git'); const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true }); await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' }); execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' }); execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' }); execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
} }
return { return {
+3 -3
View File
@@ -1,10 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/ // https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config'); const { defineConfig } = require("eslint/config");
const expoConfig = require('eslint-config-expo/flat'); const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([ module.exports = defineConfig([
expoConfig, expoConfig,
{ {
ignores: ['dist/*'], ignores: ["dist/*"],
}, },
]); ]);
+11 -10
View File
@@ -1,12 +1,13 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from "@playwright/test";
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port // E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
// This allows multiple test runs in parallel across different worktrees // This allows multiple test runs in parallel across different worktrees
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`; const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: "./e2e",
globalSetup: './e2e/global-setup.ts', globalSetup: "./e2e/global-setup.ts",
timeout: 60_000, timeout: 60_000,
expect: { expect: {
timeout: 10_000, timeout: 10_000,
@@ -16,17 +17,17 @@ export default defineConfig({
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
reporter: [['list']], reporter: [["list"]],
use: { use: {
baseURL, baseURL,
trace: 'retain-on-failure', trace: "retain-on-failure",
screenshot: 'only-on-failure', screenshot: "only-on-failure",
video: 'retain-on-failure', video: "retain-on-failure",
}, },
projects: [ projects: [
{ {
name: 'Desktop Chrome', name: "Desktop Chrome",
use: { ...devices['Desktop Chrome'] }, use: { ...devices["Desktop Chrome"] },
}, },
], ],
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs // Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
+1 -2
View File
@@ -1,8 +1,7 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
const baseURL = const baseURL =
process.env.E2E_BASE_URL ?? process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
+11 -11
View File
@@ -1,11 +1,11 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from "@playwright/test";
const baseURL = const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}` process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: "./e2e",
globalSetup: './e2e/global-setup.ts', globalSetup: "./e2e/global-setup.ts",
timeout: 60_000, timeout: 60_000,
expect: { expect: {
timeout: 10_000, timeout: 10_000,
@@ -13,17 +13,17 @@ export default defineConfig({
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
reporter: [['list']], reporter: [["list"]],
use: { use: {
baseURL, baseURL,
trace: 'retain-on-failure', trace: "retain-on-failure",
screenshot: 'only-on-failure', screenshot: "only-on-failure",
video: 'retain-on-failure', video: "retain-on-failure",
}, },
projects: [ projects: [
{ {
name: 'Desktop Safari', name: "Desktop Safari",
use: { ...devices['Desktop Safari'] }, use: { ...devices["Desktop Safari"] },
}, },
], ],
}) });
+2 -2
View File
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
userInput === "y" userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.` ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: "" : ""
}` }`,
); );
} catch (error) { } catch (error) {
console.error(`❌ Error during script execution: ${error.message}`); console.error(`❌ Error during script execution: ${error.message}`);
@@ -108,5 +108,5 @@ rl.question(
console.log("❌ Invalid input. Please enter 'Y' or 'N'."); console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close(); rl.close();
} }
} },
); );
+19 -19
View File
@@ -1,36 +1,36 @@
export const WELCOME_ROUTE = '/welcome' export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: { export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null onlineServerId: string | null;
hasTimedOut: boolean hasTimedOut: boolean;
isDesktopStartupRace: boolean isDesktopStartupRace: boolean;
daemonCount: number daemonCount: number;
pathname: string pathname: string;
}): boolean { }): boolean {
if (input.onlineServerId) { if (input.onlineServerId) {
return false return false;
} }
if (input.pathname === WELCOME_ROUTE) { if (input.pathname === WELCOME_ROUTE) {
return false return false;
} }
if (input.hasTimedOut) { if (input.hasTimedOut) {
return false return false;
} }
return input.isDesktopStartupRace || input.daemonCount > 0 return input.isDesktopStartupRace || input.daemonCount > 0;
} }
export function shouldRedirectToWelcome(input: { export function shouldRedirectToWelcome(input: {
onlineServerId: string | null onlineServerId: string | null;
hasTimedOut: boolean hasTimedOut: boolean;
pathname: string pathname: string;
isDesktopStartupRace: boolean isDesktopStartupRace: boolean;
daemonCount: number daemonCount: number;
}): boolean { }): boolean {
if (input.onlineServerId || !input.hasTimedOut) { if (input.onlineServerId || !input.hasTimedOut) {
return false return false;
} }
if (input.pathname !== '/' && input.pathname !== '') { if (input.pathname !== "/" && input.pathname !== "") {
return false return false;
} }
return input.isDesktopStartupRace || input.daemonCount > 0 return input.isDesktopStartupRace || input.daemonCount > 0;
} }
@@ -2,9 +2,7 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { import { buildHostRootRoute } from "@/utils/host-routes";
buildHostRootRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() { export default function HostAgentReadyRoute() {
@@ -43,7 +41,7 @@ export default function HostAgentReadyRoute() {
serverId, serverId,
workspaceId: normalizedCwd, workspaceId: normalizedCwd,
target: { kind: "agent", agentId }, target: { kind: "agent", agentId },
}) as any }) as any,
); );
} }
}, [agentCwd, agentId, router, serverId]); }, [agentCwd, agentId, router, serverId]);
@@ -87,7 +85,7 @@ export default function HostAgentReadyRoute() {
serverId, serverId,
workspaceId: cwd, workspaceId: cwd,
target: { kind: "agent", agentId }, target: { kind: "agent", agentId },
}) as any }) as any,
); );
return; return;
} }
+8 -17
View File
@@ -17,11 +17,11 @@ export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>(); const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : ""; const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences(); const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore( const sessionAgents = useSessionStore((state) =>
(state) => (serverId ? state.sessions[serverId]?.agents : undefined) serverId ? state.sessions[serverId]?.agents : undefined,
); );
const sessionWorkspaces = useSessionStore( const sessionWorkspaces = useSessionStore((state) =>
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined) serverId ? state.sessions[serverId]?.workspaces : undefined,
); );
useEffect(() => { useEffect(() => {
@@ -44,12 +44,10 @@ export default function HostIndexRoute() {
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt) ? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: []; : [];
visibleAgents.sort( visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime() (left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
); );
const visibleWorkspaces = sessionWorkspaces const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
? Array.from(sessionWorkspaces.values())
: [];
visibleWorkspaces.sort((left, right) => { visibleWorkspaces.sort((left, right) => {
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
@@ -63,7 +61,7 @@ export default function HostIndexRoute() {
serverId, serverId,
workspaceId: primaryAgent.cwd.trim(), workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id }, target: { kind: "agent", agentId: primaryAgent.id },
}) as any }) as any,
); );
return; return;
} }
@@ -78,14 +76,7 @@ export default function HostIndexRoute() {
}, HOST_ROOT_REDIRECT_DELAY_MS); }, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [ }, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
pathname,
preferencesLoading,
router,
serverId,
sessionAgents,
sessionWorkspaces,
]);
return null; return null;
} }
@@ -1,80 +1,82 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router' import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store' import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen' import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { import {
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
type WorkspaceOpenIntent, type WorkspaceOpenIntent,
} from '@/utils/host-routes' } from "@/utils/host-routes";
import { prepareWorkspaceTab } from '@/utils/workspace-navigation' import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
function getParamValue(value: string | string[] | undefined): string { function getParamValue(value: string | string[] | undefined): string {
if (typeof value === 'string') { if (typeof value === "string") {
return value.trim() return value.trim();
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
const firstValue = value[0] const firstValue = value[0];
return typeof firstValue === 'string' ? firstValue.trim() : '' return typeof firstValue === "string" ? firstValue.trim() : "";
} }
return '' return "";
} }
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget { function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === 'agent') { if (openIntent.kind === "agent") {
return { kind: 'agent', agentId: openIntent.agentId } return { kind: "agent", agentId: openIntent.agentId };
} }
if (openIntent.kind === 'terminal') { if (openIntent.kind === "terminal") {
return { kind: 'terminal', terminalId: openIntent.terminalId } return { kind: "terminal", terminalId: openIntent.terminalId };
} }
if (openIntent.kind === 'file') { if (openIntent.kind === "file") {
return { kind: 'file', path: openIntent.path } return { kind: "file", path: openIntent.path };
} }
return { kind: 'draft', draftId: openIntent.draftId } return { kind: "draft", draftId: openIntent.draftId };
} }
export default function HostWorkspaceLayout() { export default function HostWorkspaceLayout() {
const router = useRouter() const router = useRouter();
const consumedIntentRef = useRef<string | null>(null) const consumedIntentRef = useRef<string | null>(null);
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
serverId?: string | string[] serverId?: string | string[];
workspaceId?: string | string[] workspaceId?: string | string[];
}>() }>();
const globalParams = useGlobalSearchParams<{ const globalParams = useGlobalSearchParams<{
open?: string | string[] open?: string | string[];
}>() }>();
const serverId = getParamValue(params.serverId) const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId) const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : '' const workspaceId = workspaceValue
const openValue = getParamValue(globalParams.open) ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => { useEffect(() => {
if (!openValue) { if (!openValue) {
return return;
} }
const consumptionKey = `${serverId}:${workspaceId}:${openValue}` const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) { if (consumedIntentRef.current === consumptionKey) {
return return;
} }
consumedIntentRef.current = consumptionKey consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue) const openIntent = parseWorkspaceOpenIntent(openValue);
const route = openIntent const route = openIntent
? prepareWorkspaceTab({ ? prepareWorkspaceTab({
serverId, serverId,
workspaceId, workspaceId,
target: getOpenIntentTarget(openIntent), target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === 'agent', pin: openIntent.kind === "agent",
}) })
: buildHostWorkspaceRoute(serverId, workspaceId) : buildHostWorkspaceRoute(serverId, workspaceId);
router.replace(route as any) router.replace(route as any);
}, [openValue, router, serverId, workspaceId]) }, [openValue, router, serverId, workspaceId]);
if (openValue) { if (openValue) {
return null return null;
} }
return ( return (
@@ -83,5 +85,5 @@ export default function HostWorkspaceLayout() {
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
/> />
) );
} }
+51 -51
View File
@@ -1,82 +1,82 @@
import { useEffect, useSyncExternalStore, useState } from 'react' import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from 'expo-router' import { usePathname, useRouter } from "expo-router";
import { useHosts } from '@/runtime/host-runtime' import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon' import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from '@/utils/host-routes' import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from '@/screens/startup-splash-screen' import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from '@/components/welcome-screen' import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { import {
shouldRedirectToWelcome, shouldRedirectToWelcome,
shouldWaitOnStartupRace, shouldWaitOnStartupRace,
WELCOME_ROUTE, WELCOME_ROUTE,
} from '@/app-support/index-startup' } from "@/app-support/index-startup";
const STARTUP_TIMEOUT_MS = 30_000 const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null { function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore() const runtime = getHostRuntimeStore();
return useSyncExternalStore( return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange), (onStoreChange) => runtime.subscribeAll(onStoreChange),
() => { () => {
let firstOnlineServerId: string | null = null let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null let firstOnlineAt: string | null = null;
for (const serverId of serverIds) { for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId) const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue continue;
} }
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId firstOnlineServerId = serverId;
} }
} }
return firstOnlineServerId return firstOnlineServerId;
}, },
() => { () => {
let firstOnlineServerId: string | null = null let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null let firstOnlineAt: string | null = null;
for (const serverId of serverIds) { for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId) const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue continue;
} }
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId firstOnlineServerId = serverId;
} }
} }
return firstOnlineServerId return firstOnlineServerId;
} },
) );
} }
export default function Index() { export default function Index() {
const router = useRouter() const router = useRouter();
const pathname = usePathname() const pathname = usePathname();
const daemons = useHosts() const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false) const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon() const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId)) const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setHasTimedOut(true) setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS) }, STARTUP_TIMEOUT_MS);
return () => { return () => {
clearTimeout(timer) clearTimeout(timer);
} };
}, []) }, []);
useEffect(() => { useEffect(() => {
if (!onlineServerId) { if (!onlineServerId) {
return return;
} }
if (pathname !== '/' && pathname !== '') { if (pathname !== "/" && pathname !== "") {
return return;
} }
router.replace(buildHostRootRoute(onlineServerId) as any) router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]) }, [onlineServerId, pathname, router]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -88,10 +88,10 @@ export default function Index() {
daemonCount: daemons.length, daemonCount: daemons.length,
}) })
) { ) {
return return;
} }
router.replace(WELCOME_ROUTE as any) router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]) }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
if ( if (
shouldWaitOnStartupRace({ shouldWaitOnStartupRace({
@@ -102,12 +102,12 @@ export default function Index() {
pathname, pathname,
}) })
) { ) {
return <StartupSplashScreen /> return <StartupSplashScreen />;
} }
if (!onlineServerId) { if (!onlineServerId) {
return <WelcomeScreen /> return <WelcomeScreen />;
} }
return null return null;
} }
+21 -20
View File
@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
buildHostRootRoute,
buildHostSettingsRoute,
} from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
targetServerId?: string; targetServerId?: string;
}>(); }>();
const source = typeof params.source === "string" ? params.source : "settings"; const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId = const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts(); const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false); const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null); const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const pendingNameHostname = useSessionStore( const pendingNameHostname = useSessionStore(
useCallback( useCallback(
(state) => { (state) => {
if (!pendingNameHost) return null; if (!pendingNameHost) return null;
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
}, },
[pendingNameHost] [pendingNameHost],
) ),
); );
const returnToSource = useCallback( const returnToSource = useCallback(
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
router.replace(buildHostSettingsRoute(settingsServerId) as any); router.replace(buildHostSettingsRoute(settingsServerId) as any);
} }
}, },
[router, source, sourceServerId, targetServerId] [router, source, sourceServerId, targetServerId],
); );
const closeToSource = useCallback(() => { const closeToSource = useCallback(() => {
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
if (targetServerId && offer.serverId !== targetServerId) { if (targetServerId && offer.serverId !== targetServerId) {
lastScannedRef.current = null; lastScannedRef.current = null;
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`); Alert.alert(
"Wrong daemon",
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
);
return; return;
} }
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false); setIsPairing(false);
} }
}, },
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl] [daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
); );
if (Platform.OS === "web") { if (Platform.OS === "web") {
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
<Text style={styles.permissionBody}> <Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon. Allow camera access to scan the pairing QR code from your daemon.
</Text> </Text>
<Pressable <Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
style={styles.permissionButton}
onPress={() => void requestPermission()}
>
<Text style={styles.permissionButtonText}>Grant permission</Text> <Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable> </Pressable>
</View> </View>
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} /> <View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} /> <View style={[styles.corner, styles.cornerBR]} />
</View> </View>
<Text style={styles.helperText}> <Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
Point your camera at the pairing QR code.
</Text>
{isPairing ? ( {isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}> <Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing… Pairing…
+1 -3
View File
@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
return null; return null;
} }
if (preferences.serverId) { if (preferences.serverId) {
const match = daemons.find( const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
(daemon) => daemon.serverId === preferences.serverId
);
if (match) { if (match) {
return match.serverId; return match.serverId;
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { WelcomeScreen } from '@/components/welcome-screen' import { WelcomeScreen } from "@/components/welcome-screen";
export default function WelcomeRoute() { export default function WelcomeRoute() {
return <WelcomeScreen /> return <WelcomeScreen />;
} }
@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">; storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string; baseDirectoryName: string;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>; resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: { releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
attachment: AttachmentMetadata;
url: string;
}) => Promise<void>;
}): AttachmentStore { }): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/` ? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, { await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true, idempotent: true,
}); });
}) }),
); );
}, },
}; };
+6 -8
View File
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
} }
export async function encodeAttachmentsForSend( export async function encodeAttachmentsForSend(
attachments: readonly AttachmentMetadata[] | undefined attachments: readonly AttachmentMetadata[] | undefined,
): Promise<Array<{ data: string; mimeType: string }> | undefined> { ): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!attachments || attachments.length === 0) { if (!attachments || attachments.length === 0) {
return undefined; return undefined;
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
}); });
return null; return null;
} }
}) }),
); );
const valid = encoded.filter( const valid = encoded.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null (entry): entry is { data: string; mimeType: string } => entry !== null,
); );
return valid.length > 0 ? valid : undefined; return valid.length > 0 ? valid : undefined;
} }
export async function resolveAttachmentPreviewUrl( export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
attachment: AttachmentMetadata
): Promise<string> {
const store = await getAttachmentStore(); const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment }); return await store.resolvePreviewUrl({ attachment });
} }
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
} }
export async function deleteAttachments( export async function deleteAttachments(
attachments: readonly AttachmentMetadata[] | undefined attachments: readonly AttachmentMetadata[] | undefined,
): Promise<void> { ): Promise<void> {
if (!attachments || attachments.length === 0) { if (!attachments || attachments.length === 0) {
return; return;
@@ -113,7 +111,7 @@ export async function deleteAttachments(
error, error,
}); });
} }
}) }),
); );
} }
+2 -6
View File
@@ -13,15 +13,11 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
return createDesktopAttachmentStore(); return createDesktopAttachmentStore();
} }
const { createIndexedDbAttachmentStore } = await import( const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
"./web/indexeddb-attachment-store"
);
return createIndexedDbAttachmentStore(); return createIndexedDbAttachmentStore();
} }
const { createNativeFileAttachmentStore } = await import( const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
"./native/native-file-attachment-store"
);
return createNativeFileAttachmentStore(); return createNativeFileAttachmentStore();
} }
@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
export function useAttachmentPreviewUrl( export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined attachment: AttachmentMetadata | null | undefined,
): string | null { ): string | null {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null); const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl, url: currentUrl,
}); });
}; };
}, [ }, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
attachment?.id,
attachment?.storageType,
attachment?.storageKey,
attachment?.mimeType,
]);
return url; return url;
} }
@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
function runTx<T>( function runTx<T>(
db: IDBDatabase, db: IDBDatabase,
mode: IDBTransactionMode, mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T> run: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> { ): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode); const transaction = db.transaction(STORE_NAME, mode);
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
const source = input.source; const source = input.source;
if (source.kind === "blob") { if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type); const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType); const blob =
source.blob.type === mimeType
? source.blob
: source.blob.slice(0, source.blob.size, mimeType);
return { blob, mimeType }; return { blob, mimeType };
} }
@@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> { async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) => const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
store.get(id) store.get(id),
); );
if (!record?.blob) { if (!record?.blob) {
throw new Error(`Attachment ${id} was not found in IndexedDB.`); throw new Error(`Attachment ${id} was not found in IndexedDB.`);
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
try { try {
await runTx(db, "readwrite", (store) => await runTx(db, "readwrite", (store) =>
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord) store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
); );
} finally { } finally {
db.close(); db.close();
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const cursorRequest = store.openCursor(); const cursorRequest = store.openCursor();
cursorRequest.onerror = () => { cursorRequest.onerror = () => {
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store.")); reject(
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
);
}; };
cursorRequest.onsuccess = () => { cursorRequest.onsuccess = () => {
@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
backButtonText: { backButtonText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: '600', fontWeight: "600",
textAlign: 'center', textAlign: "center",
}, },
scrollView: { scrollView: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
}, },
processItem: { processItem: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
processText: { processText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: '500', fontWeight: "500",
}, },
processTextActive: { processTextActive: {
color: theme.colors.primaryForeground, color: theme.colors.primaryForeground,
@@ -147,20 +147,21 @@ export function ActiveProcesses({
onPress={onSelectOrchestrator} onPress={onSelectOrchestrator}
style={({ pressed }) => [ style={({ pressed }) => [
styles.processItem, styles.processItem,
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive, viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 }, pressed && { opacity: 0.7 },
]} ]}
> >
<View style={styles.agentIcon} /> <View style={styles.agentIcon} />
<Text style={[ <Text
styles.processText, style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
viewMode === 'orchestrator' && styles.processTextActive, >
]}>Orchestrator</Text> Orchestrator
</Text>
</Pressable> </Pressable>
{/* Agent pills */} {/* Agent pills */}
{agents.map((agent) => { {agents.map((agent) => {
const isActive = viewMode === 'agent' && activeAgentId === agent.id; const isActive = viewMode === "agent" && activeAgentId === agent.id;
return ( return (
<Pressable <Pressable
@@ -174,15 +175,21 @@ export function ActiveProcesses({
> >
<View style={styles.agentIcon} /> <View style={styles.agentIcon} />
<Text style={[ <Text style={[styles.processText, isActive && styles.processTextActive]}>
styles.processText, {agent.id.substring(0, 8)}
isActive && styles.processTextActive, </Text>
]}>{agent.id.substring(0, 8)}</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} /> <View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
{agent.currentModeId && ( {agent.currentModeId && (
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} /> <View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
)} )}
</Pressable> </Pressable>
); );
@@ -1,15 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import type { TextInputProps } from "react-native"; import type { TextInputProps } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -121,8 +113,7 @@ export function AdaptiveModalSheet({
testID, testID,
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null); const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false); const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -148,19 +139,14 @@ export function AdaptiveModalSheet({
onClose(); onClose();
} }
}, },
[onClose] [onClose],
); );
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
), ),
[] [],
); );
if (isMobile) { if (isMobile) {
@@ -180,11 +166,7 @@ export function AdaptiveModalSheet({
> >
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
<Pressable <Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} /> <X size={16} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
@@ -209,11 +191,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopCard}> <View style={styles.desktopCard}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
<Pressable <Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} /> <X size={16} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
@@ -254,13 +232,12 @@ export function AdaptiveModalSheet({
*/ */
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>( export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) { function AdaptiveTextInput(props, ref) {
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) { if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />; return <BottomSheetTextInput ref={ref as any} {...props} />;
} }
return <TextInput ref={ref} {...props} />; return <TextInput ref={ref} {...props} />;
} },
); );
@@ -60,8 +60,17 @@ export function AddHostMethodModal({
}, [onPasteLink]); }, [onPasteLink]);
return ( return (
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal"> <AdaptiveModalSheet
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection"> title="Add connection"
visible={visible}
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityLabel="Direct connection"
>
<Link2 size={18} color={theme.colors.foreground} /> <Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text> <Text style={styles.optionText}>Direct connection</Text>
@@ -79,7 +88,11 @@ export function AddHostMethodModal({
</Pressable> </Pressable>
) : null} ) : null}
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link"> <Pressable
style={styles.option}
onPress={handlePaste}
accessibilityLabel="Paste pairing link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} /> <ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text> <Text style={styles.optionText}>Paste pairing link</Text>
+43 -11
View File
@@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
.map((value) => normalizeTransportMessage(value)) .map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value)) .filter((value): value is string => Boolean(value))
.map((value) => value.trim()) .map((value) => value.trim())
.filter((value) => value.length > 0) .filter((value) => value.length > 0),
) ),
); );
if (unique.length === 0) return null; if (unique.length === 0) return null;
@@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
return unique.join(" — "); return unique.join(" — ");
} }
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } { function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`; const title = `We failed to connect to ${endpoint}.`;
const raw = (() => { const raw = (() => {
@@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
detail = "Host not found. Check the hostname and try again."; detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall."; detail = "Host is unreachable. Check your network and firewall.";
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) { } else if (
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access."; rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) { } else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable."; detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else { } else {
@@ -125,15 +133,25 @@ export interface AddHostModalProps {
onClose: () => void; onClose: () => void;
targetServerId?: string; targetServerId?: string;
onCancel?: () => void; onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
} }
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) { export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations(); const { upsertDirectConnection } = useHostMutations();
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null); const hostInputRef = useRef<TextInput>(null);
@@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]); }, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
return ( return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal"> <AdaptiveModalSheet
title="Direct connection"
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text> <Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}> <View style={styles.field}>
+81 -111
View File
@@ -1,76 +1,77 @@
import { useState } from 'react'; import { useState } from "react";
import { View, Text, Pressable } from 'react-native'; import { View, Text, Pressable } from "react-native";
import { StyleSheet } from 'react-native-unistyles'; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity'; import type {
AgentActivity,
GroupedTextMessage,
MergedToolCall,
SessionUpdate,
} from "@/types/agent-activity";
interface AgentActivityItemProps { interface AgentActivityItemProps {
item: GroupedTextMessage | MergedToolCall | AgentActivity; item: GroupedTextMessage | MergedToolCall | AgentActivity;
} }
function formatTimestamp(date: Date): string { function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
hour: 'numeric', hour: "numeric",
minute: '2-digit', minute: "2-digit",
second: '2-digit', second: "2-digit",
hour12: true, hour12: true,
}).format(date); }).format(date);
} }
function getToolIcon(toolKind?: string): string { function getToolIcon(toolKind?: string): string {
switch (toolKind) { switch (toolKind) {
case 'read': case "read":
return '📖'; return "📖";
case 'edit': case "edit":
return '✏️'; return "✏️";
case 'delete': case "delete":
return '🗑️'; return "🗑️";
case 'move': case "move":
return '📦'; return "📦";
case 'search': case "search":
return '🔍'; return "🔍";
case 'execute': case "execute":
return '▶️'; return "▶️";
case 'think': case "think":
return '💭'; return "💭";
case 'fetch': case "fetch":
return '🌐'; return "🌐";
case 'switch_mode': case "switch_mode":
return '🔄'; return "🔄";
default: default:
return '🔧'; return "🔧";
} }
} }
function getStatusColor(status?: string): string { function getStatusColor(status?: string): string {
switch (status) { switch (status) {
case 'pending': case "pending":
return '#9ca3af'; return "#9ca3af";
case 'in_progress': case "in_progress":
return '#fbbf24'; return "#fbbf24";
case 'completed': case "completed":
return '#22c55e'; return "#22c55e";
case 'failed': case "failed":
return '#ef4444'; return "#ef4444";
default: default:
return '#6b7280'; return "#6b7280";
} }
} }
function GroupedTextItem({ item }: { item: GroupedTextMessage }) { function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === 'thought'; const isThought = item.messageType === "thought";
return ( return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}> <View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}> <Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)} {formatTimestamp(item.startTimestamp)}
</Text> </Text>
{isThought && ( {isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text> <Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
)}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
{item.text}
</Text>
</View> </View>
); );
} }
@@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
return ( return (
<View style={stylesheet.toolCard}> <View style={stylesheet.toolCard}>
<Pressable <Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.toolHeader}
>
<View style={stylesheet.toolHeaderLeft}> <View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}> <Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
{formatTimestamp(item.startTimestamp)}
</Text>
<View style={stylesheet.toolTitleRow}> <View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text> <Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text> <Text style={stylesheet.toolTitle}>{item.title}</Text>
<View <View
style={[ style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(item.status) },
]}
> >
<Text style={stylesheet.statusText}>{item.status}</Text> <Text style={stylesheet.statusText}>{item.status}</Text>
</View> </View>
</View> </View>
</View> </View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text> <Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable> </Pressable>
{isExpanded && ( {isExpanded && (
@@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{item.input && ( {item.input && (
<View style={stylesheet.section}> <View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text> <Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}> <Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
{JSON.stringify(item.input, null, 2)}
</Text>
</View> </View>
)} )}
{item.output && ( {item.output && (
<View style={stylesheet.section}> <View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text> <Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}> <Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
{JSON.stringify(item.output, null, 2)}
</Text>
</View> </View>
)} )}
{!item.input && !item.output && ( {!item.input && !item.output && (
<Text style={stylesheet.emptyText}> <Text style={stylesheet.emptyText}>No details available</Text>
No details available
</Text>
)} )}
</View> </View>
)} )}
@@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) { function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== 'plan') { if (update.kind !== "plan") {
return null; return null;
} }
return ( return (
<View style={stylesheet.planCard}> <View style={stylesheet.planCard}>
<Pressable <Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.planHeader}
>
<View style={stylesheet.planHeaderLeft}> <View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text> <Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}> <Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
📋 Tasks ({update.entries.length})
</Text>
</View> </View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text> <Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable> </Pressable>
{isExpanded && ( {isExpanded && (
<View style={stylesheet.planContent}> <View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => ( {update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}> <View key={idx} style={stylesheet.planEntry}>
<Text <Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
style={[ {entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
stylesheet.planEntryStatus,
{ color: getStatusColor(entry.status) },
]}
>
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
</Text> </Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text> <Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View> </View>
@@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
return ( return (
<View style={stylesheet.unknownCard}> <View style={stylesheet.unknownCard}>
<Pressable <Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
onPress={() => setShowDrawer(!showDrawer)}
style={stylesheet.unknownHeader}
>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text> <Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}> <View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text> <Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
@@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
{showDrawer && ( {showDrawer && (
<View style={stylesheet.drawerContent}> <View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}> <Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
{JSON.stringify(update, null, 2)}
</Text>
</View> </View>
)} )}
</View> </View>
@@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
export function AgentActivityItem({ item }: AgentActivityItemProps) { export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message // Grouped text message
if ('kind' in item && item.kind === 'grouped_text') { if ("kind" in item && item.kind === "grouped_text") {
return <GroupedTextItem item={item} />; return <GroupedTextItem item={item} />;
} }
// Merged tool call // Merged tool call
if ('kind' in item && item.kind === 'merged_tool_call') { if ("kind" in item && item.kind === "merged_tool_call") {
return <MergedToolCallItem item={item} />; return <MergedToolCallItem item={item} />;
} }
@@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const update = activity.update; const update = activity.update;
// Tasks // Tasks
if (update.kind === 'plan') { if (update.kind === "plan") {
return <PlanItem update={update} timestamp={activity.timestamp} />; return <PlanItem update={update} timestamp={activity.timestamp} />;
} }
// Available commands update // Available commands update
if (update.kind === 'available_commands_update') { if (update.kind === "available_commands_update") {
return ( return (
<View style={stylesheet.card}> <View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text> <Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
@@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
} }
// Current mode update // Current mode update
if (update.kind === 'current_mode_update') { if (update.kind === "current_mode_update") {
return ( return (
<View style={stylesheet.card}> <View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text> <Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}> <Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
Mode changed to: {update.currentModeId}
</Text>
</View> </View>
); );
} }
@@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({
}, },
thoughtText: { thoughtText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontStyle: 'italic', fontStyle: "italic",
}, },
toolCard: { toolCard: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
overflow: 'hidden', overflow: "hidden",
}, },
toolHeader: { toolHeader: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
padding: theme.spacing[3], padding: theme.spacing[3],
}, },
toolHeaderLeft: { toolHeaderLeft: {
flex: 1, flex: 1,
}, },
toolTitleRow: { toolTitleRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
marginTop: theme.spacing[1], marginTop: theme.spacing[1],
}, },
@@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({
emptyText: { emptyText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontStyle: 'italic', fontStyle: "italic",
}, },
planCard: { planCard: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
overflow: 'hidden', overflow: "hidden",
}, },
planHeader: { planHeader: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
padding: theme.spacing[3], padding: theme.spacing[3],
}, },
planHeaderLeft: { planHeaderLeft: {
@@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({
padding: theme.spacing[3], padding: theme.spacing[3],
}, },
planEntry: { planEntry: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'flex-start', alignItems: "flex-start",
gap: theme.spacing[2], gap: theme.spacing[2],
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
}, },
@@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
overflow: 'hidden', overflow: "hidden",
}, },
unknownHeader: { unknownHeader: {
padding: theme.spacing[3], padding: theme.spacing[3],
@@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2], marginTop: theme.spacing[2],
alignSelf: 'flex-start', alignSelf: "flex-start",
}, },
unknownBadgeText: { unknownBadgeText: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -1,13 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
View,
Text,
Pressable,
TextInput,
ActivityIndicator,
Platform,
} from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
@@ -17,7 +10,18 @@ import {
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native"; import {
ChevronDown,
ChevronRight,
Pencil,
Check,
X,
Bot,
Brain,
ShieldCheck,
ShieldAlert,
ShieldOff,
} from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme"; import { theme as defaultTheme } from "@/styles/theme";
import type { import type {
AgentMode, AgentMode,
@@ -107,19 +111,14 @@ export function DropdownField({
testID={testID} testID={testID}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]} style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
> >
<Text <Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
numberOfLines={1}
>
{value || placeholder} {value || placeholder}
</Text> </Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} /> <ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable> </Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null} {warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && helperText ? ( {!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
<Text style={styles.helperText}>{helperText}</Text>
) : null}
</View> </View>
); );
} }
@@ -176,7 +175,7 @@ export function SelectField({
onPress(); onPress();
} }
}, },
[getWebKey, onPress, preventWebDefault] [getWebKey, onPress, preventWebDefault],
); );
const normalizedValue = (value ?? "").trim(); const normalizedValue = (value ?? "").trim();
@@ -184,9 +183,7 @@ export function SelectField({
const hasConcreteValue = const hasConcreteValue =
normalizedValue.length > 0 && normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder); (normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
? normalizedValue
: (normalizedPlaceholder || "Select...");
return ( return (
<View style={styles.selectFieldContainer}> <View style={styles.selectFieldContainer}>
@@ -231,12 +228,7 @@ interface DropdownSheetProps {
} }
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) { function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return ( return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
} }
export function DropdownSheet({ export function DropdownSheet({
@@ -267,19 +259,14 @@ export function DropdownSheet({
onClose(); onClose();
} }
}, },
[onClose] [onClose],
); );
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
), ),
[] [],
); );
return ( return (
@@ -450,16 +437,14 @@ export function FormSelectTrigger({
onPress(); onPress();
} }
}, },
[getWebKey, onPress, preventWebDefault] [getWebKey, onPress, preventWebDefault],
); );
const normalizedValue = (value ?? "").trim(); const normalizedValue = (value ?? "").trim();
const normalizedPlaceholder = (placeholder ?? "").trim(); const normalizedPlaceholder = (placeholder ?? "").trim();
const hasConcreteValue = const hasConcreteValue =
normalizedValue.length > 0 && normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder); (normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
? normalizedValue
: (normalizedPlaceholder || "Select...");
return ( return (
<Pressable <Pressable
@@ -481,9 +466,7 @@ export function FormSelectTrigger({
> >
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null} {icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
<View style={styles.compactSelectValueContainer}> <View style={styles.compactSelectValueContainer}>
{showLabel ? ( {showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
<Text style={styles.compactSelectLabel}>{label}</Text>
) : null}
{isLoading ? ( {isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
) : ( ) : (
@@ -540,7 +523,7 @@ export function AgentConfigRow({
id: def.id, id: def.id,
label: def.label, label: def.label,
})), })),
[providerDefinitions] [providerDefinitions],
); );
const modeSelectOptions: ComboSelectOption[] = useMemo(() => { const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
@@ -554,9 +537,7 @@ export function AgentConfigRow({
}, [modeOptions]); }, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => { const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [ const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
{ id: "", label: "Auto" },
];
for (const model of models) { for (const model of models) {
opts.push({ opts.push({
id: model.id, id: model.id,
@@ -572,7 +553,7 @@ export function AgentConfigRow({
id: option.id, id: option.id,
label: option.label, label: option.label,
})), })),
[thinkingOptions] [thinkingOptions],
); );
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : ""); const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
@@ -609,7 +590,9 @@ export function AgentConfigRow({
disabled={disabled} disabled={disabled}
isLoading={isModelLoading} isLoading={isModelLoading}
onSelect={onSelectModel} onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />} icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false} showLabel={false}
testID="draft-model-select" testID="draft-model-select"
/> />
@@ -638,7 +621,9 @@ export function AgentConfigRow({
placeholder="Select..." placeholder="Select..."
disabled={disabled} disabled={disabled}
onSelect={onSelectThinkingOption} onSelect={onSelectThinkingOption}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />} icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false} showLabel={false}
/> />
</View> </View>
@@ -664,7 +649,7 @@ export function AssistantDropdown({
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find( const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider (definition) => definition.id === selectedProvider,
); );
const options = useMemo( const options = useMemo(
@@ -673,7 +658,7 @@ export function AssistantDropdown({
id: def.id, id: def.id,
label: def.label, label: def.label,
})), })),
[providerDefinitions] [providerDefinitions],
); );
const handleOpen = useCallback(() => setIsOpen(true), []); const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -720,9 +705,9 @@ export function PermissionsDropdown({
const hasOptions = modeOptions.length > 0; const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions const selectedModeLabel = hasOptions
? modeOptions.find((mode) => mode.id === selectedMode)?.label ?? ? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ?? modeOptions[0]?.label ??
"Default" "Default")
: "Automatic"; : "Automatic";
const options = useMemo( const options = useMemo(
@@ -732,7 +717,7 @@ export function PermissionsDropdown({
label: mode.label, label: mode.label,
description: mode.description, description: mode.description,
})), })),
[modeOptions] [modeOptions],
); );
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
@@ -751,9 +736,7 @@ export function PermissionsDropdown({
onPress={handleOpen} onPress={handleOpen}
disabled={disabled || !hasOptions} disabled={disabled || !hasOptions}
helperText={ helperText={
hasOptions hasOptions ? undefined : "This assistant does not expose selectable permissions."
? undefined
: "This assistant does not expose selectable permissions."
} }
controlRef={anchorRef} controlRef={anchorRef}
/> />
@@ -795,7 +778,7 @@ export function ModelDropdown({
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel ? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
: "Automatic"; : "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic"; const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error const helperText = error
@@ -834,7 +817,7 @@ export function ModelDropdown({
onSelect(id); onSelect(id);
} }
}, },
[onClear, onSelect] [onClear, onSelect],
); );
return ( return (
@@ -882,7 +865,7 @@ export function WorkingDirectoryDropdown({
const options = useMemo( const options = useMemo(
() => suggestedPaths.map((path) => ({ id: path, label: path })), () => suggestedPaths.map((path) => ({ id: path, label: path })),
[suggestedPaths] [suggestedPaths],
); );
const handleOpen = useCallback(() => setIsOpen(true), []); const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -957,9 +940,7 @@ export function ToggleRow({
</View> </View>
<View style={styles.toggleTextContainer}> <View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text> <Text style={styles.toggleLabel}>{label}</Text>
{description ? ( {description ? <Text style={styles.helperText}>{description}</Text> : null}
<Text style={styles.helperText}>{description}</Text>
) : null}
</View> </View>
</Pressable> </Pressable>
); );
@@ -1052,9 +1033,7 @@ export function GitOptionsSection({
<View style={styles.gitOptionsContainer}> <View style={styles.gitOptionsContainer}>
<Pressable <Pressable
testID="worktree-create-toggle" testID="worktree-create-toggle"
onPress={() => onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
onWorktreeModeChange(isCreateMode ? "none" : "create")
}
disabled={isLoading} disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]} style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
> >
@@ -1077,9 +1056,7 @@ export function GitOptionsSection({
<Pressable <Pressable
testID="worktree-attach-toggle" testID="worktree-attach-toggle"
onPress={() => onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
onWorktreeModeChange(isAttachMode ? "none" : "attach")
}
disabled={isLoading} disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]} style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
> >
@@ -1143,8 +1120,15 @@ export function GitOptionsSection({
placeholderTextColor={defaultTheme.colors.foregroundMuted} placeholderTextColor={defaultTheme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit} onSubmitEditing={handleConfirmEdit}
/> />
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}> <Pressable
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} /> onPress={handleConfirmEdit}
hitSlop={8}
style={styles.baseBranchIconButton}
>
<Check
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
</Pressable> </Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}> <Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} /> <X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
@@ -1159,17 +1143,11 @@ export function GitOptionsSection({
</View> </View>
) : null} ) : null}
{baseBranchError ? ( {baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{repoError ? ( {repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{gitValidationError ? ( {gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
</View> </View>
); );
} }
@@ -1,31 +1,31 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from './agent-input-area.status-controls' import { resolveStatusControlMode } from "./agent-input-area.status-controls";
describe('resolveStatusControlMode', () => { describe("resolveStatusControlMode", () => {
it('uses ready mode when no controlled status controls are provided', () => { it("uses ready mode when no controlled status controls are provided", () => {
expect(resolveStatusControlMode(undefined)).toBe('ready') expect(resolveStatusControlMode(undefined)).toBe("ready");
}) });
it('uses draft mode when controlled status controls are provided', () => { it("uses draft mode when controlled status controls are provided", () => {
expect( expect(
resolveStatusControlMode({ resolveStatusControlMode({
providerDefinitions: [], providerDefinitions: [],
selectedProvider: 'codex', selectedProvider: "codex",
onSelectProvider: () => undefined, onSelectProvider: () => undefined,
modeOptions: [], modeOptions: [],
selectedMode: '', selectedMode: "",
onSelectMode: () => undefined, onSelectMode: () => undefined,
models: [], models: [],
selectedModel: '', selectedModel: "",
onSelectModel: () => undefined, onSelectModel: () => undefined,
isModelLoading: false, isModelLoading: false,
allProviderModels: new Map(), allProviderModels: new Map(),
isAllModelsLoading: false, isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined, onSelectProviderAndModel: () => undefined,
thinkingOptions: [], thinkingOptions: [],
selectedThinkingOptionId: '', selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined, onSelectThinkingOption: () => undefined,
}) }),
).toBe('draft') ).toBe("draft");
}) });
}) });
@@ -1,5 +1,5 @@
import type { DraftAgentStatusBarProps } from './agent-status-bar' import type { DraftAgentStatusBarProps } from "./agent-status-bar";
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) { export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? 'draft' : 'ready' return statusControls ? "draft" : "ready";
} }
File diff suppressed because it is too large Load Diff
+142 -143
View File
@@ -6,107 +6,107 @@ import {
RefreshControl, RefreshControl,
FlatList, FlatList,
type ListRenderItem, type ListRenderItem,
} from 'react-native' } from "react-native";
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from 'react' import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from 'expo-router' import { router } from "expo-router";
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from '@/utils/time' import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from '@/utils/shorten-path' import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents' import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from '@/stores/session-store' import { useSessionStore } from "@/stores/session-store";
import { Archive } from 'lucide-react-native' import { Archive } from "lucide-react-native";
import { prepareWorkspaceTab } from '@/utils/workspace-navigation' import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps { interface AgentListProps {
agents: AggregatedAgent[] agents: AggregatedAgent[];
showCheckoutInfo?: boolean showCheckoutInfo?: boolean;
isRefreshing?: boolean isRefreshing?: boolean;
onRefresh?: () => void onRefresh?: () => void;
selectedAgentId?: string selectedAgentId?: string;
onAgentSelect?: () => void onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean showAttentionIndicator?: boolean;
} }
type FlatListItem = type FlatListItem =
| { type: 'header'; key: string; title: string } | { type: "header"; key: string; title: string }
| { type: 'agent'; key: string; agent: AggregatedAgent } | { type: "agent"; key: string; agent: AggregatedAgent };
function deriveDateSectionLabel(lastActivityAt: Date): string { function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date() const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000) const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
const activityStart = new Date( const activityStart = new Date(
lastActivityAt.getFullYear(), lastActivityAt.getFullYear(),
lastActivityAt.getMonth(), lastActivityAt.getMonth(),
lastActivityAt.getDate() lastActivityAt.getDate(),
) );
if (activityStart.getTime() >= todayStart.getTime()) { if (activityStart.getTime() >= todayStart.getTime()) {
return 'Today' return "Today";
} }
if (activityStart.getTime() >= yesterdayStart.getTime()) { if (activityStart.getTime() >= yesterdayStart.getTime()) {
return 'Yesterday' return "Yesterday";
} }
const diffTime = todayStart.getTime() - activityStart.getTime() const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) { if (diffDays <= 7) {
return 'This week' return "This week";
} }
if (diffDays <= 30) { if (diffDays <= 30) {
return 'This month' return "This month";
} }
return 'Older' return "Older";
} }
function formatStatusLabel(status: AggregatedAgent['status']): string { function formatStatusLabel(status: AggregatedAgent["status"]): string {
switch (status) { switch (status) {
case 'initializing': case "initializing":
return 'Starting' return "Starting";
case 'idle': case "idle":
return 'Idle' return "Idle";
case 'running': case "running":
return 'Running' return "Running";
case 'error': case "error":
return 'Error' return "Error";
case 'closed': case "closed":
return 'Closed' return "Closed";
default: default:
return status return status;
} }
} }
function SessionBadge({ function SessionBadge({
label, label,
icon, icon,
tone = 'neutral', tone = "neutral",
}: { }: {
label: string label: string;
icon?: ReactElement icon?: ReactElement;
tone?: 'neutral' | 'warning' | 'danger' tone?: "neutral" | "warning" | "danger";
}) { }) {
return ( return (
<View <View
style={[ style={[
styles.badge, styles.badge,
tone === 'warning' && styles.badgeWarning, tone === "warning" && styles.badgeWarning,
tone === 'danger' && styles.badgeDanger, tone === "danger" && styles.badgeDanger,
]} ]}
> >
{icon} {icon}
<Text <Text
style={[ style={[
styles.badgeText, styles.badgeText,
tone === 'warning' && styles.badgeTextWarning, tone === "warning" && styles.badgeTextWarning,
tone === 'danger' && styles.badgeTextDanger, tone === "danger" && styles.badgeTextDanger,
]} ]}
> >
{label} {label}
</Text> </Text>
</View> </View>
) );
} }
function SessionRow({ function SessionRow({
@@ -117,19 +117,19 @@ function SessionRow({
onPress, onPress,
onLongPress, onLongPress,
}: { }: {
agent: AggregatedAgent agent: AggregatedAgent;
isMobile: boolean isMobile: boolean;
selectedAgentId?: string selectedAgentId?: string;
showAttentionIndicator: boolean showAttentionIndicator: boolean;
onPress: (agent: AggregatedAgent) => void onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void onLongPress: (agent: AggregatedAgent) => void;
}) { }) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
const timeAgo = formatTimeAgo(agent.lastActivityAt) const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}` const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status) const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd) const projectPath = shortenPath(agent.cwd);
return ( return (
<Pressable <Pressable
@@ -149,7 +149,7 @@ function SessionRow({
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]} style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1} numberOfLines={1}
> >
{agent.title || 'New session'} {agent.title || "New session"}
</Text> </Text>
{agent.archivedAt ? ( {agent.archivedAt ? (
<SessionBadge <SessionBadge
@@ -199,10 +199,9 @@ function SessionRow({
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
) );
} }
export function AgentList({ export function AgentList({
agents, agents,
isRefreshing = false, isRefreshing = false,
@@ -212,88 +211,88 @@ export function AgentList({
listFooterComponent, listFooterComponent,
showAttentionIndicator = true, showAttentionIndicator = true,
}: AgentListProps) { }: AgentListProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null) const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const actionClient = useSessionStore((state) => const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
) );
const isActionSheetVisible = actionAgent !== null const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient) const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback( const handleAgentPress = useCallback(
(agent: AggregatedAgent) => { (agent: AggregatedAgent) => {
if (isActionSheetVisible) { if (isActionSheetVisible) {
return return;
} }
const serverId = agent.serverId const serverId = agent.serverId;
const agentId = agent.id const agentId = agent.id;
onAgentSelect?.() onAgentSelect?.();
const route = prepareWorkspaceTab({ const route = prepareWorkspaceTab({
serverId, serverId,
workspaceId: agent.cwd, workspaceId: agent.cwd,
target: { kind: 'agent', agentId }, target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt), pin: Boolean(agent.archivedAt),
}) });
router.navigate(route as any) router.navigate(route as any);
}, },
[isActionSheetVisible, onAgentSelect] [isActionSheetVisible, onAgentSelect],
) );
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent) setActionAgent(agent);
}, []) }, []);
const handleCloseActionSheet = useCallback(() => { const handleCloseActionSheet = useCallback(() => {
setActionAgent(null) setActionAgent(null);
}, []) }, []);
const handleArchiveAgent = useCallback(() => { const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !actionClient) { if (!actionAgent || !actionClient) {
return return;
} }
void actionClient.archiveAgent(actionAgent.id) void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null) setActionAgent(null);
}, [actionAgent, actionClient]) }, [actionAgent, actionClient]);
const flatItems = useMemo((): FlatListItem[] => { const flatItems = useMemo((): FlatListItem[] => {
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>() const buckets = new Map<string, AggregatedAgent[]>();
for (const agent of agents) { for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt) const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [] const existing = buckets.get(label) ?? [];
existing.push(agent) existing.push(agent);
buckets.set(label, existing) buckets.set(label, existing);
} }
const result: FlatListItem[] = [] const result: FlatListItem[] = [];
for (const label of order) { for (const label of order) {
const data = buckets.get(label) const data = buckets.get(label);
if (!data || data.length === 0) { if (!data || data.length === 0) {
continue continue;
} }
result.push({ type: 'header', key: `header:${label}`, title: label }) result.push({ type: "header", key: `header:${label}`, title: label });
for (const agent of data) { for (const agent of data) {
result.push({ type: 'agent', key: `${agent.serverId}:${agent.id}`, agent }) result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
} }
} }
return result return result;
}, [agents]) }, [agents]);
const renderItem: ListRenderItem<FlatListItem> = useCallback( const renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => { ({ item }) => {
if (item.type === 'header') { if (item.type === "header") {
return ( return (
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text> <Text style={styles.sectionTitle}>{item.title}</Text>
</View> </View>
) );
} }
return ( return (
<SessionRow <SessionRow
@@ -304,12 +303,12 @@ export function AgentList({
onPress={handleAgentPress} onPress={handleAgentPress}
onLongPress={handleAgentLongPress} onLongPress={handleAgentLongPress}
/> />
) );
}, },
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator] [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
) );
const keyExtractor = useCallback((item: FlatListItem) => item.key, []) const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
return ( return (
<> <>
@@ -350,7 +349,7 @@ export function AgentList({
> >
<View style={styles.sheetHandle} /> <View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}> <Text style={styles.sheetTitle}>
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'} {isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
</Text> </Text>
<View style={styles.sheetButtonRow}> <View style={styles.sheetButtonRow}>
<Pressable <Pressable
@@ -380,7 +379,7 @@ export function AgentList({
</View> </View>
</Modal> </Modal>
</> </>
) );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
@@ -399,8 +398,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
sectionHeading: { sectionHeading: {
marginTop: theme.spacing[2], marginTop: theme.spacing[2],
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[3], gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
@@ -411,8 +410,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
row: { row: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
borderRadius: { borderRadius: {
@@ -429,15 +428,15 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
}, },
rowTitleRow: { rowTitleRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
flexWrap: 'wrap', flexWrap: "wrap",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
rowMetaRow: { rowMetaRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
flexWrap: 'wrap', flexWrap: "wrap",
gap: theme.spacing[1], gap: theme.spacing[1],
marginTop: 2, marginTop: 2,
}, },
@@ -456,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
sessionTitle: { sessionTitle: {
flexShrink: 1, flexShrink: 1,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: '400', fontWeight: "400",
color: theme.colors.foreground, color: theme.colors.foreground,
opacity: 0.86, opacity: 0.86,
}, },
@@ -464,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 1, opacity: 1,
}, },
sessionMetaText: { sessionMetaText: {
maxWidth: '100%', maxWidth: "100%",
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
@@ -486,11 +485,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
flexShrink: 0, flexShrink: 0,
width: 72, width: 72,
textAlign: 'right' as const, textAlign: "right" as const,
}, },
badge: { badge: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
@@ -498,10 +497,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
badgeWarning: { badgeWarning: {
backgroundColor: 'rgba(245, 158, 11, 0.12)', backgroundColor: "rgba(245, 158, 11, 0.12)",
}, },
badgeDanger: { badgeDanger: {
backgroundColor: 'rgba(239, 68, 68, 0.14)', backgroundColor: "rgba(239, 68, 68, 0.14)",
}, },
badgeText: { badgeText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -516,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({
}, },
sheetOverlay: { sheetOverlay: {
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: "flex-end",
}, },
sheetBackdrop: { sheetBackdrop: {
position: 'absolute', position: "absolute",
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
left: 0, left: 0,
backgroundColor: 'rgba(0,0,0,0.35)', backgroundColor: "rgba(0,0,0,0.35)",
}, },
sheetContainer: { sheetContainer: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius['2xl'], borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius['2xl'], borderTopRightRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[6], paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4], paddingTop: theme.spacing[4],
gap: theme.spacing[4], gap: theme.spacing[4],
}, },
sheetHandle: { sheetHandle: {
alignSelf: 'center', alignSelf: "center",
width: 40, width: 40,
height: 4, height: 4,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
@@ -546,18 +545,18 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground, color: theme.colors.foreground,
textAlign: 'center', textAlign: "center",
}, },
sheetButtonRow: { sheetButtonRow: {
flexDirection: 'row', flexDirection: "row",
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
sheetButton: { sheetButton: {
flex: 1, flex: 1,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}, },
sheetArchiveButton: { sheetArchiveButton: {
backgroundColor: theme.colors.primary, backgroundColor: theme.colors.primary,
@@ -578,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
})) }));
@@ -1,72 +1,72 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from "vitest";
import { import {
getStatusSelectorHint, getStatusSelectorHint,
normalizeModelId, normalizeModelId,
resolveAgentModelSelection, resolveAgentModelSelection,
} from './agent-status-bar.utils' } from "./agent-status-bar.utils";
describe('getStatusSelectorHint', () => { describe("getStatusSelectorHint", () => {
it('explains what each editable status control does', () => { it("explains what each editable status control does", () => {
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode') expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint('model')).toBe('Change model') expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint('mode')).toBe('Change permission mode') expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
}) });
}) });
describe('normalizeModelId', () => { describe("normalizeModelId", () => {
it('treats empty and default values as unset', () => { it("treats empty and default values as unset", () => {
expect(normalizeModelId('')).toBeNull() expect(normalizeModelId("")).toBeNull();
expect(normalizeModelId(' default ')).toBeNull() expect(normalizeModelId(" default ")).toBeNull();
expect(normalizeModelId(undefined)).toBeNull() expect(normalizeModelId(undefined)).toBeNull();
}) });
it('returns trimmed model ids', () => { it("returns trimmed model ids", () => {
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex') expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
}) });
}) });
describe('resolveAgentModelSelection', () => { describe("resolveAgentModelSelection", () => {
it('prefers runtime model over configured model', () => { it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({ const selection = resolveAgentModelSelection({
models: [ models: [
{ {
id: 'a', id: "a",
provider: 'codex', provider: "codex",
label: 'Model A', label: "Model A",
thinkingOptions: [{ id: 'low', label: 'Low' }], thinkingOptions: [{ id: "low", label: "Low" }],
defaultThinkingOptionId: 'low', defaultThinkingOptionId: "low",
}, },
], ],
runtimeModelId: 'a', runtimeModelId: "a",
configuredModelId: 'b', configuredModelId: "b",
explicitThinkingOptionId: null, explicitThinkingOptionId: null,
}) });
expect(selection.activeModelId).toBe('a') expect(selection.activeModelId).toBe("a");
expect(selection.displayModel).toBe('Model A') expect(selection.displayModel).toBe("Model A");
expect(selection.selectedThinkingId).toBe('low') expect(selection.selectedThinkingId).toBe("low");
}) });
it('uses explicit thinking option when provided', () => { it("uses explicit thinking option when provided", () => {
const selection = resolveAgentModelSelection({ const selection = resolveAgentModelSelection({
models: [ models: [
{ {
id: 'a', id: "a",
provider: 'codex', provider: "codex",
label: 'Model A', label: "Model A",
thinkingOptions: [ thinkingOptions: [
{ id: 'low', label: 'Low' }, { id: "low", label: "Low" },
{ id: 'high', label: 'High' }, { id: "high", label: "High" },
], ],
defaultThinkingOptionId: 'low', defaultThinkingOptionId: "low",
}, },
], ],
runtimeModelId: 'a', runtimeModelId: "a",
configuredModelId: null, configuredModelId: null,
explicitThinkingOptionId: 'high', explicitThinkingOptionId: "high",
}) });
expect(selection.selectedThinkingId).toBe('high') expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe('High') expect(selection.displayThinking).toBe("High");
}) });
}) });
+242 -230
View File
@@ -1,129 +1,130 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable } from 'react-native' import { View, Text, Platform, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
Brain, import { getProviderIcon } from "@/components/provider-icons";
ChevronDown, import { CombinedModelSelector } from "@/components/combined-model-selector";
ShieldAlert, import { useQuery } from "@tanstack/react-query";
ShieldCheck, import { useSessionStore } from "@/stores/session-store";
ShieldOff,
} from 'lucide-react-native'
import { getProviderIcon } from '@/components/provider-icons'
import { CombinedModelSelector } from '@/components/combined-model-selector'
import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox' import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet' import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { import type {
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentProvider, AgentProvider,
} from '@server/server/agent/agent-sdk-types' } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest' import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { import {
getModeVisuals, getModeVisuals,
type AgentModeColorTier, type AgentModeColorTier,
type AgentModeIcon, type AgentModeIcon,
} from '@server/server/agent/provider-manifest' } from "@server/server/agent/provider-manifest";
import { import {
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from '@/components/agent-status-bar.utils' } from "@/components/agent-status-bar.utils";
type StatusOption = { type StatusOption = {
id: string id: string;
label: string label: string;
} };
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking' type StatusSelector = "provider" | "mode" | "model" | "thinking";
type ControlledAgentStatusBarProps = { type ControlledAgentStatusBarProps = {
provider: string provider: string;
providerOptions?: StatusOption[] providerOptions?: StatusOption[];
selectedProviderId?: string selectedProviderId?: string;
onSelectProvider?: (providerId: string) => void onSelectProvider?: (providerId: string) => void;
modeOptions?: StatusOption[] modeOptions?: StatusOption[];
selectedModeId?: string selectedModeId?: string;
onSelectMode?: (modeId: string) => void onSelectMode?: (modeId: string) => void;
modelOptions?: StatusOption[] modelOptions?: StatusOption[];
selectedModelId?: string selectedModelId?: string;
onSelectModel?: (modelId: string) => void onSelectModel?: (modelId: string) => void;
thinkingOptions?: StatusOption[] thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string selectedThinkingOptionId?: string;
onSelectThinkingOption?: (thinkingOptionId: string) => void onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean disabled?: boolean;
isModelLoading?: boolean isModelLoading?: boolean;
} };
export interface DraftAgentStatusBarProps { export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[] providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider selectedProvider: AgentProvider;
onSelectProvider: (provider: AgentProvider) => void onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[] modeOptions: AgentMode[];
selectedMode: string selectedMode: string;
onSelectMode: (modeId: string) => void onSelectMode: (modeId: string) => void;
models: AgentModelDefinition[] models: AgentModelDefinition[];
selectedModel: string selectedModel: string;
onSelectModel: (modelId: string) => void onSelectModel: (modelId: string) => void;
isModelLoading: boolean isModelLoading: boolean;
allProviderModels: Map<string, AgentModelDefinition[]> allProviderModels: Map<string, AgentModelDefinition[]>;
isAllModelsLoading: boolean isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']> thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void onSelectThinkingOption: (thinkingOptionId: string) => void;
disabled?: boolean disabled?: boolean;
} }
interface AgentStatusBarProps { interface AgentStatusBarProps {
agentId: string agentId: string;
serverId: string serverId: string;
} }
function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) { function findOptionLabel(
options: StatusOption[] | undefined,
selectedId: string | undefined,
fallback: string,
) {
if (!options || options.length === 0) { if (!options || options.length === 0) {
return fallback return fallback;
} }
const selected = options.find((option) => option.id === selectedId) const selected = options.find((option) => option.id === selectedId);
return selected?.label ?? fallback return selected?.label ?? fallback;
} }
const MODE_ICONS = { const MODE_ICONS = {
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
ShieldOff, ShieldOff,
} as const } as const;
function getModeIconColor( function getModeIconColor(
colorTier: AgentModeColorTier | undefined, colorTier: AgentModeColorTier | undefined,
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } } palette: {
blue: { 500: string };
green: { 500: string };
amber: { 500: string };
red: { 500: string };
purple: { 500: string };
},
): string { ): string {
switch (colorTier) { switch (colorTier) {
case 'default': case "default":
return palette.blue[500] return palette.blue[500];
case 'safe': case "safe":
return palette.green[500] return palette.green[500];
case 'moderate': case "moderate":
return palette.amber[500] return palette.amber[500];
case 'dangerous': case "dangerous":
return palette.red[500] return palette.red[500];
case 'readonly': case "readonly":
return palette.purple[500] return palette.purple[500];
default: default:
return palette.blue[500] return palette.blue[500];
} }
} }
function ControlledStatusBar({ function ControlledStatusBar({
provider, provider,
providerOptions, providerOptions,
@@ -141,71 +142,83 @@ function ControlledStatusBar({
disabled = false, disabled = false,
isModelLoading = false, isModelLoading = false,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
const isWeb = Platform.OS === 'web' const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false) const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null) const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null) const providerAnchorRef = useRef<View>(null);
const modeAnchorRef = useRef<View>(null) const modeAnchorRef = useRef<View>(null);
const modelAnchorRef = useRef<View>(null) const modelAnchorRef = useRef<View>(null);
const thinkingAnchorRef = useRef<View>(null) const thinkingAnchorRef = useRef<View>(null);
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0) const canSelectProvider = Boolean(
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0) onSelectProvider && providerOptions && providerOptions.length > 0,
const canSelectModel = Boolean(onSelectModel) );
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0);
const canSelectModel = Boolean(onSelectModel);
const canSelectThinking = Boolean( const canSelectThinking = Boolean(
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0 onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
) );
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider') const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default') const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel = const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0) isModelLoading && (!modelOptions || modelOptions.length === 0)
? 'Loading models...' ? "Loading models..."
: findOptionLabel(modelOptions, selectedModelId, 'Auto') : findOptionLabel(modelOptions, selectedModelId, "Auto");
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto') const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette) const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider) const ProviderIcon = getProviderIcon(provider);
const hasAnyControl = const hasAnyControl =
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) || Boolean(modeOptions?.length) ||
canSelectModel || canSelectModel ||
Boolean(thinkingOptions?.length) Boolean(thinkingOptions?.length);
if (!hasAnyControl) { if (!hasAnyControl) {
return null return null;
} }
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0 const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
const SEARCH_THRESHOLD = 6 const SEARCH_THRESHOLD = 6;
const comboboxProviderOptions = useMemo<ComboboxOption[]>( const comboboxProviderOptions = useMemo<ComboboxOption[]>(
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })), () => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[providerOptions] [providerOptions],
) );
const comboboxModeOptions = useMemo<ComboboxOption[]>( const comboboxModeOptions = useMemo<ComboboxOption[]>(
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })), () => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modeOptions] [modeOptions],
) );
const comboboxModelOptions = useMemo<ComboboxOption[]>( const comboboxModelOptions = useMemo<ComboboxOption[]>(
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })), () => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions] [modelOptions],
) );
const comboboxThinkingOptions = useMemo<ComboboxOption[]>( const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })), () => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions] [thinkingOptions],
) );
const renderModeOption = useCallback( const renderModeOption = useCallback(
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => { ({
const visuals = getModeVisuals(provider, option.id) option,
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const visuals = getModeVisuals(provider, option.id);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return ( return (
<ComboboxItem <ComboboxItem
label={option.label} label={option.label}
@@ -214,24 +227,24 @@ function ControlledStatusBar({
onPress={onPress} onPress={onPress}
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />} leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
/> />
) );
}, },
[provider, theme.colors.foreground] [provider, theme.colors.foreground],
) );
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => { (selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null) setOpenSelector(nextOpen ? selector : null);
}, },
[] [],
) );
const handleSelectorPress = useCallback( const handleSelectorPress = useCallback(
(selector: StatusSelector) => { (selector: StatusSelector) => {
handleOpenChange(selector)(openSelector !== selector) handleOpenChange(selector)(openSelector !== selector);
}, },
[handleOpenChange, openSelector] [handleOpenChange, openSelector],
) );
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -243,11 +256,11 @@ function ControlledStatusBar({
ref={providerAnchorRef} ref={providerAnchorRef}
collapsable={false} collapsable={false}
disabled={disabled || !canSelectProvider} disabled={disabled || !canSelectProvider}
onPress={() => handleSelectorPress('provider')} onPress={() => handleSelectorPress("provider")}
style={({ pressed, hovered }) => [ style={({ pressed, hovered }) => [
styles.modeBadge, styles.modeBadge,
hovered && styles.modeBadgeHovered, hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'provider') && styles.modeBadgePressed, (pressed || openSelector === "provider") && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge, (disabled || !canSelectProvider) && styles.disabledBadge,
]} ]}
accessibilityRole="button" accessibilityRole="button"
@@ -259,11 +272,11 @@ function ControlledStatusBar({
</Pressable> </Pressable>
<Combobox <Combobox
options={comboboxProviderOptions} options={comboboxProviderOptions}
value={selectedProviderId ?? ''} value={selectedProviderId ?? ""}
onSelect={(id) => onSelectProvider?.(id)} onSelect={(id) => onSelectProvider?.(id)}
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD} searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'provider'} open={openSelector === "provider"}
onOpenChange={handleOpenChange('provider')} onOpenChange={handleOpenChange("provider")}
anchorRef={providerAnchorRef} anchorRef={providerAnchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
/> />
@@ -273,7 +286,7 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? ( {modeOptions && modeOptions.length > 0 ? (
<> <>
<Tooltip <Tooltip
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`} key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0} delayDuration={0}
enabledOnDesktop enabledOnDesktop
enabledOnMobile={false} enabledOnMobile={false}
@@ -283,11 +296,11 @@ function ControlledStatusBar({
ref={modeAnchorRef} ref={modeAnchorRef}
collapsable={false} collapsable={false}
disabled={disabled || !canSelectMode} disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress('mode')} onPress={() => handleSelectorPress("mode")}
style={({ pressed, hovered }) => [ style={({ pressed, hovered }) => [
styles.modeIconBadge, styles.modeIconBadge,
hovered && styles.modeBadgeHovered, hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed, (pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge, (disabled || !canSelectMode) && styles.disabledBadge,
]} ]}
accessibilityRole="button" accessibilityRole="button"
@@ -302,16 +315,16 @@ function ControlledStatusBar({
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text> <Text style={styles.tooltipText}>{getStatusSelectorHint("mode")}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Combobox <Combobox
options={comboboxModeOptions} options={comboboxModeOptions}
value={selectedModeId ?? ''} value={selectedModeId ?? ""}
onSelect={(id) => onSelectMode?.(id)} onSelect={(id) => onSelectMode?.(id)}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD} searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'mode'} open={openSelector === "mode"}
onOpenChange={handleOpenChange('mode')} onOpenChange={handleOpenChange("mode")}
anchorRef={modeAnchorRef} anchorRef={modeAnchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
renderOption={renderModeOption} renderOption={renderModeOption}
@@ -322,7 +335,7 @@ function ControlledStatusBar({
{canSelectModel ? ( {canSelectModel ? (
<> <>
<Tooltip <Tooltip
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`} key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0} delayDuration={0}
enabledOnDesktop enabledOnDesktop
enabledOnMobile={false} enabledOnMobile={false}
@@ -332,11 +345,11 @@ function ControlledStatusBar({
ref={modelAnchorRef} ref={modelAnchorRef}
collapsable={false} collapsable={false}
disabled={modelDisabled} disabled={modelDisabled}
onPress={() => handleSelectorPress('model')} onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [ style={({ pressed, hovered }) => [
styles.modeBadge, styles.modeBadge,
hovered && styles.modeBadgeHovered, hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'model') && styles.modeBadgePressed, (pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge, modelDisabled && styles.disabledBadge,
]} ]}
accessibilityRole="button" accessibilityRole="button"
@@ -349,16 +362,16 @@ function ControlledStatusBar({
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text> <Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Combobox <Combobox
options={comboboxModelOptions} options={comboboxModelOptions}
value={selectedModelId ?? ''} value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)} onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'model'} open={openSelector === "model"}
onOpenChange={handleOpenChange('model')} onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef} anchorRef={modelAnchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
/> />
@@ -368,7 +381,7 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? ( {thinkingOptions && thinkingOptions.length > 0 ? (
<> <>
<Tooltip <Tooltip
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`} key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
delayDuration={0} delayDuration={0}
enabledOnDesktop enabledOnDesktop
enabledOnMobile={false} enabledOnMobile={false}
@@ -378,11 +391,11 @@ function ControlledStatusBar({
ref={thinkingAnchorRef} ref={thinkingAnchorRef}
collapsable={false} collapsable={false}
disabled={disabled || !canSelectThinking} disabled={disabled || !canSelectThinking}
onPress={() => handleSelectorPress('thinking')} onPress={() => handleSelectorPress("thinking")}
style={({ pressed, hovered }) => [ style={({ pressed, hovered }) => [
styles.modeBadge, styles.modeBadge,
hovered && styles.modeBadgeHovered, hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'thinking') && styles.modeBadgePressed, (pressed || openSelector === "thinking") && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge, (disabled || !canSelectThinking) && styles.disabledBadge,
]} ]}
accessibilityRole="button" accessibilityRole="button"
@@ -395,16 +408,16 @@ function ControlledStatusBar({
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text> <Text style={styles.tooltipText}>{getStatusSelectorHint("thinking")}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Combobox <Combobox
options={comboboxThinkingOptions} options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''} value={selectedThinkingOptionId ?? ""}
onSelect={(id) => onSelectThinkingOption?.(id)} onSelect={(id) => onSelectThinkingOption?.(id)}
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD} searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'thinking'} open={openSelector === "thinking"}
onOpenChange={handleOpenChange('thinking')} onOpenChange={handleOpenChange("thinking")}
anchorRef={thinkingAnchorRef} anchorRef={thinkingAnchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
/> />
@@ -415,16 +428,15 @@ function ControlledStatusBar({
<> <>
<Pressable <Pressable
onPress={() => setPrefsOpen(true)} onPress={() => setPrefsOpen(true)}
style={({ pressed }) => [ style={({ pressed }) => [styles.prefsButton, pressed && styles.prefsButtonPressed]}
styles.prefsButton,
pressed && styles.prefsButtonPressed,
]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Agent preferences" accessibilityLabel="Agent preferences"
testID="agent-preferences-button" testID="agent-preferences-button"
> >
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> <ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
<Text style={styles.prefsButtonText} numberOfLines={1}>{displayModel}</Text> <Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel}
</Text>
</Pressable> </Pressable>
<AdaptiveModalSheet <AdaptiveModalSheet
@@ -436,8 +448,8 @@ function ControlledStatusBar({
{providerOptions && providerOptions.length > 0 ? ( {providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu <DropdownMenu
open={openSelector === 'provider'} open={openSelector === "provider"}
onOpenChange={handleOpenChange('provider')} onOpenChange={handleOpenChange("provider")}
> >
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectProvider} disabled={disabled || !canSelectProvider}
@@ -471,8 +483,8 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? ( {modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu <DropdownMenu
open={openSelector === 'mode'} open={openSelector === "mode"}
onOpenChange={handleOpenChange('mode')} onOpenChange={handleOpenChange("mode")}
> >
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectMode} disabled={disabled || !canSelectMode}
@@ -493,8 +505,8 @@ function ControlledStatusBar({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => { {modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id) const visuals = getModeVisuals(provider, mode.id);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={mode.id} key={mode.id}
@@ -504,7 +516,7 @@ function ControlledStatusBar({
> >
{mode.label} {mode.label}
</DropdownMenuItem> </DropdownMenuItem>
) );
})} })}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -514,8 +526,8 @@ function ControlledStatusBar({
{canSelectModel ? ( {canSelectModel ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu <DropdownMenu
open={openSelector === 'model'} open={openSelector === "model"}
onOpenChange={handleOpenChange('model')} onOpenChange={handleOpenChange("model")}
> >
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={modelDisabled} disabled={modelDisabled}
@@ -549,8 +561,8 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? ( {thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu <DropdownMenu
open={openSelector === 'thinking'} open={openSelector === "thinking"}
onOpenChange={handleOpenChange('thinking')} onOpenChange={handleOpenChange("thinking")}
> >
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectThinking} disabled={disabled || !canSelectThinking}
@@ -584,68 +596,68 @@ function ControlledStatusBar({
</> </>
)} )}
</View> </View>
) );
} }
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({ const modelsQuery = useQuery({
queryKey: [ queryKey: [
'providerModels', "providerModels",
serverId, serverId,
agent?.provider ?? '__missing_provider__', agent?.provider ?? "__missing_provider__",
agent?.cwd ?? '__missing_cwd__', agent?.cwd ?? "__missing_cwd__",
], ],
enabled: Boolean(client && agent?.provider), enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
queryFn: async () => { queryFn: async () => {
if (!client || !agent) { if (!client || !agent) {
throw new Error('Daemon client unavailable') throw new Error("Daemon client unavailable");
} }
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }) const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) { if (payload.error) {
throw new Error(payload.error) throw new Error(payload.error);
} }
return payload.models ?? [] return payload.models ?? [];
}, },
}) });
const models = modelsQuery.data ?? null const models = modelsQuery.data ?? null;
const displayMode = const displayMode =
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label || agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
agent?.currentModeId || agent?.currentModeId ||
'default' "default";
const modelSelection = resolveAgentModelSelection({ const modelSelection = resolveAgentModelSelection({
models, models,
runtimeModelId: agent?.runtimeInfo?.model, runtimeModelId: agent?.runtimeInfo?.model,
configuredModelId: agent?.model, configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId, explicitThinkingOptionId: agent?.thinkingOptionId,
}) });
const modeOptions = useMemo<StatusOption[]>(() => { const modeOptions = useMemo<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({ return (agent?.availableModes ?? []).map((mode) => ({
id: mode.id, id: mode.id,
label: mode.label, label: mode.label,
})) }));
}, [agent?.availableModes]) }, [agent?.availableModes]);
const modelOptions = useMemo<StatusOption[]>(() => { const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label })) return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]) }, [models]);
const thinkingOptions = useMemo<StatusOption[]>(() => { const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({ return (modelSelection.thinkingOptions ?? []).map((option) => ({
id: option.id, id: option.id,
label: option.label, label: option.label,
})) }));
}, [modelSelection.thinkingOptions]) }, [modelSelection.thinkingOptions]);
if (!agent) { if (!agent) {
return null return null;
} }
return ( return (
@@ -654,41 +666,41 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions={ modeOptions={
modeOptions.length > 0 modeOptions.length > 0
? modeOptions ? modeOptions
: [{ id: agent.currentModeId ?? '', label: displayMode }] : [{ id: agent.currentModeId ?? "", label: displayMode }]
} }
selectedModeId={agent.currentModeId ?? undefined} selectedModeId={agent.currentModeId ?? undefined}
onSelectMode={(modeId) => { onSelectMode={(modeId) => {
if (!client) { if (!client) {
return return;
} }
void client.setAgentMode(agentId, modeId).catch((error) => { void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn('[AgentStatusBar] setAgentMode failed', error) console.warn("[AgentStatusBar] setAgentMode failed", error);
}) });
}} }}
modelOptions={modelOptions} modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined} selectedModelId={modelSelection.activeModelId ?? undefined}
onSelectModel={(modelId) => { onSelectModel={(modelId) => {
if (!client) { if (!client) {
return return;
} }
void client.setAgentModel(agentId, modelId).catch((error) => { void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn('[AgentStatusBar] setAgentModel failed', error) console.warn("[AgentStatusBar] setAgentModel failed", error);
}) });
}} }}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => { onSelectThinkingOption={(thinkingOptionId) => {
if (!client) { if (!client) {
return return;
} }
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn('[AgentStatusBar] setAgentThinkingOption failed', error) console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
}) });
}} }}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching} isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client} disabled={!client}
/> />
) );
} }
export function DraftAgentStatusBar({ export function DraftAgentStatusBar({
@@ -710,25 +722,25 @@ export function DraftAgentStatusBar({
onSelectThinkingOption, onSelectThinkingOption,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === 'web' const isWeb = Platform.OS === "web";
const mappedModeOptions = useMemo<StatusOption[]>(() => { const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) { if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }] return [{ id: "", label: "Default" }];
} }
return modeOptions.map((mode) => ({ return modeOptions.map((mode) => ({
id: mode.id, id: mode.id,
label: mode.label, label: mode.label,
})) }));
}, [modeOptions]) }, [modeOptions]);
const mappedThinkingOptions = useMemo<StatusOption[]>(() => { const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label })) return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]) }, [thinkingOptions]);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || '' const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) { if (isWeb) {
return ( return (
@@ -753,17 +765,17 @@ export function DraftAgentStatusBar({
disabled={disabled} disabled={disabled}
/> />
</View> </View>
) );
} }
const providerOptions = providerDefinitions.map((definition) => ({ const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id, id: definition.id,
label: definition.label, label: definition.label,
})) }));
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }] const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
for (const model of models) { for (const model of models) {
modelOptions.push({ id: model.id, label: model.label }) modelOptions.push({ id: model.id, label: model.label });
} }
return ( return (
@@ -784,30 +796,30 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption} onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled} disabled={disabled}
/> />
) );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'flex-end', alignItems: "flex-end",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
modeBadge: { modeBadge: {
height: 28, height: 28,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
backgroundColor: 'transparent', backgroundColor: "transparent",
gap: theme.spacing[1], gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'], borderRadius: theme.borderRadius["2xl"],
}, },
modeIconBadge: { modeIconBadge: {
width: 28, width: 28,
height: 28, height: 28,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
backgroundColor: 'transparent', backgroundColor: "transparent",
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
}, },
modeBadgeHovered: { modeBadgeHovered: {
@@ -831,11 +843,11 @@ const styles = StyleSheet.create((theme) => ({
}, },
prefsButton: { prefsButton: {
height: 28, height: 28,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'], borderRadius: theme.borderRadius["2xl"],
}, },
prefsButtonPressed: { prefsButtonPressed: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
@@ -850,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
sheetSelect: { sheetSelect: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
gap: theme.spacing[3], gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
@@ -873,4 +885,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
})) }));
@@ -1,51 +1,54 @@
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types' import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking' export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string { export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) { switch (selector) {
case 'thinking': case "thinking":
return 'Thinking mode' return "Thinking mode";
case 'model': case "model":
return 'Change model' return "Change model";
case 'mode': case "mode":
return 'Change permission mode' return "Change permission mode";
} }
} }
export function normalizeModelId(modelId: string | null | undefined): string | null { export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : '' const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === 'default') { if (!normalized || normalized.toLowerCase() === "default") {
return null return null;
} }
return normalized return normalized;
} }
export function resolveAgentModelSelection(input: { export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined configuredModelId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined explicitThinkingOptionId: string | null | undefined;
}) { }) {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId) const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId) const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel = const selectedModel =
models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? null)
: null;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto' const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId = const selectedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== 'default' explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId ? explicitThinkingOptionId
: selectedModel?.defaultThinkingOptionId ?? null : (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null const selectedThinking =
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
const displayThinking = const displayThinking =
selectedThinking?.label ?? selectedThinking?.label ??
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto') (selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
return { return {
selectedModel, selectedModel,
@@ -54,5 +57,5 @@ export function resolveAgentModelSelection(input: {
thinkingOptions, thinkingOptions,
selectedThinkingId, selectedThinkingId,
displayThinking, displayThinking,
} };
} }
@@ -128,7 +128,7 @@ function splitOrderedTail(params: {
} }
export function buildAgentStreamRenderModel( export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel { ): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({ const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native", platform: input.platform === "web" ? "web" : "native",
@@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({ resolveBottomAnchorTransportBehavior({
strategy, strategy,
isViewportSettling: true, isViewportSettling: true,
}) }),
).toEqual({ ).toEqual({
verificationDelayFrames: 4, verificationDelayFrames: 4,
verificationRetryMode: "recheck", verificationRetryMode: "recheck",
@@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({ resolveBottomAnchorTransportBehavior({
strategy, strategy,
isViewportSettling: true, isViewportSettling: true,
}) }),
).toEqual({ ).toEqual({
verificationDelayFrames: 0, verificationDelayFrames: 0,
verificationRetryMode: "rescroll", verificationRetryMode: "rescroll",
@@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => {
strategy: forward, strategy: forward,
items: chronological, items: chronological,
startIndex: forwardStartIndex, startIndex: forwardStartIndex,
}) }),
).toBe("assistant-1\n\nassistant-2"); ).toBe("assistant-1\n\nassistant-2");
const inverted = resolveStreamRenderStrategy({ const inverted = resolveStreamRenderStrategy({
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
strategy: inverted, strategy: inverted,
items: invertedItems, items: invertedItems,
startIndex: invertedStartIndex, startIndex: invertedStartIndex,
}) }),
).toBe("assistant-1\n\nassistant-2"); ).toBe("assistant-1\n\nassistant-2");
}); });
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
items, items,
index: 0, index: 0,
relation: "above", relation: "above",
}) }),
).toBeUndefined(); ).toBeUndefined();
expect( expect(
getStreamNeighborItem({ getStreamNeighborItem({
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
items, items,
index: 0, index: 0,
relation: "below", relation: "below",
}) }),
).toBeUndefined(); ).toBeUndefined();
}); });
}); });
@@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300, viewportHeight: 300,
contentHeight: 1000, contentHeight: 1000,
threshold: 24, threshold: 24,
}) }),
).toBe(true); ).toBe(true);
expect( expect(
isNearBottomForStreamRenderStrategy({ isNearBottomForStreamRenderStrategy({
@@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300, viewportHeight: 300,
contentHeight: 1000, contentHeight: 1000,
threshold: 24, threshold: 24,
}) }),
).toBe(false); ).toBe(false);
}); });
@@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300, viewportHeight: 300,
contentHeight: 1000, contentHeight: 1000,
threshold: 24, threshold: 24,
}) }),
).toBe(true); ).toBe(true);
expect( expect(
isNearBottomForStreamRenderStrategy({ isNearBottomForStreamRenderStrategy({
@@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300, viewportHeight: 300,
contentHeight: 1000, contentHeight: 1000,
threshold: 24, threshold: 24,
}) }),
).toBe(false); ).toBe(false);
expect( expect(
getBottomOffsetForStreamRenderStrategy({ getBottomOffsetForStreamRenderStrategy({
strategy, strategy,
viewportHeight: 300, viewportHeight: 300,
contentHeight: 1000, contentHeight: 1000,
}) }),
).toBe(0); ).toBe(0);
}); });
@@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => {
strategy, strategy,
viewportHeight: 320, viewportHeight: 320,
contentHeight: 1000, contentHeight: 1000,
}) }),
).toBe(680); ).toBe(680);
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({ findMountedWindowStart({
items, items,
minMountedCount: 50, minMountedCount: 50,
}) }),
).toBe(0); ).toBe(0);
}); });
@@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({ findMountedWindowStart({
items, items,
minMountedCount: 50, minMountedCount: 50,
}) }),
).toBe(39); ).toBe(39);
}); });
}); });
@@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown; __PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
}; };
const previousThreshold = const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
const previousMounted =
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try { try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe( expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
expect(getWebMountedRecentStreamItems()).toBe(
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
); );
expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6; globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4; globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
@@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => {
if (previousThreshold === undefined) { if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else { } else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold;
previousThreshold;
} }
if (previousMounted === undefined) { if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else { } else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
previousMounted;
} }
} }
}); });
@@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null {
export function getWebPartialVirtualizationThreshold(): number { export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride( const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals) (globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
); );
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} }
export function getWebMountedRecentStreamItems(): number { export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride( const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals) (globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
); );
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS; return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} }
@@ -1,35 +1,35 @@
import { useState } from 'react' import { useState } from "react";
import { View, Text } from 'react-native' import { View, Text } from "react-native";
import { StyleSheet } from 'react-native-unistyles' import { StyleSheet } from "react-native-unistyles";
import Animated from 'react-native-reanimated' import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import type { Theme } from '@/styles/theme' import type { Theme } from "@/styles/theme";
interface ArchivedAgentCalloutProps { interface ArchivedAgentCalloutProps {
serverId: string serverId: string;
agentId: string agentId: string;
} }
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) { export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId) const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId) const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false) const [isUnarchiving, setIsUnarchiving] = useState(false);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' }) const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
async function handleUnarchive() { async function handleUnarchive() {
if (!client || !isConnected || isUnarchiving) return if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true) setIsUnarchiving(true);
try { try {
await client.refreshAgent(agentId) await client.refreshAgent(agentId);
} catch (error) { } catch (error) {
console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error) console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false) setIsUnarchiving(false);
} }
} }
@@ -53,36 +53,36 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
) );
} }
const styles = StyleSheet.create(((theme: Theme) => ({ const styles = StyleSheet.create(((theme: Theme) => ({
container: { container: {
flexDirection: 'column', flexDirection: "column",
position: 'relative', position: "relative",
}, },
inputAreaContainer: { inputAreaContainer: {
position: 'relative', position: "relative",
minHeight: FOOTER_HEIGHT, minHeight: FOOTER_HEIGHT,
marginHorizontal: 'auto', marginHorizontal: "auto",
alignItems: 'center', alignItems: "center",
width: '100%', width: "100%",
overflow: 'visible', overflow: "visible",
padding: theme.spacing[4], padding: theme.spacing[4],
}, },
inputAreaContent: { inputAreaContent: {
width: '100%', width: "100%",
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
}, },
callout: { callout: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
gap: theme.spacing[3], gap: theme.spacing[3],
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius['2xl'], borderRadius: theme.borderRadius["2xl"],
paddingVertical: { paddingVertical: {
xs: theme.spacing[3], xs: theme.spacing[3],
md: theme.spacing[4], md: theme.spacing[4],
@@ -96,4 +96,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
})) as any) as Record<string, any> })) as any) as Record<string, any>;
@@ -1,10 +1,4 @@
import { import { View, Text, ScrollView, Pressable, Modal } from "react-native";
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
@@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</Text> </Text>
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<View <View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
style={[ <Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
</View> </View>
<Pressable onPress={onClose} style={styles.closeButton}> <Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text> <Text style={styles.closeButtonText}>×</Text>
@@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
> >
{artifact.type === "image" ? ( {artifact.type === "image" ? (
<View style={styles.imagePlaceholder}> <View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}> <Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
Image viewing not yet implemented <Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
</Text>
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
</View> </View>
) : ( ) : (
<View style={styles.codeContainer}> <View style={styles.codeContainer}>
@@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View> </View>
<View style={styles.metadataRow}> <View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text> <Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}> <Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
{content.length.toLocaleString()} characters
</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`; return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
} }
export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: AudioDebugNoticeProps) { export function AudioDebugNotice({
info,
onDismiss,
title = "Dictation Debug",
}: AudioDebugNoticeProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
</View> </View>
{info.debugRecordingPath ? ( {info.debugRecordingPath ? (
<Pressable style={styles.pathRow} onPress={handleCopyPath} accessibilityLabel="Copy raw audio path"> <Pressable
<Text style={styles.pathRow}
numberOfLines={2} onPress={handleCopyPath}
style={[styles.pathText, { color: theme.colors.foreground }]} accessibilityLabel="Copy raw audio path"
> >
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
{info.debugRecordingPath} {info.debugRecordingPath}
</Text> </Text>
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}> <View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
@@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
)} )}
{stats ? ( {stats ? (
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}> <Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
{stats}
</Text>
) : null} ) : null}
</View> </View>
); );
@@ -1,24 +1,24 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform } from 'react-native' import { View, Text, Pressable, Platform } from "react-native";
import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native' import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types' import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest' import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox' import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { getProviderIcon } from '@/components/provider-icons' import { getProviderIcon } from "@/components/provider-icons";
const INLINE_MODEL_THRESHOLD = 8 const INLINE_MODEL_THRESHOLD = 8;
type DrillDownView = { provider: string } type DrillDownView = { provider: string };
interface CombinedModelSelectorProps { interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[] providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]> allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string selectedProvider: string;
selectedModel: string selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean isLoading: boolean;
disabled?: boolean disabled?: boolean;
} }
export function CombinedModelSelector({ export function CombinedModelSelector({
@@ -30,43 +30,46 @@ export function CombinedModelSelector({
isLoading, isLoading,
disabled = false, disabled = false,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
const anchorRef = useRef<View>(null) const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<'groups' | DrillDownView>('groups') const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState("");
const handleOpenChange = useCallback((open: boolean) => { const handleOpenChange = useCallback(
setIsOpen(open) (open: boolean) => {
if (open) { setIsOpen(open);
const models = allProviderModels.get(selectedProvider) if (open) {
if (models && models.length > INLINE_MODEL_THRESHOLD) { const models = allProviderModels.get(selectedProvider);
setView({ provider: selectedProvider }) if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider });
}
} else {
setView("groups");
setSearchQuery("");
} }
} else { },
setView('groups') [allProviderModels, selectedProvider],
setSearchQuery('') );
}
}, [allProviderModels, selectedProvider])
const handleSelect = useCallback( const handleSelect = useCallback(
(provider: string, modelId: string) => { (provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId) onSelect(provider as AgentProvider, modelId);
setIsOpen(false) setIsOpen(false);
setView('groups') setView("groups");
setSearchQuery('') setSearchQuery("");
}, },
[onSelect] [onSelect],
) );
const ProviderIcon = getProviderIcon(selectedProvider) const ProviderIcon = getProviderIcon(selectedProvider);
const selectedModelLabel = useMemo(() => { const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider) const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? 'Loading...' : 'Auto' if (!models) return isLoading ? "Loading..." : "Auto";
const model = models.find((m) => m.id === selectedModel) const model = models.find((m) => m.id === selectedModel);
return model?.label ?? 'Auto' return model?.label ?? "Auto";
}, [allProviderModels, selectedProvider, selectedModel, isLoading]) }, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return ( return (
<> <>
@@ -99,7 +102,7 @@ export function CombinedModelSelector({
desktopPlacement="top-start" desktopPlacement="top-start"
title="Select model" title="Select model"
> >
{view === 'groups' ? ( {view === "groups" ? (
<GroupsView <GroupsView
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels} allProviderModels={allProviderModels}
@@ -107,8 +110,8 @@ export function CombinedModelSelector({
selectedModel={selectedModel} selectedModel={selectedModel}
onSelect={handleSelect} onSelect={handleSelect}
onDrillDown={(provider) => { onDrillDown={(provider) => {
setView({ provider }) setView({ provider });
setSearchQuery('') setSearchQuery("");
}} }}
/> />
) : ( ) : (
@@ -122,14 +125,14 @@ export function CombinedModelSelector({
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSelect={handleSelect} onSelect={handleSelect}
onBack={() => { onBack={() => {
setView('groups') setView("groups");
setSearchQuery('') setSearchQuery("");
}} }}
/> />
)} )}
</Combobox> </Combobox>
</> </>
) );
} }
function GroupsView({ function GroupsView({
@@ -140,21 +143,21 @@ function GroupsView({
onSelect, onSelect,
onDrillDown, onDrillDown,
}: { }: {
providerDefinitions: AgentProviderDefinition[] providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]> allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string selectedProvider: string;
selectedModel: string selectedModel: string;
onSelect: (provider: string, modelId: string) => void onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void onDrillDown: (provider: string) => void;
}) { }) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
return ( return (
<View> <View>
{providerDefinitions.map((def, index) => { {providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? [] const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id) const ProvIcon = getProviderIcon(def.id);
return ( return (
<View key={def.id}> <View key={def.id}>
@@ -193,10 +196,10 @@ function GroupsView({
</Pressable> </Pressable>
)} )}
</View> </View>
) );
})} })}
</View> </View>
) );
} }
function DrillDownModelView({ function DrillDownModelView({
@@ -210,27 +213,27 @@ function DrillDownModelView({
onSelect, onSelect,
onBack, onBack,
}: { }: {
provider: string provider: string;
providerDefinitions: AgentProviderDefinition[] providerDefinitions: AgentProviderDefinition[];
models: AgentModelDefinition[] models: AgentModelDefinition[];
selectedProvider: string selectedProvider: string;
selectedModel: string selectedModel: string;
searchQuery: string searchQuery: string;
onSearchChange: (query: string) => void onSearchChange: (query: string) => void;
onSelect: (provider: string, modelId: string) => void onSelect: (provider: string, modelId: string) => void;
onBack: () => void onBack: () => void;
}) { }) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider) const ProvIcon = getProviderIcon(provider);
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase() const q = searchQuery.toLowerCase();
return models.filter( return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) (m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
) );
}, [models, searchQuery]) }, [models, searchQuery]);
return ( return (
<View> <View>
@@ -251,7 +254,7 @@ function DrillDownModelView({
placeholder="Search models..." placeholder="Search models..."
value={searchQuery} value={searchQuery}
onChangeText={onSearchChange} onChangeText={onSearchChange}
autoFocus={Platform.OS === 'web'} autoFocus={Platform.OS === "web"}
/> />
{filteredModels.map((model) => ( {filteredModels.map((model) => (
@@ -270,18 +273,18 @@ function DrillDownModelView({
</View> </View>
) : null} ) : null}
</View> </View>
) );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
trigger: { trigger: {
height: 28, height: 28,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
backgroundColor: 'transparent', backgroundColor: "transparent",
gap: theme.spacing[1], gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'], borderRadius: theme.borderRadius["2xl"],
}, },
triggerHovered: { triggerHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -303,8 +306,8 @@ const styles = StyleSheet.create((theme) => ({
marginVertical: theme.spacing[1], marginVertical: theme.spacing[1],
}, },
sectionHeading: { sectionHeading: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
@@ -315,8 +318,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
drillDownRow: { drillDownRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
@@ -334,8 +337,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
drillDownTrailing: { drillDownTrailing: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
drillDownCount: { drillDownCount: {
@@ -343,8 +346,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
backButton: { backButton: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
@@ -363,10 +366,10 @@ const styles = StyleSheet.create((theme) => ({
}, },
emptyState: { emptyState: {
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
alignItems: 'center', alignItems: "center",
}, },
emptyStateText: { emptyStateText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
})) }));
+17 -40
View File
@@ -1,12 +1,4 @@
import { import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react"; import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native"; import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -54,16 +46,8 @@ const CommandCenterRow = memo(function CommandCenterRow({
export function CommandCenter() { export function CommandCenter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
open, useCommandCenter();
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
} = useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map()); const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null); const resultsRef = useRef<ScrollView>(null);
@@ -77,11 +61,13 @@ export function CommandCenter() {
return; return;
} }
const scrollNode = const scrollNode =
(resultsRef.current as (
| (ScrollView & { resultsRef.current as
getScrollableNode?: () => HTMLElement | null; | (ScrollView & {
}) getScrollableNode?: () => HTMLElement | null;
| null)?.getScrollableNode?.() ?? null; })
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement; const rowEl = row as unknown as HTMLElement;
if (!scrollNode) { if (!scrollNode) {
@@ -110,18 +96,16 @@ export function CommandCenter() {
const agentItems = items.filter((item) => item.kind === "agent"); const agentItems = items.filter((item) => item.kind === "agent");
return ( return (
<Modal <Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<View style={styles.overlay}> <View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} /> <Pressable style={styles.backdrop} onPress={handleClose} />
<View <View
testID="command-center-panel" testID="command-center-panel"
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]} style={[
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
]}
> >
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}> <View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput <TextInput
@@ -131,10 +115,7 @@ export function CommandCenter() {
onChangeText={setQuery} onChangeText={setQuery}
placeholder="Type a command or search agents..." placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
style={[ style={[styles.input, { color: theme.colors.foreground }]}
styles.input,
{ color: theme.colors.foreground },
]}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
autoFocus autoFocus
@@ -164,11 +145,7 @@ export function CommandCenter() {
const action = item.action; const action = item.action;
const actionIcon = const actionIcon =
action.icon === "plus" ? ( action.icon === "plus" ? (
<Plus <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
size={16}
strokeWidth={2.4}
color={theme.colors.foregroundMuted}
/>
) : action.icon === "settings" ? ( ) : action.icon === "settings" ? (
<Settings <Settings
size={16} size={16}
@@ -40,18 +40,8 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.row}> <View style={styles.row}>
<View <View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
style={[ <Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
styles.dot,
isConnected ? styles.dotConnected : styles.dotDisconnected,
]}
/>
<Text
style={[
styles.text,
isConnected ? styles.textConnected : styles.textDisconnected,
]}
>
{isConnected ? "Connected" : "Disconnected"} {isConnected ? "Connected" : "Disconnected"}
</Text> </Text>
</View> </View>
@@ -24,9 +24,7 @@ interface DictationControlsProps {
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = seconds % 60; const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
.toString()
.padStart(2, "0")}`;
} }
export function DictationControls({ export function DictationControls({
@@ -66,12 +64,7 @@ export function DictationControls({
return ( return (
<View style={styles.activeContainer}> <View style={styles.activeContainer}>
<View style={styles.meterWrapper}> <View style={styles.meterWrapper}>
<VolumeMeter <VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
/>
</View> </View>
<Text style={[styles.timerText, { color: theme.colors.foreground }]}> <Text style={[styles.timerText, { color: theme.colors.foreground }]}>
{formatDuration(duration)} {formatDuration(duration)}
@@ -152,12 +145,7 @@ export function DictationOverlay({
} }
return ( return (
<View <View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
style={[
overlayStyles.container,
{ backgroundColor: theme.colors.accent },
]}
>
<Pressable <Pressable
onPress={handleCancel} onPress={handleCancel}
disabled={actionsDisabled && !isFailed} disabled={actionsDisabled && !isFailed}
@@ -180,12 +168,7 @@ export function DictationOverlay({
orientation="horizontal" orientation="horizontal"
color={theme.colors.palette.white} color={theme.colors.palette.white}
/> />
<Text <Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
style={[
overlayStyles.timerText,
{ color: theme.colors.palette.white },
]}
>
{formatDuration(duration)} {formatDuration(duration)}
</Text> </Text>
</View> </View>
@@ -205,26 +188,16 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}> <View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? ( {actionsDisabled ? (
<View style={overlayStyles.loadingContainer}> <View style={overlayStyles.loadingContainer}>
<ActivityIndicator <ActivityIndicator size="small" color={theme.colors.palette.white} />
size="small"
color={theme.colors.palette.white}
/>
</View> </View>
) : isFailed ? ( ) : isFailed ? (
<Pressable <Pressable
onPress={onRetry} onPress={onRetry}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Retry dictation" accessibilityLabel="Retry dictation"
style={[ style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
> >
<RefreshCcw <RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
</Pressable> </Pressable>
) : ( ) : (
<> <>
@@ -232,10 +205,7 @@ export function DictationOverlay({
onPress={onAccept} onPress={onAccept}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Insert transcription" accessibilityLabel="Insert transcription"
style={[ style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
> >
<Pencil <Pencil
size={theme.iconSize.lg} size={theme.iconSize.lg}
@@ -247,16 +217,9 @@ export function DictationOverlay({
onPress={onAcceptAndSend} onPress={onAcceptAndSend}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Insert transcription and send" accessibilityLabel="Insert transcription and send"
style={[ style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
> >
<ArrowUp <ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
</Pressable> </Pressable>
</> </>
)} )}
@@ -1,6 +1,14 @@
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AlertTriangle, CheckCircle2, Info, RefreshCcw, RotateCcw, WifiOff, X } from "lucide-react-native"; import {
AlertTriangle,
CheckCircle2,
Info,
RefreshCcw,
RotateCcw,
WifiOff,
X,
} from "lucide-react-native";
export type DictationToastVariant = "info" | "success" | "warning" | "error"; export type DictationToastVariant = "info" | "success" | "warning" | "error";
@@ -53,7 +61,8 @@ export function DictationStatusNotice({
})(); })();
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white; const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white; const secondaryColor =
variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
return ( return (
<View <View
@@ -83,11 +92,7 @@ export function DictationStatusNotice({
{(meta || (actionLabel && onAction)) && ( {(meta || (actionLabel && onAction)) && (
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
{meta ? ( {meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
) : (
<View />
)}
{actionLabel && onAction ? ( {actionLabel && onAction ? (
<Pressable <Pressable
style={[ style={[
+2 -1
View File
@@ -71,7 +71,8 @@ export function DiffViewer({
key={segIdx} key={segIdx}
style={[ style={[
line.type === "add" ? styles.addText : styles.removeText, line.type === "add" ? styles.addText : styles.removeText,
segment.changed && (line.type === "add" ? styles.addHighlight : styles.removeHighlight), segment.changed &&
(line.type === "add" ? styles.addHighlight : styles.removeHighlight),
]} ]}
> >
{segment.text} {segment.text}
@@ -41,7 +41,10 @@ export function DownloadToast() {
} }
return ( return (
<View style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]} pointerEvents="box-none"> <View
style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]}
pointerEvents="box-none"
>
<View style={styles.toast}> <View style={styles.toast}>
{activeDownload.status === "downloading" ? ( {activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} /> <ActivityIndicator size="small" color={theme.colors.foreground} />
@@ -61,7 +64,7 @@ export function DownloadToast() {
: "Starting..." : "Starting..."
: activeDownload.status === "complete" : activeDownload.status === "complete"
? "Download complete" ? "Download complete"
: activeDownload.message ?? "Download failed"} : (activeDownload.message ?? "Download failed")}
</Text> </Text>
{activeDownload.status === "downloading" && activeDownload.progress && ( {activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}> <View style={styles.progressBar}>
@@ -5,10 +5,7 @@ import DraggableFlatList, {
type RenderItemParams, type RenderItemParams,
} from "react-native-draggable-flatlist"; } from "react-native-draggable-flatlist";
import { useUnistyles } from "react-native-unistyles"; import { useUnistyles } from "react-native-unistyles";
import type { import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo }; export type { DraggableListProps, DraggableRenderItemInfo };
@@ -53,7 +50,7 @@ export function DraggableList<T>({
}; };
return renderItem(info); return renderItem(info);
}, },
[renderItem] [renderItem],
); );
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
@@ -61,7 +58,7 @@ export function DraggableList<T>({
setIsDragging(false); setIsDragging(false);
onDragEnd(newData); onDragEnd(newData);
}, },
[onDragEnd] [onDragEnd],
); );
const handleDragBegin = useCallback(() => { const handleDragBegin = useCallback(() => {
@@ -74,12 +71,11 @@ export function DraggableList<T>({
}, []); }, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing)); const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle = const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const shouldShowRefreshControl = showRefreshControl && !nestable; const shouldShowRefreshControl = showRefreshControl && !nestable;
const ListComponent: typeof DraggableFlatList = (nestable const ListComponent: typeof DraggableFlatList = (
? (NestableDraggableFlatList as any) nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
: DraggableFlatList) as any; ) as any;
return ( return (
<ListComponent <ListComponent
@@ -19,14 +19,8 @@ import {
arrayMove, arrayMove,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
DraggableListProps, import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
DraggableRenderItemInfo,
} from "./draggable-list.types";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo }; export type { DraggableListProps, DraggableRenderItemInfo };
@@ -79,7 +73,7 @@ function SortableItem<T>({
// the "ghost" stretches. Keep the dragged item's size stable by zeroing // the "ghost" stretches. Keep the dragged item's size stable by zeroing
// out the dnd-kit scaling component. // out the dnd-kit scaling component.
const baseTransform = CSS.Transform.toString( const baseTransform = CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
); );
const scaleTransform = isDragging ? "scale(1.02)" : ""; const scaleTransform = isDragging ? "scale(1.02)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" "); const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -100,9 +94,7 @@ function SortableItem<T>({
? { ? {
attributes: attributes as unknown as Record<string, unknown>, attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>, listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as ( setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
node: unknown
) => void,
} }
: undefined, : undefined,
}; };
@@ -152,7 +144,7 @@ export function DraggableList<T>({
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) }),
); );
const handleDragStart = useCallback( const handleDragStart = useCallback(
@@ -161,7 +153,7 @@ export function DraggableList<T>({
setActiveId(String(event.active.id)); setActiveId(String(event.active.id));
onDragBegin?.(); onDragBegin?.();
}, },
[data, onDragBegin] [data, onDragBegin],
); );
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
@@ -172,12 +164,8 @@ export function DraggableList<T>({
setDragItems(null); setDragItems(null);
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
const oldIndex = items.findIndex( const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
(item, i) => keyExtractor(item, i) === active.id const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex); const newItems = arrayMove(items, oldIndex, newIndex);
@@ -185,7 +173,7 @@ export function DraggableList<T>({
} }
} }
}, },
[items, keyExtractor, onDragEnd] [items, keyExtractor, onDragEnd],
); );
const ids = items.map((item, index) => keyExtractor(item, index)); const ids = items.map((item, index) => keyExtractor(item, index));
@@ -204,9 +192,7 @@ export function DraggableList<T>({
testID={testID} testID={testID}
style={style} style={style}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={ showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined} onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={ onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
+1 -4
View File
@@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
<Text style={styles.primaryButtonText}>New agent</Text> <Text style={styles.primaryButtonText}>New agent</Text>
</Pressable> </Pressable>
{hasImportCta ? ( {hasImportCta ? (
<Pressable <Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
onPress={onImportAgent}
style={[styles.button, styles.secondaryButton]}
>
<Download size={20} color={styles.secondaryButtonText.color} /> <Download size={20} color={styles.secondaryButtonText.color} />
<Text style={styles.secondaryButtonText}>Import agent</Text> <Text style={styles.secondaryButtonText}>Import agent</Text>
</Pressable> </Pressable>
@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native"; import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import Animated, { import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
useAnimatedStyle,
useSharedValue,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
@@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void { function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
}
interface ExplorerSidebarProps { interface ExplorerSidebarProps {
serverId: string; serverId: string;
@@ -44,8 +39,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isScreenFocused = useIsFocused(); const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent); const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -68,7 +62,7 @@ export function ExplorerSidebar({
} }
const maxWidth = Math.max( const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH) Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
); );
if (explorerWidth > maxWidth) { if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth); setExplorerWidth(maxWidth);
@@ -102,7 +96,7 @@ export function ExplorerSidebar({
}); });
closeToAgent(); closeToAgent();
}, },
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView] [closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
); );
const enableSidebarCloseGesture = isMobile && isOpen; const enableSidebarCloseGesture = isMobile && isOpen;
@@ -111,7 +105,7 @@ export function ExplorerSidebar({
(tab: ExplorerTab) => { (tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
}, },
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot] [isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
); );
// Swipe gesture to close (swipe right on mobile) // Swipe gesture to close (swipe right on mobile)
@@ -170,8 +164,7 @@ export function ExplorerSidebar({
}) })
.onEnd((event) => { .onEnd((event) => {
isGesturing.value = false; isGesturing.value = false;
const shouldClose = const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", { runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX, translationX: event.translationX,
velocityX: event.velocityX, velocityX: event.velocityX,
@@ -200,7 +193,7 @@ export function ExplorerSidebar({
closeGestureRef, closeGestureRef,
closeTouchStartX, closeTouchStartX,
closeTouchStartY, closeTouchStartY,
] ],
); );
// Desktop resize gesture (drag left edge) // Desktop resize gesture (drag left edge)
@@ -218,18 +211,15 @@ export function ExplorerSidebar({
const newWidth = startWidthRef.current - event.translationX; const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max( const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH) Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(maxWidth, newWidth)
); );
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth; resizeWidth.value = clampedWidth;
}) })
.onEnd(() => { .onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value); runOnJS(setExplorerWidth)(resizeWidth.value);
}), }),
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth] [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
); );
const sidebarAnimatedStyle = useAnimatedStyle(() => ({ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
@@ -299,16 +289,11 @@ export function ExplorerSidebar({
} }
return ( return (
<Animated.View <Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
{/* Resize handle - absolutely positioned over left border */} {/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View
style={[ style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/> />
</GestureDetector> </GestureDetector>
@@ -351,8 +336,7 @@ function SidebarContent({
onOpenFile, onOpenFile,
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const resolvedTab: ExplorerTab = const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
!isGit && activeTab === "changes" ? "files" : activeTab;
return ( return (
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
@@ -365,12 +349,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]} style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")} onPress={() => onTabPress("changes")}
> >
<Text <Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
style={[
styles.tabText,
resolvedTab === "changes" && styles.tabTextActive,
]}
>
Changes Changes
</Text> </Text>
</Pressable> </Pressable>
@@ -380,12 +359,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "files" && styles.tabActive]} style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")} onPress={() => onTabPress("files")}
> >
<Text <Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
style={[
styles.tabText,
resolvedTab === "files" && styles.tabTextActive,
]}
>
Files Files
</Text> </Text>
</Pressable> </Pressable>
+2 -10
View File
@@ -1,10 +1,6 @@
import { View, Text, Platform } from "react-native"; import { View, Text, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
useAnimatedStyle,
withTiming,
useSharedValue,
} from "react-native-reanimated";
import { useEffect } from "react"; import { useEffect } from "react";
import { Upload } from "lucide-react-native"; import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone"; import { useFileDropZone } from "@/hooks/use-file-drop-zone";
@@ -18,11 +14,7 @@ interface FileDropZoneProps {
const IS_WEB = Platform.OS === "web"; const IS_WEB = Platform.OS === "web";
export function FileDropZone({ export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
children,
onFilesDropped,
disabled = false,
}: FileDropZoneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({ const { isDragging, containerRef } = useFileDropZone({
onFilesDropped, onFilesDropped,
@@ -38,10 +38,7 @@ import {
RotateCw, RotateCw,
X, X,
} from "lucide-react-native"; } from "lucide-react-native";
import type { import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store"; import { useDownloadStore } from "@/stores/download-store";
@@ -54,10 +51,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { import { usePanelStore, type SortOption } from "@/stores/panel-store";
usePanelStore,
type SortOption,
} from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { import {
@@ -102,36 +96,32 @@ export function FileExplorerPane({
onOpenFile, onOpenFile,
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts(); const daemons = useHosts();
const daemonProfile = useMemo( const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId), () => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId] [daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(
() => workspaceRoot.trim(),
[workspaceRoot]
); );
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo( const workspaceStateKey = useMemo(
() => () =>
buildWorkspaceExplorerStateKey({ buildWorkspaceExplorerStateKey({
workspaceId, workspaceId,
workspaceRoot: normalizedWorkspaceRoot, workspaceRoot: normalizedWorkspaceRoot,
}), }),
[normalizedWorkspaceRoot, workspaceId] [normalizedWorkspaceRoot, workspaceId],
); );
const workspaceScopeId = useMemo( const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot, () => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId] [normalizedWorkspaceRoot, workspaceId],
); );
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot); const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) => const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId] workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey) ? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined : undefined,
); );
const { const {
@@ -156,9 +146,9 @@ export function FileExplorerPane({
const isDirectoryLoading = useCallback( const isDirectoryLoading = useCallback(
(path: string) => (path: string) =>
Boolean( Boolean(
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path,
), ),
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path] [isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
); );
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."])); const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
@@ -235,7 +225,7 @@ export function FileExplorerPane({
}); });
} }
}, },
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing] [directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
); );
const handleOpenFile = useCallback( const handleOpenFile = useCallback(
@@ -246,7 +236,7 @@ export function FileExplorerPane({
selectExplorerEntry(entry.path); selectExplorerEntry(entry.path);
onOpenFile?.(entry.path); onOpenFile?.(entry.path);
}, },
[hasWorkspaceScope, onOpenFile, selectExplorerEntry] [hasWorkspaceScope, onOpenFile, selectExplorerEntry],
); );
const handleEntryPress = useCallback( const handleEntryPress = useCallback(
@@ -257,7 +247,7 @@ export function FileExplorerPane({
} }
handleOpenFile(entry); handleOpenFile(entry);
}, },
[handleOpenFile, handleToggleDirectory] [handleOpenFile, handleToggleDirectory],
); );
const handleCopyPath = useCallback( const handleCopyPath = useCallback(
@@ -266,10 +256,10 @@ export function FileExplorerPane({
buildAbsoluteExplorerPath({ buildAbsoluteExplorerPath({
workspaceRoot: normalizedWorkspaceRoot, workspaceRoot: normalizedWorkspaceRoot,
entryPath: path, entryPath: path,
}) }),
); );
}, },
[normalizedWorkspaceRoot] [normalizedWorkspaceRoot],
); );
const startDownload = useDownloadStore((state) => state.startDownload); const startDownload = useDownloadStore((state) => state.startDownload);
@@ -288,13 +278,7 @@ export function FileExplorerPane({
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
}); });
}, },
[ [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
daemonProfile,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
]
); );
const handleSortCycle = useCallback(() => { const handleSortCycle = useCallback(() => {
@@ -320,7 +304,7 @@ export function FileExplorerPane({
requestDirectoryListing(path, { requestDirectoryListing(path, {
recordHistory: false, recordHistory: false,
setCurrentPath: false, setCurrentPath: false,
}) }),
), ),
]); ]);
return null; return null;
@@ -342,7 +326,7 @@ export function FileExplorerPane({
easing: Easing.linear, easing: Easing.linear,
}), }),
-1, -1,
false false,
); );
return; return;
} }
@@ -366,7 +350,7 @@ export function FileExplorerPane({
if (finished) { if (finished) {
refreshIconRotation.value = 0; refreshIconRotation.value = 0;
} }
} },
); );
}, [isRefreshFetching, refreshIconRotation]); }, [isRefreshFetching, refreshIconRotation]);
@@ -394,10 +378,7 @@ export function FileExplorerPane({
!directories.has(".") && !directories.has(".") &&
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === "."); Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const showBackFromError = Boolean(error && selectedEntryPath); const showBackFromError = Boolean(error && selectedEntryPath);
const errorRecoveryPath = useMemo( const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
() => getErrorRecoveryPath(explorerState),
[explorerState]
);
const renderTreeRow = useCallback( const renderTreeRow = useCallback(
({ item }: ListRenderItemInfo<TreeRow>) => { ({ item }: ListRenderItemInfo<TreeRow>) => {
@@ -495,7 +476,7 @@ export function FileExplorerPane({
selectedEntryPath, selectedEntryPath,
theme.colors, theme.colors,
theme.spacing, theme.spacing,
] ],
); );
const handleBackFromError = useCallback(() => { const handleBackFromError = useCallback(() => {
@@ -515,7 +496,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onScroll(event); treeScrollbarMetrics.onScroll(event);
} }
}, },
[showDesktopWebScrollbar, treeScrollbarMetrics] [showDesktopWebScrollbar, treeScrollbarMetrics],
); );
const handleTreeListLayout = useCallback( const handleTreeListLayout = useCallback(
@@ -524,7 +505,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onLayout(event); treeScrollbarMetrics.onLayout(event);
} }
}, },
[showDesktopWebScrollbar, treeScrollbarMetrics] [showDesktopWebScrollbar, treeScrollbarMetrics],
); );
if (!hasWorkspaceScope) { if (!hasWorkspaceScope) {
@@ -536,9 +517,7 @@ export function FileExplorerPane({
} }
return ( return (
<View <View style={styles.container}>
style={styles.container}
>
{error ? ( {error ? (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{error}</Text>
@@ -632,16 +611,7 @@ export function FileExplorerPane({
type EntryDisplayKind = "directory" | "image" | "text" | "other"; type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([ const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"webp",
"ico",
]);
const TEXT_EXTENSIONS = new Set([ const TEXT_EXTENSIONS = new Set([
"txt", "txt",
@@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([
function renderEntryIcon( function renderEntryIcon(
kind: EntryDisplayKind, kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean } colors: { foreground: string; primary: string; directoryOpen?: boolean },
) { ) {
const color = colors.foreground; const color = colors.foreground;
switch (kind) { switch (kind) {
@@ -783,7 +753,7 @@ function buildTreeRows({
sortOption, sortOption,
path: entry.path, path: entry.path,
depth: depth + 1, depth: depth + 1,
}) }),
); );
} }
} }
+87 -87
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useRef } from 'react' import React, { useCallback, useMemo, useRef } from "react";
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query";
import { import {
ActivityIndicator, ActivityIndicator,
Image as RNImage, Image as RNImage,
@@ -10,54 +10,54 @@ import {
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
} from 'react-native' } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from '@/constants/theme' import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from '@/stores/session-store' import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { import {
WebDesktopScrollbarOverlay, WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics, useWebDesktopScrollbarMetrics,
} from '@/components/web-desktop-scrollbar' } from "@/components/web-desktop-scrollbar";
import { import {
highlightCode, highlightCode,
darkHighlightColors, darkHighlightColors,
lightHighlightColors, lightHighlightColors,
type HighlightToken, type HighlightToken,
type HighlightStyle, type HighlightStyle,
} from '@getpaseo/highlight' } from "@getpaseo/highlight";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[] tokens: HighlightToken[];
lineNumber: number lineNumber: number;
gutterWidth: number gutterWidth: number;
colorMap: Record<HighlightStyle, string> colorMap: Record<HighlightStyle, string>;
baseColor: string baseColor: string;
} }
interface FilePreviewBodyProps { interface FilePreviewBodyProps {
preview: ExplorerFile | null preview: ExplorerFile | null;
isLoading: boolean isLoading: boolean;
showDesktopWebScrollbar: boolean showDesktopWebScrollbar: boolean;
isMobile: boolean isMobile: boolean;
filePath: string filePath: string;
} }
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== 'string') { if (typeof value !== "string") {
return null return null;
} }
const trimmed = value.trim() const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null return trimmed.length > 0 ? trimmed : null;
} }
function formatFileSize({ size }: { size: number }): string { function formatFileSize({ size }: { size: number }): string {
if (size < 1024) { if (size < 1024) {
return `${size} B` return `${size} B`;
} }
if (size < 1024 * 1024) { if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB` return `${(size / 1024).toFixed(1)} KB`;
} }
return `${(size / (1024 * 1024)).toFixed(1)} MB` return `${(size / (1024 * 1024)).toFixed(1)} MB`;
} }
const CodeLine = React.memo(function CodeLine({ const CodeLine = React.memo(function CodeLine({
@@ -76,22 +76,22 @@ const CodeLine = React.memo(function CodeLine({
{tokens.map((token, index) => ( {tokens.map((token, index) => (
<Text <Text
key={index} key={index}
style={{ color: token.style ? colorMap[token.style] ?? baseColor : baseColor }} style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
> >
{token.text} {token.text}
</Text> </Text>
))} ))}
</Text> </Text>
</View> </View>
) );
}) });
const codeLineStyles = StyleSheet.create((theme) => ({ const codeLineStyles = StyleSheet.create((theme) => ({
line: { line: {
flexDirection: 'row', flexDirection: "row",
}, },
gutter: { gutter: {
alignItems: 'flex-end', alignItems: "flex-end",
paddingRight: theme.spacing[3], paddingRight: theme.spacing[3],
flexShrink: 0, flexShrink: 0,
}, },
@@ -107,7 +107,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
lineHeight: theme.fontSize.sm * 1.45, lineHeight: theme.fontSize.sm * 1.45,
flex: 1, flex: 1,
}, },
})) }));
function FilePreviewBody({ function FilePreviewBody({
preview, preview,
@@ -116,46 +116,46 @@ function FilePreviewBody({
isMobile, isMobile,
filePath, filePath,
}: FilePreviewBodyProps) { }: FilePreviewBodyProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === '#18181c' const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? '#c9d1d9' : '#24292f' const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null) const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics() const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const highlightedLines = useMemo(() => { const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== 'text') { if (!preview || preview.kind !== "text") {
return null return null;
} }
return highlightCode(preview.content ?? '', filePath) return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]) }, [preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => { const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0 if (!highlightedLines) return 0;
const digits = String(highlightedLines.length).length const digits = String(highlightedLines.length).length;
return Math.max(digits * 8 + 12, 28) return Math.max(digits * 8 + 12, 28);
}, [highlightedLines]) }, [highlightedLines]);
const handlePreviewScroll = useCallback( const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) { if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event) previewScrollbarMetrics.onScroll(event);
} }
}, },
[enablePreviewDesktopScrollbar, previewScrollbarMetrics] [enablePreviewDesktopScrollbar, previewScrollbarMetrics],
) );
const handlePreviewLayout = useCallback( const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => { (event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) { if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event) previewScrollbarMetrics.onLayout(event);
} }
}, },
[enablePreviewDesktopScrollbar, previewScrollbarMetrics] [enablePreviewDesktopScrollbar, previewScrollbarMetrics],
) );
if (isLoading && !preview) { if (isLoading && !preview) {
return ( return (
@@ -163,7 +163,7 @@ function FilePreviewBody({
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text> <Text style={styles.loadingText}>Loading file…</Text>
</View> </View>
) );
} }
if (!preview) { if (!preview) {
@@ -171,11 +171,11 @@ function FilePreviewBody({
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text> <Text style={styles.emptyText}>No preview available</Text>
</View> </View>
) );
} }
if (preview.kind === 'text') { if (preview.kind === "text") {
const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]] const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = ( const codeLines = (
<View> <View>
{lines.map((tokens, index) => ( {lines.map((tokens, index) => (
@@ -189,7 +189,7 @@ function FilePreviewBody({
/> />
))} ))}
</View> </View>
) );
return ( return (
<View style={styles.previewScrollContainer}> <View style={styles.previewScrollContainer}>
@@ -221,14 +221,14 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar} enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics} metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => { onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }) previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}} }}
/> />
</View> </View>
) );
} }
if (preview.kind === 'image' && preview.content) { if (preview.kind === "image" && preview.content) {
return ( return (
<View style={styles.previewScrollContainer}> <View style={styles.previewScrollContainer}>
<RNScrollView <RNScrollView
@@ -245,7 +245,7 @@ function FilePreviewBody({
> >
<RNImage <RNImage
source={{ source={{
uri: `data:${preview.mimeType ?? 'image/png'};base64,${preview.content}`, uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}} }}
style={styles.previewImage} style={styles.previewImage}
resizeMode="contain" resizeMode="contain"
@@ -255,11 +255,11 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar} enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics} metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => { onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }) previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}} }}
/> />
</View> </View>
) );
} }
return ( return (
@@ -267,7 +267,7 @@ function FilePreviewBody({
<Text style={styles.emptyText}>Binary preview unavailable</Text> <Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text> <Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View> </View>
) );
} }
export function FilePane({ export function FilePane({
@@ -275,33 +275,33 @@ export function FilePane({
workspaceRoot, workspaceRoot,
filePath, filePath,
}: { }: {
serverId: string serverId: string;
workspaceRoot: string workspaceRoot: string;
filePath: string filePath: string;
}) { }) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]) const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]) const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const query = useQuery({ const query = useQuery({
queryKey: ['workspaceFile', serverId, normalizedWorkspaceRoot, normalizedFilePath], queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath), enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
queryFn: async () => { queryFn: async () => {
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) { if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
return { file: null as ExplorerFile | null, error: 'Host is not connected' } return { file: null as ExplorerFile | null, error: "Host is not connected" };
} }
const payload = await client.exploreFileSystem( const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot, normalizedWorkspaceRoot,
normalizedFilePath, normalizedFilePath,
'file' "file",
) );
return { file: payload.file ?? null, error: payload.error ?? null } return { file: payload.file ?? null, error: payload.error ?? null };
}, },
staleTime: 5_000, staleTime: 5_000,
}) });
return ( return (
<View style={styles.container} testID="workspace-file-pane"> <View style={styles.container} testID="workspace-file-pane">
@@ -319,7 +319,7 @@ export function FilePane({
filePath={filePath} filePath={filePath}
/> />
</View> </View>
) );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
@@ -330,8 +330,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
centerState: { centerState: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
padding: theme.spacing[4], padding: theme.spacing[4],
}, },
loadingText: { loadingText: {
@@ -342,12 +342,12 @@ const styles = StyleSheet.create((theme) => ({
errorText: { errorText: {
color: theme.colors.destructive, color: theme.colors.destructive,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
textAlign: 'center', textAlign: "center",
}, },
emptyText: { emptyText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
textAlign: 'center', textAlign: "center",
}, },
binaryMetaText: { binaryMetaText: {
marginTop: theme.spacing[2], marginTop: theme.spacing[2],
@@ -368,11 +368,11 @@ const styles = StyleSheet.create((theme) => ({
previewImageScrollContent: { previewImageScrollContent: {
flexGrow: 1, flexGrow: 1,
padding: theme.spacing[4], padding: theme.spacing[4],
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}, },
previewImage: { previewImage: {
width: '100%', width: "100%",
height: 420, height: 420,
}, },
})) }));
@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy"; import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput( function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
return { return {
isGit: true, isGit: true,
githubFeaturesEnabled: true, githubFeaturesEnabled: true,
@@ -68,7 +66,7 @@ describe("git-actions-policy", () => {
aheadCount: 3, aheadCount: 3,
aheadOfOrigin: 2, aheadOfOrigin: 2,
shipDefault: "pr", shipDefault: "pr",
}) }),
); );
expect(noPrActions.primary).toBeNull(); expect(noPrActions.primary).toBeNull();
@@ -117,18 +115,20 @@ describe("git-actions-policy", () => {
hasRemote: true, hasRemote: true,
hasPullRequest: true, hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
}) }),
); );
expect(actions.primary?.id).toBe("pr"); expect(actions.primary?.id).toBe("pr");
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true); expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
).toBe(true);
}); });
it("disables sync on the base branch when already up to date", () => { it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions( const actions = buildGitActions(
createInput({ createInput({
hasRemote: true, hasRemote: true,
}) }),
); );
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base"); const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
@@ -54,12 +54,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>; runtime: Record<GitActionId, GitActionRuntimeState>;
} }
const SECONDARY_ACTION_IDS: GitActionId[] = [ const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
"merge-branch",
"pr",
"merge-from-base",
"push",
];
export function buildGitActions(input: BuildGitActionsInput): GitActions { export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) { if (!input.isGit) {
@@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
}); });
const primaryActionId = getPrimaryActionId(input); const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null; const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!); const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
if (input.isPaseoOwnedWorktree) { if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!); secondary.push(allActions.get("archive-worktree")!);
@@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR", label: "Create PR",
pendingLabel: "Creating PR...", pendingLabel: "Creating PR...",
successLabel: "PR Created", successLabel: "PR Created",
disabled: disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
status: input.runtime.pr.status, status: input.runtime.pr.status,
description: getCreatePrDescription(input), description: getCreatePrDescription(input),
icon: input.runtime.pr.icon, icon: input.runtime.pr.icon,
@@ -49,7 +49,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
) : ( ) : (
<View style={styles.splitButtonContent}> <View style={styles.splitButtonContent}>
{gitActions.primary.icon} {gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text> <Text style={styles.splitButtonText}>
{getActionDisplayLabel(gitActions.primary)}
</Text>
</View> </View>
)} )}
</Pressable> </Pressable>
File diff suppressed because it is too large Load Diff
@@ -11,21 +11,14 @@ interface BackHeaderProps {
onBack?: () => void; onBack?: () => void;
} }
export function BackHeader({ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
title,
rightContent,
onBack,
}: BackHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
return ( return (
<ScreenHeader <ScreenHeader
left={ left={
<> <>
<Pressable <Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> <ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
{title && ( {title && (
@@ -29,11 +29,7 @@ function MobileMenuIcon({ color }: { color: string }) {
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} /> <View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} /> <View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View <View
style={[ style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
/> />
</View> </View>
); );
@@ -46,17 +42,15 @@ export function SidebarMenuToggle({
nativeID = "menu-button", nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) { }: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor = !isMobile && isOpen const menuIconColor =
? theme.colors.foreground !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
: theme.colors.foregroundMuted;
return ( return (
<HeaderToggleButton <HeaderToggleButton
@@ -81,10 +75,7 @@ export function SidebarMenuToggle({
); );
} }
export function MenuHeader({ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
title,
rightContent,
}: MenuHeaderProps) {
return ( return (
<ScreenHeader <ScreenHeader
left={ left={
@@ -22,12 +22,7 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border, * Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place. * and safe-area logic in one place.
*/ */
export function ScreenHeader({ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -37,9 +32,7 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2]; const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset = const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() !isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
? trafficLightPadding.left
: 0;
const dragHandlers = useDesktopDragHandlers(); const dragHandlers = useDesktopDragHandlers();
@@ -25,10 +25,34 @@ export function SourceControlPanelIcon({
strokeLinejoin="round" strokeLinejoin="round"
/> />
{/* Plus */} {/* Plus */}
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" /> <Line
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" /> x1={9}
y1={9.5}
x2={15}
y2={9.5}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Line
x1={12}
y1={6.5}
x2={12}
y2={12.5}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* Minus */} {/* Minus */}
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" /> <Line
x1={9}
y1={16}
x2={15}
y2={16}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</Svg> </Svg>
); );
} }
@@ -16,7 +16,7 @@ export function KeyboardShortcutsDialog() {
const isDesktopApp = getIsDesktop(); const isDesktopApp = getIsDesktop();
const sections = useMemo( const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }), () => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac] [isDesktopApp, isMac],
); );
return ( return (
+233 -221
View File
@@ -9,103 +9,108 @@ import {
type Dispatch, type Dispatch,
type RefObject, type RefObject,
type SetStateAction, type SetStateAction,
} from 'react' } from "react";
import { View, Pressable, Text, Platform } from 'react-native' import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
interpolate, interpolate,
Extrapolation, Extrapolation,
runOnJS, runOnJS,
useSharedValue, useSharedValue,
} from 'react-native-reanimated' } from "react-native-reanimated";
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from 'lucide-react-native' import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from '@/components/ui/shortcut' import { Shortcut } from "@/components/ui/shortcut";
import { router, usePathname } from 'expo-router' import { router, usePathname } from "expo-router";
import { usePanelStore } from '@/stores/panel-store' import { usePanelStore } from "@/stores/panel-store";
import { SidebarWorkspaceList } from './sidebar-workspace-list' import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton' import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model' import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list' import {
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' useSidebarWorkspacesList,
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window' type SidebarProjectEntry,
import { Combobox } from '@/components/ui/combobox' } from "@/hooks/use-sidebar-workspaces-list";
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime' import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { formatConnectionStatus } from '@/utils/daemons' import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { import {
buildHostSessionsRoute, buildHostSessionsRoute,
buildHostSettingsRoute, buildHostSettingsRoute,
mapPathnameToServer, mapPathnameToServer,
parseServerIdFromPathname, parseServerIdFromPathname,
} from '@/utils/host-routes' } from "@/utils/host-routes";
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker' import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
const DESKTOP_SIDEBAR_WIDTH = 320 const DESKTOP_SIDEBAR_WIDTH = 320;
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel> type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
type SidebarTheme = ReturnType<typeof useUnistyles>['theme'] type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
interface LeftSidebarProps { interface LeftSidebarProps {
selectedAgentId?: string selectedAgentId?: string;
} }
interface HostOption { interface HostOption {
id: string id: string;
label: string label: string;
description: string description: string;
} }
interface SidebarSharedProps { interface SidebarSharedProps {
theme: SidebarTheme theme: SidebarTheme;
activeServerId: string | null activeServerId: string | null;
activeHostLabel: string activeHostLabel: string;
activeHostStatusColor: string activeHostStatusColor: string;
hostOptions: HostOption[] hostOptions: HostOption[];
hostTriggerRef: RefObject<View | null> hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>> setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
projects: SidebarProjectEntry[] projects: SidebarProjectEntry[];
isInitialLoad: boolean isInitialLoad: boolean;
isRevalidating: boolean isRevalidating: boolean;
isManualRefresh: boolean isManualRefresh: boolean;
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys'] collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey'] shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed'] toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed'] setProjectCollapsed: SidebarShortcutModel["setProjectCollapsed"];
handleRefresh: () => void handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void handleOpenProject: () => void;
handleSettings: () => void handleSettings: () => void;
} }
interface MobileSidebarProps extends SidebarSharedProps { interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number insetsTop: number;
insetsBottom: number insetsBottom: number;
isOpen: boolean isOpen: boolean;
closeToAgent: () => void closeToAgent: () => void;
handleViewMoreNavigate: () => void handleViewMoreNavigate: () => void;
} }
interface DesktopSidebarProps extends SidebarSharedProps { interface DesktopSidebarProps extends SidebarSharedProps {
isOpen: boolean isOpen: boolean;
handleViewMore: () => void handleViewMore: () => void;
} }
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { export const LeftSidebar = memo(function LeftSidebar({
void _selectedAgentId selectedAgentId: _selectedAgentId,
}: LeftSidebarProps) {
void _selectedAgentId;
const { theme } = useUnistyles() const { theme } = useUnistyles();
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView) const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen) const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent) const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname() const pathname = usePathname();
const daemons = useHosts() const daemons = useHosts();
const runtime = getHostRuntimeStore() const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore( const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange), (onStoreChange) => runtime.subscribeAll(onStoreChange),
() => () =>
@@ -113,128 +118,132 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
.map( .map(
(daemon) => (daemon) =>
`${daemon.serverId}:${ `${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}` }`,
) )
.join('|'), .join("|"),
() => () =>
daemons daemons
.map( .map(
(daemon) => (daemon) =>
`${daemon.serverId}:${ `${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}` }`,
) )
.join('|') .join("|"),
) );
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]) const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => { const activeDaemon = useMemo(() => {
if (daemons.length === 0) { if (daemons.length === 0) {
return null return null;
} }
if (activeServerIdFromPath) { if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath) const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) { if (routeMatch) {
return routeMatch return routeMatch;
} }
} }
return daemons[0] ?? null return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]) }, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => { const activeHostLabel = useMemo(() => {
if (!activeDaemon) return 'No host' if (!activeDaemon) return "No host";
const trimmed = activeDaemon.label?.trim() const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]) }, [activeDaemon]);
const activeHostStatus = activeServerId const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting') ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
: 'idle' : "idle";
const activeHostStatusColor = const activeHostStatusColor =
activeHostStatus === 'online' activeHostStatus === "online"
? theme.colors.palette.green[400] ? theme.colors.palette.green[400]
: activeHostStatus === 'connecting' : activeHostStatus === "connecting"
? theme.colors.palette.amber[500] ? theme.colors.palette.amber[500]
: theme.colors.palette.red[500] : theme.colors.palette.red[500];
const hostOptions = useMemo( const hostOptions = useMemo(
() => () =>
daemons.map((daemon) => ({ daemons.map((daemon) => ({
id: daemon.serverId, id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId, label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus( description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
), ),
})), })),
[daemons, runtime, runtimeConnectionStatusSignature] [daemons, runtime, runtimeConnectionStatusSignature],
) );
const hostTriggerRef = useRef<View | null>(null) const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false) const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId, serverId: activeServerId,
enabled: isOpen, enabled: isOpen,
}) });
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } = const {
useSidebarShortcutModel(projects) collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
} = useSidebarShortcutModel(projects);
const [isManualRefresh, setIsManualRefresh] = useState(false) const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsManualRefresh(true) setIsManualRefresh(true);
refreshAll() refreshAll();
}, [refreshAll]) }, [refreshAll]);
useEffect(() => { useEffect(() => {
if (!isRevalidating && isManualRefresh) { if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false) setIsManualRefresh(false);
} }
}, [isRevalidating, isManualRefresh]) }, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker(activeServerId) const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => { const handleOpenProjectMobile = useCallback(() => {
closeToAgent() closeToAgent();
void openProjectPicker() void openProjectPicker();
}, [closeToAgent, openProjectPicker]) }, [closeToAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => { const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker() void openProjectPicker();
}, [openProjectPicker]) }, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => { const handleSettingsMobile = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return return;
} }
closeToAgent() closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any) router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]) }, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => { const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return return;
} }
router.push(buildHostSettingsRoute(activeServerId) as any) router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]) }, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => { const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return return;
} }
router.push(buildHostSessionsRoute(activeServerId) as any) router.push(buildHostSessionsRoute(activeServerId) as any);
}, [activeServerId]) }, [activeServerId]);
const handleHostSelect = useCallback( const handleHostSelect = useCallback(
(nextServerId: string) => { (nextServerId: string) => {
if (!nextServerId) { if (!nextServerId) {
return return;
} }
const nextPath = mapPathnameToServer(pathname, nextServerId) const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false) setIsHostPickerOpen(false);
router.push(nextPath as any) router.push(nextPath as any);
}, },
[pathname] [pathname],
) );
const sharedProps = { const sharedProps = {
theme, theme,
@@ -255,7 +264,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
setProjectCollapsed, setProjectCollapsed,
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
} };
if (isMobile) { if (isMobile) {
return ( return (
@@ -269,7 +278,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsMobile} handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate} handleViewMoreNavigate={handleViewMoreNavigate}
/> />
) );
} }
return ( return (
@@ -280,13 +289,13 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsDesktop} handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate} handleViewMore={handleViewMoreNavigate}
/> />
) );
}) });
function SessionsButton({ onPress }: { onPress: () => void }) { function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles() const { theme } = useUnistyles();
const pathname = usePathname() const pathname = usePathname();
const isActive = pathname.includes('/sessions') const isActive = pathname.includes("/sessions");
return ( return (
<Pressable <Pressable
@@ -308,14 +317,17 @@ function SessionsButton({ onPress }: { onPress: () => void }) {
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
<Text <Text
style={[styles.newAgentButtonText, (hovered || isActive) && styles.newAgentButtonTextHovered]} style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
> >
Sessions Sessions
</Text> </Text>
</> </>
)} )}
</Pressable> </Pressable>
) );
} }
function MobileSidebar({ function MobileSidebar({
@@ -353,22 +365,22 @@ function MobileSidebar({
animateToClose, animateToClose,
isGesturing, isGesturing,
closeGestureRef, closeGestureRef,
} = useSidebarAnimation() } = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0) const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0) const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
closeToAgent() closeToAgent();
}, [closeToAgent]) }, [closeToAgent]);
const handleViewMore = useCallback(() => { const handleViewMore = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return return;
} }
translateX.value = -windowWidth translateX.value = -windowWidth;
backdropOpacity.value = 0 backdropOpacity.value = 0;
closeToAgent() closeToAgent();
handleViewMoreNavigate() handleViewMoreNavigate();
}, [ }, [
activeServerId, activeServerId,
backdropOpacity, backdropOpacity,
@@ -376,7 +388,7 @@ function MobileSidebar({
handleViewMoreNavigate, handleViewMoreNavigate,
translateX, translateX,
windowWidth, windowWidth,
]) ]);
const closeGesture = useMemo( const closeGesture = useMemo(
() => () =>
@@ -385,62 +397,62 @@ function MobileSidebar({
.enabled(isOpen) .enabled(isOpen)
.manualActivation(true) .manualActivation(true)
.onTouchesDown((event) => { .onTouchesDown((event) => {
const touch = event.changedTouches[0] const touch = event.changedTouches[0];
if (!touch) { if (!touch) {
return return;
} }
closeTouchStartX.value = touch.absoluteX closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY closeTouchStartY.value = touch.absoluteY;
}) })
.onTouchesMove((event, stateManager) => { .onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0] const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) { if (!touch || event.numberOfTouches !== 1) {
stateManager.fail() stateManager.fail();
return return;
} }
const deltaX = touch.absoluteX - closeTouchStartX.value const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX) const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY) const absDeltaY = Math.abs(deltaY);
if (deltaX >= 10) { if (deltaX >= 10) {
stateManager.fail() stateManager.fail();
return return;
} }
if (absDeltaY > 10 && absDeltaY > absDeltaX) { if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail() stateManager.fail();
return return;
} }
if (deltaX <= -15 && absDeltaX > absDeltaY) { if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate() stateManager.activate();
} }
}) })
.onStart(() => { .onStart(() => {
isGesturing.value = true isGesturing.value = true;
}) })
.onUpdate((event) => { .onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX translateX.value = newTranslateX;
backdropOpacity.value = interpolate( backdropOpacity.value = interpolate(
newTranslateX, newTranslateX,
[-windowWidth, 0], [-windowWidth, 0],
[0, 1], [0, 1],
Extrapolation.CLAMP Extrapolation.CLAMP,
) );
}) })
.onEnd((event) => { .onEnd((event) => {
isGesturing.value = false isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) { if (shouldClose) {
animateToClose() animateToClose();
runOnJS(handleClose)() runOnJS(handleClose)();
} else { } else {
animateToOpen() animateToOpen();
} }
}) })
.onFinalize(() => { .onFinalize(() => {
isGesturing.value = false isGesturing.value = false;
}), }),
[ [
isOpen, isOpen,
@@ -454,29 +466,29 @@ function MobileSidebar({
animateToClose, animateToClose,
animateToOpen, animateToOpen,
handleClose, handleClose,
] ],
) );
const mobileSidebarInsetStyle = useMemo( const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }), () => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom] [windowWidth, insetsTop, insetsBottom],
) );
const hostStatusDotStyle = useMemo( const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor] [activeHostStatusColor],
) );
const sidebarAnimatedStyle = useAnimatedStyle(() => ({ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }], transform: [{ translateX: translateX.value }],
})) }));
const backdropAnimatedStyle = useAnimatedStyle(() => ({ const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value, opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none', pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
})) }));
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none' const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -554,7 +566,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text> <Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} /> <Shortcut keys={["⌘", "⇧", "O"]} />
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -578,7 +590,7 @@ function MobileSidebar({
</View> </View>
<Combobox <Combobox
options={hostOptions} options={hostOptions}
value={activeServerId ?? ''} value={activeServerId ?? ""}
onSelect={handleHostSelect} onSelect={handleHostSelect}
searchable={false} searchable={false}
title="Switch host" title="Switch host"
@@ -592,7 +604,7 @@ function MobileSidebar({
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
</View> </View>
) );
} }
function DesktopSidebar({ function DesktopSidebar({
@@ -619,15 +631,15 @@ function DesktopSidebar({
isOpen, isOpen,
handleViewMore, handleViewMore,
}: DesktopSidebarProps) { }: DesktopSidebarProps) {
const dragHandlers = useDesktopDragHandlers() const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding() const trafficLightPadding = useTrafficLightPadding();
const hostStatusDotStyle = useMemo( const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor] [activeHostStatusColor],
) );
if (!isOpen) { if (!isOpen) {
return null return null;
} }
return ( return (
@@ -697,7 +709,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text> <Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} /> <Shortcut keys={["⌘", "⇧", "O"]} />
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -721,7 +733,7 @@ function DesktopSidebar({
</View> </View>
<Combobox <Combobox
options={hostOptions} options={hostOptions}
value={activeServerId ?? ''} value={activeServerId ?? ""}
onSelect={handleHostSelect} onSelect={handleHostSelect}
searchable={false} searchable={false}
title="Switch host" title="Switch host"
@@ -732,29 +744,29 @@ function DesktopSidebar({
/> />
</View> </View>
</View> </View>
) );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
backdrop: { backdrop: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: "rgba(0, 0, 0, 0.5)",
}, },
backdropPressable: { backdropPressable: {
flex: 1, flex: 1,
}, },
mobileSidebar: { mobileSidebar: {
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
bottom: 0, bottom: 0,
backgroundColor: theme.colors.surfaceSidebar, backgroundColor: theme.colors.surfaceSidebar,
overflow: 'hidden', overflow: "hidden",
}, },
sidebarContent: { sidebarContent: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflow: 'hidden', overflow: "hidden",
}, },
desktopSidebar: { desktopSidebar: {
borderRightWidth: 1, borderRightWidth: 1,
@@ -767,21 +779,21 @@ const styles = StyleSheet.create((theme) => ({
md: HEADER_INNER_HEIGHT, md: HEADER_INNER_HEIGHT,
}, },
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
justifyContent: 'center', justifyContent: "center",
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
userSelect: 'none', userSelect: "none",
}, },
sidebarHeaderRow: { sidebarHeaderRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
newAgentButton: { newAgentButton: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -802,9 +814,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
hostTrigger: { hostTrigger: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'flex-start', justifyContent: "flex-start",
gap: theme.spacing[2], gap: theme.spacing[2],
minWidth: 0, minWidth: 0,
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
@@ -826,9 +838,9 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
}, },
sidebarFooter: { sidebarFooter: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
borderTopWidth: 1, borderTopWidth: 1,
@@ -841,16 +853,16 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[2], marginRight: theme.spacing[2],
}, },
footerIconRow: { footerIconRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
flexShrink: 0, flexShrink: 0,
}, },
footerIconButton: { footerIconButton: {
width: 28, width: 28,
height: 28, height: 28,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
}, },
@@ -876,19 +888,19 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
alignItems: 'center', alignItems: "center",
}, },
hostPickerCancelText: { hostPickerCancelText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
tooltipRow: { tooltipRow: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
tooltipText: { tooltipText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground, color: theme.colors.popoverForeground,
}, },
})) }));
File diff suppressed because it is too large Load Diff
+162 -329
View File
@@ -40,11 +40,7 @@ import {
TriangleAlertIcon, TriangleAlertIcon,
Scissors, Scissors,
} from "lucide-react-native"; } from "lucide-react-native";
import { import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
StyleSheet,
useUnistyles,
UnistylesRuntime,
} from "react-native-unistyles";
import Animated, { import Animated, {
Easing, Easing,
cancelAnimation, cancelAnimation,
@@ -53,23 +49,14 @@ import Animated, {
withRepeat, withRepeat,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import Svg, { import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import { theme } from "@/styles/theme"; import { theme } from "@/styles/theme";
import { import { createMarkdownStyles } from "@/styles/markdown-styles";
createMarkdownStyles,
} from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme"; import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { import {
hasMeaningfulToolCallDetail, hasMeaningfulToolCallDetail,
@@ -159,10 +146,7 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true; webToolCallShimmerRegistered = true;
} }
function getWheelEventElementTarget( function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
const { target } = event; const { target } = event;
if (target instanceof HTMLElement) { if (target instanceof HTMLElement) {
return target; return target;
@@ -176,7 +160,7 @@ function getWheelEventElementTarget(
function canElementScrollInDirection( function canElementScrollInDirection(
element: HTMLElement, element: HTMLElement,
axis: ScrollAxis, axis: ScrollAxis,
delta: number delta: number,
): boolean { ): boolean {
if (delta === 0) { if (delta === 0) {
return false; return false;
@@ -192,7 +176,9 @@ function canElementScrollInDirection(
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop; const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
const scrollSize = const scrollSize =
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight; axis === "x"
? element.scrollWidth - element.clientWidth
: element.scrollHeight - element.clientHeight;
if (scrollSize <= SCROLL_EDGE_EPSILON) { if (scrollSize <= SCROLL_EDGE_EPSILON) {
return false; return false;
} }
@@ -207,7 +193,7 @@ function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement, detailRoot: HTMLElement,
startElement: HTMLElement, startElement: HTMLElement,
axis: ScrollAxis, axis: ScrollAxis,
delta: number delta: number,
): boolean { ): boolean {
if (delta === 0) { if (delta === 0) {
return false; return false;
@@ -226,14 +212,10 @@ function canScrollInsideDetailFromTarget(
return false; return false;
} }
function shouldStopDetailWheelPropagation( function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
detailRoot: HTMLElement,
event: WheelEvent
): boolean {
const startElement = getWheelEventElementTarget(event, detailRoot); const startElement = getWheelEventElementTarget(event, detailRoot);
const verticalDelta = event.deltaY; const verticalDelta = event.deltaY;
const horizontalDelta = const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON; const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON; const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
@@ -245,12 +227,7 @@ function shouldStopDetailWheelPropagation(
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta) ? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false; : false;
const canScrollHorizontally = hasHorizontalIntent const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget( ? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
detailRoot,
startElement,
"x",
horizontalDelta
)
: false; : false;
if (hasVerticalIntent && hasHorizontalIntent) { if (hasVerticalIntent && hasHorizontalIntent) {
@@ -336,11 +313,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
}, },
})); }));
function UserMessageAttachmentThumbnail({ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
image,
}: {
image: UserMessageImageAttachment;
}) {
const uri = useAttachmentPreviewUrl(image); const uri = useAttachmentPreviewUrl(image);
if (!uri) { if (!uri) {
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />; return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
@@ -358,12 +331,10 @@ export const UserMessage = memo(function UserMessage({
}: UserMessageProps) { }: UserMessageProps) {
const [messageHovered, setMessageHovered] = useState(false); const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0; const hasText = message.trim().length > 0;
const hasImages = images.length > 0; const hasImages = images.length > 0;
const showCopyButton = const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
return ( return (
<View <View
@@ -372,20 +343,14 @@ export const UserMessage = memo(function UserMessage({
!resolvedDisableOuterSpacing && [ !resolvedDisableOuterSpacing && [
isFirstInGroup && { marginTop: theme.spacing[4] }, isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] }, isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup !isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
? { marginBottom: theme.spacing[1] }
: undefined,
], ],
]} ]}
> >
<Pressable <Pressable
style={userMessageStylesheet.content} style={userMessageStylesheet.content}
onHoverIn={ onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
Platform.OS === "web" ? () => setMessageHovered(true) : undefined onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
}
onHoverOut={
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
}
> >
<View style={userMessageStylesheet.bubble}> <View style={userMessageStylesheet.bubble}>
{hasImages ? ( {hasImages ? (
@@ -396,10 +361,7 @@ export const UserMessage = memo(function UserMessage({
]} ]}
> >
{images.map((image, index) => ( {images.map((image, index) => (
<View <View key={`${image.id}-${index}`} style={userMessageStylesheet.imagePill}>
key={`${image.id}-${index}`}
style={userMessageStylesheet.imagePill}
>
<UserMessageAttachmentThumbnail image={image} /> <UserMessageAttachmentThumbnail image={image} />
</View> </View>
))} ))}
@@ -475,11 +437,7 @@ function MarkdownLink({
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return ( return (
<Text <Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
accessibilityRole="link"
onPress={() => onPress(href)}
style={style}
>
{children} {children}
</Text> </Text>
); );
@@ -492,25 +450,25 @@ function MarkdownLink({
onHoverIn={() => setHovered(true)} onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)} onHoverOut={() => setHovered(false)}
> >
<Text style={[style, hovered && { textDecorationLine: "underline" }]}> <Text style={[style, hovered && { textDecorationLine: "underline" }]}>{children}</Text>
{children}
</Text>
</Pressable> </Pressable>
); );
} }
function getInlineCodeAutoLinkUrl( function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>, markdownParser: ReturnType<typeof MarkdownIt>,
content: string content: string,
): string | null { ): string | null {
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) { if (!trimmed) {
return null; return null;
} }
const matches = markdownParser.linkify.match(trimmed) as const matches = markdownParser.linkify.match(trimmed) as Array<{
| Array<{ index: number; lastIndex: number; url: string }> index: number;
| null; lastIndex: number;
url: string;
}> | null;
if (!matches || matches.length !== 1) { if (!matches || matches.length !== 1) {
return null; return null;
} }
@@ -604,9 +562,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={[turnCopyButtonStylesheet.container, containerStyle]} style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
copied copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
? (copiedAccessibilityLabel ?? "Copied")
: (accessibilityLabel ?? "Copy turn")
} }
> >
{({ hovered }) => { {({ hovered }) => {
@@ -767,41 +723,38 @@ export const AssistantMessage = memo(function AssistantMessage({
}); });
const { theme, rt } = useUnistyles(); const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing = const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownParser = useMemo( const markdownParser = useMemo(() => {
() => { const parser = MarkdownIt({ typographer: true, linkify: true });
const parser = MarkdownIt({ typographer: true, linkify: true }); const defaultValidateLink = parser.validateLink.bind(parser);
const defaultValidateLink = parser.validateLink.bind(parser); parser.validateLink = (url: string) => {
parser.validateLink = (url: string) => { if (url.trim().toLowerCase().startsWith("file://")) {
if (url.trim().toLowerCase().startsWith("file://")) { return true;
return true; }
}
return defaultValidateLink(url); return defaultValidateLink(url);
}; };
return parser; return parser;
}, }, []);
[]
);
const handleLinkPress = useCallback((url: string) => { const handleLinkPress = useCallback(
const fileTarget = onInlinePathPress (url: string) => {
? parseAssistantFileLink(url, { workspaceRoot }) const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
: null; if (fileTarget) {
if (fileTarget) { onInlinePathPress?.(fileTarget);
onInlinePathPress?.(fileTarget); return false;
}
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false; return false;
} },
[onInlinePathPress, workspaceRoot],
void openExternalUrl(url); );
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}, [onInlinePathPress, workspaceRoot]);
const markdownRules = useMemo(() => { const markdownRules = useMemo(() => {
return { return {
@@ -810,7 +763,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[], _children: ReactNode[],
_parent: any, _parent: any,
styles: any, styles: any,
inheritedStyles: any = {} inheritedStyles: any = {},
) => ( ) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}> <Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content} {node.content}
@@ -821,12 +774,9 @@ export const AssistantMessage = memo(function AssistantMessage({
children: ReactNode[], children: ReactNode[],
_parent: any, _parent: any,
styles: any, styles: any,
inheritedStyles: any = {} inheritedStyles: any = {},
) => ( ) => (
<Text <Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
key={node.key}
style={[inheritedStyles, styles.textgroup]}
>
{children} {children}
</Text> </Text>
), ),
@@ -835,12 +785,9 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[], _children: ReactNode[],
_parent: any, _parent: any,
styles: any, styles: any,
inheritedStyles: any = {} inheritedStyles: any = {},
) => ( ) => (
<Text <Text key={node.key} style={[inheritedStyles, styles.code_block]}>
key={node.key}
style={[inheritedStyles, styles.code_block]}
>
{node.content} {node.content}
</Text> </Text>
), ),
@@ -849,7 +796,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[], _children: ReactNode[],
_parent: any, _parent: any,
styles: any, styles: any,
inheritedStyles: any = {} inheritedStyles: any = {},
) => ( ) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}> <Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content} {node.content}
@@ -860,16 +807,14 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[], _children: ReactNode[],
parent: any, parent: any,
styles: any, styles: any,
inheritedStyles: any = {} inheritedStyles: any = {},
) => { ) => {
const content = node.content ?? ""; const content = node.content ?? "";
const isLinkedInlineCode = const isLinkedInlineCode =
nodeHasParentType(parent, "link") || nodeHasParentType(parent, "link") ||
(!Array.isArray(parent) && (!Array.isArray(parent) && typeof parent?.attributes?.href === "string");
typeof parent?.attributes?.href === "string"); const parsed =
const parsed = onInlinePathPress && !isLinkedInlineCode onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
? parseInlinePathToken(content)
: null;
if (parsed) { if (parsed) {
return ( return (
@@ -877,10 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key} key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)} onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false} selectable={false}
style={[ style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
> >
{content} {content}
</Text> </Text>
@@ -902,81 +844,42 @@ export const AssistantMessage = memo(function AssistantMessage({
} }
return ( return (
<Text <Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
key={node.key}
style={[inheritedStyles, styles.code_inline]}
>
{content} {content}
</Text> </Text>
); );
}, },
bullet_list: ( bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
<View key={node.key} style={styles.bullet_list}> <View key={node.key} style={styles.bullet_list}>
{children} {children}
</View> </View>
), ),
ordered_list: ( ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
<View key={node.key} style={styles.ordered_list}> <View key={node.key} style={styles.ordered_list}>
{children} {children}
</View> </View>
), ),
list_item: ( list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
node: any,
children: ReactNode[],
parent: any,
styles: any
) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent); const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
? styles.ordered_list_icon const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
: styles.bullet_list_icon;
const contentStyle = isOrdered
? styles.ordered_list_content
: styles.bullet_list_content;
return ( return (
<View key={node.key} style={styles.list_item}> <View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text> <Text style={iconStyle}>{marker}</Text>
<View <View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View> </View>
); );
}, },
paragraph: ( paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
node: any,
children: ReactNode[],
parent: any,
styles: any,
) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return ( return (
<View <View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
key={node.key}
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
>
{children} {children}
</View> </View>
); );
}, },
link: ( link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
) => (
<MarkdownLink <MarkdownLink
key={node.key} key={node.key}
href={node.attributes?.href ?? ""} href={node.attributes?.href ?? ""}
@@ -985,8 +888,10 @@ export const AssistantMessage = memo(function AssistantMessage({
> >
{Children.map(children, (child) => {Children.map(children, (child) =>
isValidElement(child) isValidElement(child)
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any) ? cloneElement(child, {
: child style: [(child.props as any).style, { color: styles.link.color }],
} as any)
: child,
)} )}
</MarkdownLink> </MarkdownLink>
), ),
@@ -998,8 +903,7 @@ export const AssistantMessage = memo(function AssistantMessage({
testID="assistant-message" testID="assistant-message"
style={[ style={[
assistantMessageStylesheet.container, assistantMessageStylesheet.container,
!resolvedDisableOuterSpacing && !resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
assistantMessageStylesheet.containerSpacing,
]} ]}
> >
<Markdown <Markdown
@@ -1109,8 +1013,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick, onArtifactClick,
disableOuterSpacing, disableOuterSpacing,
}: ActivityLogProps) { }: ActivityLogProps) {
const resolvedDisableOuterSpacing = const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = { const typeConfig = {
@@ -1149,9 +1052,7 @@ export const ActivityLog = memo(function ActivityLog({
}; };
const displayMessage = const displayMessage =
type === "artifact" && artifactType && title type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
? `${artifactType}: ${title}`
: message;
const isInteractive = type === "artifact" || metadata; const isInteractive = type === "artifact" || metadata;
@@ -1172,12 +1073,7 @@ export const ActivityLog = memo(function ActivityLog({
<IconComponent size={16} color={config.color} /> <IconComponent size={16} color={config.color} />
</View> </View>
<View style={activityLogStylesheet.textContainer}> <View style={activityLogStylesheet.textContainer}>
<Text <Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
style={[
activityLogStylesheet.messageText,
{ color: config.color },
]}
>
{displayMessage} {displayMessage}
</Text> </Text>
{metadata && ( {metadata && (
@@ -1402,21 +1298,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
testID, testID,
}: ExpandableBadgeProps) { }: ExpandableBadgeProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false); const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle); const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails); const hasDetailContent = Boolean(renderDetails);
const detailContent = const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null); const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${ const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
testID ?? label
}`;
const nativeGradientIdRef = useRef( const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}` `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
); );
const [labelRowWidth, setLabelRowWidth] = useState(0); const [labelRowWidth, setLabelRowWidth] = useState(0);
const [labelRowHeight, setLabelRowHeight] = useState(0); const [labelRowHeight, setLabelRowHeight] = useState(0);
@@ -1426,77 +1318,64 @@ const ExpandableBadge = memo(function ExpandableBadge({
const [secondaryWidth, setSecondaryWidth] = useState(0); const [secondaryWidth, setSecondaryWidth] = useState(0);
const shimmerTranslateX = useSharedValue(0); const shimmerTranslateX = useSharedValue(0);
const totalShimmerChars = const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
label.trim().length + (secondaryLabel?.trim().length ?? 0);
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0; const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
const shimmerDuration = Math.max( const shimmerDuration = Math.max(
1, 1,
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment) Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment),
); );
const nativeShimmerPeakWidth = Math.max( const nativeShimmerPeakWidth = Math.max(
32, 32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0) Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
); );
const isWebShimmer = isLoading && Platform.OS === "web"; const isWebShimmer = isLoading && Platform.OS === "web";
const shouldMeasureWebShimmer = isWebShimmer; const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
const isNativeShimmer = const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
shouldMeasureNativeShimmer &&
labelRowWidth > 0 &&
labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel const webShimmerSpanEndX = secondaryLabel
? secondaryOffsetX + secondaryWidth ? secondaryOffsetX + secondaryWidth
: labelOffsetX + labelWidth; : labelOffsetX + labelWidth;
const webShimmerSpanWidth = Math.max( const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
1, const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
webShimmerSpanEndX - webShimmerSpanStartX
);
const webShimmerPeakWidth = Math.max(
42,
Math.min(120, webShimmerSpanWidth * 0.22)
);
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth; const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
const webShimmerTrackEnd = webShimmerSpanEndX; const webShimmerTrackEnd = webShimmerSpanEndX;
const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => { const handleLabelRowLayout = useCallback(
if (!shouldMeasureNativeShimmer) { (event: LayoutChangeEvent) => {
return; if (!shouldMeasureNativeShimmer) {
} return;
const { width, height } = event.nativeEvent.layout; }
setLabelRowWidth((previous) => const { width, height } = event.nativeEvent.layout;
Math.abs(previous - width) > 0.5 ? width : previous setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
); setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
setLabelRowHeight((previous) => },
Math.abs(previous - height) > 0.5 ? height : previous [shouldMeasureNativeShimmer],
); );
}, [shouldMeasureNativeShimmer]);
const handleLabelLayout = useCallback((event: LayoutChangeEvent) => { const handleLabelLayout = useCallback(
if (!shouldMeasureWebShimmer) { (event: LayoutChangeEvent) => {
return; if (!shouldMeasureWebShimmer) {
} return;
const { x, width } = event.nativeEvent.layout; }
setLabelOffsetX((previous) => const { x, width } = event.nativeEvent.layout;
Math.abs(previous - x) > 0.5 ? x : previous setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
); setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
setLabelWidth((previous) => },
Math.abs(previous - width) > 0.5 ? width : previous [shouldMeasureWebShimmer],
); );
}, [shouldMeasureWebShimmer]);
const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => { const handleSecondaryLayout = useCallback(
if (!shouldMeasureWebShimmer || !secondaryLabel) { (event: LayoutChangeEvent) => {
return; if (!shouldMeasureWebShimmer || !secondaryLabel) {
} return;
const { x, width } = event.nativeEvent.layout; }
setSecondaryOffsetX((previous) => const { x, width } = event.nativeEvent.layout;
Math.abs(previous - x) > 0.5 ? x : previous setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
); setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
setSecondaryWidth((previous) => },
Math.abs(previous - width) > 0.5 ? width : previous [shouldMeasureWebShimmer, secondaryLabel],
); );
}, [shouldMeasureWebShimmer, secondaryLabel]);
useEffect(() => { useEffect(() => {
if (!isWebShimmer) { if (!isWebShimmer) {
@@ -1520,18 +1399,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
easing: Easing.linear, easing: Easing.linear,
}), }),
-1, -1,
false false,
); );
return () => { return () => {
cancelAnimation(shimmerTranslateX); cancelAnimation(shimmerTranslateX);
}; };
}, [ }, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
isNativeShimmer,
labelRowWidth,
nativeShimmerPeakWidth,
shimmerDuration,
shimmerTranslateX,
]);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
@@ -1605,23 +1478,21 @@ const ExpandableBadge = memo(function ExpandableBadge({
: expandableBadgeStylesheet.containerSpacing), : expandableBadgeStylesheet.containerSpacing),
style, style,
], ],
[isLastInSequence, resolvedDisableOuterSpacing, style] [isLastInSequence, resolvedDisableOuterSpacing, style],
); );
const pressableStyle = useMemo( const pressableStyle = useMemo(
() => [ () => [
expandableBadgeStylesheet.pressable, expandableBadgeStylesheet.pressable,
isPressed && isInteractive isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
? expandableBadgeStylesheet.pressablePressed
: null,
isExpanded && expandableBadgeStylesheet.pressableExpanded, isExpanded && expandableBadgeStylesheet.pressableExpanded,
], ],
[isExpanded, isInteractive, isPressed] [isExpanded, isInteractive, isPressed],
); );
const accessibilityState = useMemo( const accessibilityState = useMemo(
() => (isInteractive ? { expanded: isExpanded } : undefined), () => (isInteractive ? { expanded: isExpanded } : undefined),
[isExpanded, isInteractive] [isExpanded, isInteractive],
); );
const isActive = isHovered || isExpanded; const isActive = isHovered || isExpanded;
@@ -1632,7 +1503,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
isActive && expandableBadgeStylesheet.labelActive, isActive && expandableBadgeStylesheet.labelActive,
isLoading && expandableBadgeStylesheet.labelLoading, isLoading && expandableBadgeStylesheet.labelLoading,
], ],
[isActive, isLoading] [isActive, isLoading],
); );
const secondaryLabelStyle = useMemo( const secondaryLabelStyle = useMemo(
@@ -1640,7 +1511,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.secondaryLabel, expandableBadgeStylesheet.secondaryLabel,
isActive && expandableBadgeStylesheet.secondaryLabelActive, isActive && expandableBadgeStylesheet.secondaryLabelActive,
], ],
[isActive] [isActive],
); );
const shimmerLabelTextStyle = useMemo( const shimmerLabelTextStyle = useMemo(
@@ -1650,7 +1521,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText, expandableBadgeStylesheet.shimmerText,
shimmerLabelStyle, shimmerLabelStyle,
], ],
[isLoading, shimmerLabelStyle] [isLoading, shimmerLabelStyle],
); );
const shimmerSecondaryTextStyle = useMemo( const shimmerSecondaryTextStyle = useMemo(
@@ -1659,7 +1530,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText, expandableBadgeStylesheet.shimmerText,
shimmerSecondaryStyle, shimmerSecondaryStyle,
], ],
[shimmerSecondaryStyle] [shimmerSecondaryStyle],
); );
const nativeShimmerTrackStyle = useMemo( const nativeShimmerTrackStyle = useMemo(
@@ -1667,7 +1538,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.nativeShimmerTrack, expandableBadgeStylesheet.nativeShimmerTrack,
{ width: labelRowWidth, height: labelRowHeight }, { width: labelRowWidth, height: labelRowHeight },
], ],
[labelRowHeight, labelRowWidth] [labelRowHeight, labelRowWidth],
); );
const nativeShimmerMaskStyle = useMemo( const nativeShimmerMaskStyle = useMemo(
@@ -1675,20 +1546,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerMaskRow, expandableBadgeStylesheet.shimmerMaskRow,
{ width: labelRowWidth, height: labelRowHeight }, { width: labelRowWidth, height: labelRowHeight },
], ],
[labelRowHeight, labelRowWidth] [labelRowHeight, labelRowWidth],
); );
const nativeLabelMaskStyle = useMemo( const nativeLabelMaskStyle = useMemo(
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }], () => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
[] [],
); );
const nativeSecondaryMaskStyle = useMemo( const nativeSecondaryMaskStyle = useMemo(
() => [ () => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
expandableBadgeStylesheet.secondaryLabel, [],
{ color: "#000000", opacity: 1 },
],
[]
); );
const nativeShimmerPeakCombinedStyle = useMemo( const nativeShimmerPeakCombinedStyle = useMemo(
@@ -1697,7 +1565,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
nativeShimmerPeakStyle, nativeShimmerPeakStyle,
{ width: nativeShimmerPeakWidth, height: labelRowHeight }, { width: nativeShimmerPeakWidth, height: labelRowHeight },
], ],
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth] [labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
); );
const chevronStyle = useMemo( const chevronStyle = useMemo(
@@ -1705,7 +1573,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.chevron, expandableBadgeStylesheet.chevron,
isExpanded && expandableBadgeStylesheet.chevronExpanded, isExpanded && expandableBadgeStylesheet.chevronExpanded,
], ],
[isExpanded] [isExpanded],
); );
const IconComponent = icon; const IconComponent = icon;
@@ -1723,10 +1591,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
} }
return ( return (
<View <View style={containerStyle} testID={testID}>
style={containerStyle}
testID={testID}
>
<Pressable <Pressable
onPress={isInteractive ? onToggle : undefined} onPress={isInteractive ? onToggle : undefined}
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined} onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
@@ -1770,21 +1635,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
<View style={expandableBadgeStylesheet.spacer} /> <View style={expandableBadgeStylesheet.spacer} />
)} )}
{isWebShimmer ? ( {isWebShimmer ? (
<View <View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
style={expandableBadgeStylesheet.shimmerOverlay} <Text style={shimmerLabelTextStyle} numberOfLines={1}>
pointerEvents="none"
>
<Text
style={shimmerLabelTextStyle}
numberOfLines={1}
>
{label} {label}
</Text> </Text>
{secondaryLabel ? ( {secondaryLabel ? (
<Text <Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
style={shimmerSecondaryTextStyle}
numberOfLines={1}
>
{secondaryLabel} {secondaryLabel}
</Text> </Text>
) : ( ) : (
@@ -1793,25 +1649,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View> </View>
) : null} ) : null}
{isNativeShimmer ? ( {isNativeShimmer ? (
<View <View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<MaskedView <MaskedView
style={nativeShimmerTrackStyle} style={nativeShimmerTrackStyle}
maskElement={ maskElement={
<View style={nativeShimmerMaskStyle}> <View style={nativeShimmerMaskStyle}>
<Text <Text style={nativeLabelMaskStyle} numberOfLines={1}>
style={nativeLabelMaskStyle}
numberOfLines={1}
>
{label} {label}
</Text> </Text>
{secondaryLabel ? ( {secondaryLabel ? (
<Text <Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
style={nativeSecondaryMaskStyle}
numberOfLines={1}
>
{secondaryLabel} {secondaryLabel}
</Text> </Text>
) : ( ) : (
@@ -1820,9 +1667,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View> </View>
} }
> >
<View <View style={nativeShimmerTrackStyle}>
style={nativeShimmerTrackStyle}
>
<Animated.View style={nativeShimmerPeakCombinedStyle}> <Animated.View style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none"> <Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs> <Defs>
@@ -1853,11 +1698,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null} ) : null}
</View> </View>
{isInteractive && isHovered ? ( {isInteractive && isHovered ? (
<ChevronRight <ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
size={14}
color={theme.colors.foreground}
style={chevronStyle}
/>
) : null} ) : null}
</View> </View>
</Pressable> </Pressable>
@@ -1875,10 +1716,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
); );
}, areExpandableBadgePropsEqual); }, areExpandableBadgePropsEqual);
function areExpandableBadgePropsEqual( function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
previous: ExpandableBadgeProps,
next: ExpandableBadgeProps
) {
if (previous.label !== next.label) return false; if (previous.label !== next.label) return false;
if (previous.secondaryLabel !== next.secondaryLabel) return false; if (previous.secondaryLabel !== next.secondaryLabel) return false;
if (previous.icon !== next.icon) return false; if (previous.icon !== next.icon) return false;
@@ -1932,9 +1770,7 @@ export const ToolCall = memo(function ToolCall({
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand) // Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => { const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) { if (detail) {
@@ -1950,12 +1786,11 @@ export const ToolCall = memo(function ToolCall({
return undefined; return undefined;
}, [detail, args, result]); }, [detail, args, result]);
const displayDetail = const displayDetail = effectiveDetail ?? {
effectiveDetail ?? { type: "unknown",
type: "unknown", input: null,
input: null, output: null,
output: null, };
};
const displayModel = useMemo( const displayModel = useMemo(
() => () =>
@@ -1967,7 +1802,7 @@ export const ToolCall = memo(function ToolCall({
metadata, metadata,
cwd, cwd,
}), }),
[toolName, status, error, displayDetail, metadata, cwd] [toolName, status, error, displayDetail, metadata, cwd],
); );
const displayName = displayModel.displayName; const displayName = displayModel.displayName;
const summary = displayModel.summary; const summary = displayModel.summary;
@@ -1981,9 +1816,7 @@ export const ToolCall = memo(function ToolCall({
const secondaryLabel = summary; const secondaryLabel = summary;
// Check if there's any content to display // Check if there's any content to display
const hasDetails = const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
Boolean(error) ||
hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails; const canOpenDetails = hasDetails || isLoadingDetails;
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
@@ -18,12 +18,7 @@ export function ModeSelectorModal({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
return ( return (
<Modal <Modal visible={visible} animationType="fade" transparent={true} onRequestClose={onClose}>
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
>
<Pressable style={styles.modalOverlay} onPress={onClose}> <Pressable style={styles.modalOverlay} onPress={onClose}>
<View style={styles.modeSelectorContent}> <View style={styles.modeSelectorContent}>
{agent?.availableModes?.map((mode) => { {agent?.availableModes?.map((mode) => {
@@ -35,26 +30,13 @@ export function ModeSelectorModal({
onModeChange(mode.id); onModeChange(mode.id);
onClose(); onClose();
}} }}
style={[ style={[styles.modeItem, isActive && styles.modeItemActive]}
styles.modeItem,
isActive && styles.modeItemActive,
]}
> >
<Text <Text style={[styles.modeName, isActive && styles.modeNameActive]}>
style={[
styles.modeName,
isActive && styles.modeNameActive,
]}
>
{mode.label} {mode.label}
</Text> </Text>
{mode.description && ( {mode.description && (
<Text <Text style={[styles.modeDescription, isActive && styles.modeDescriptionActive]}>
style={[
styles.modeDescription,
isActive && styles.modeDescriptionActive,
]}
>
{mode.description} {mode.description}
</Text> </Text>
)} )}
@@ -81,10 +81,13 @@ export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: N
}, [label, onSave, onSkip]); }, [label, onSave, onSkip]);
return ( return (
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal"> <AdaptiveModalSheet
<Text style={styles.helper}> title="Name this host"
Optional. You can rename this later in Settings. visible={visible}
</Text> onClose={onSkip}
testID="name-host-modal"
>
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
<View style={styles.field}> <View style={styles.field}>
<Text style={styles.label}>Label</Text> <Text style={styles.label}>Label</Text>
@@ -48,15 +48,25 @@ export interface PairLinkModalProps {
onClose: () => void; onClose: () => void;
targetServerId?: string; targetServerId?: string;
onCancel?: () => void; onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
} }
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) { export function PairLinkModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: PairLinkModalProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const [offerUrl, setOfferUrl] = useState(""); const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -148,10 +158,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]); }, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
return ( return (
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal"> <AdaptiveModalSheet
title="Paste pairing link"
visible={visible}
onClose={handleClose}
testID="pair-link-modal"
>
<Text style={styles.helper}>Paste the pairing link from your server.</Text> <Text style={styles.helper}>Paste the pairing link from your server.</Text>
<View style={styles.field}> <View style={styles.field}>
@@ -1,13 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -37,7 +29,7 @@ export function ProjectPickerModal() {
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) => const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined serverId ? state.sessions[serverId]?.workspaces : undefined,
); );
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
@@ -49,7 +41,7 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => { const recommendedPaths = useMemo(() => {
if (!workspaces) return []; if (!workspaces) return [];
return Array.from(workspaces.values()).map( return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id (workspace) => workspace.projectRootPath || workspace.id,
); );
}, [workspaces]); }, [workspaces]);
@@ -64,9 +56,7 @@ export function ProjectPickerModal() {
limit: 30, limit: 30,
}); });
return ( return (
result.entries?.flatMap((entry) => result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
entry.kind === "directory" ? [entry.path] : []
) ?? []
); );
}, },
enabled: Boolean(client) && isConnected && open, enabled: Boolean(client) && isConnected && open,
@@ -81,7 +71,7 @@ export function ProjectPickerModal() {
serverPaths: directorySuggestionsQuery.data ?? [], serverPaths: directorySuggestionsQuery.data ?? [],
query, query,
}), }),
[query, directorySuggestionsQuery.data, recommendedPaths] [query, directorySuggestionsQuery.data, recommendedPaths],
); );
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -103,7 +93,7 @@ export function ProjectPickerModal() {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, },
[client, openProject, serverId, setOpen] [client, openProject, serverId, setOpen],
); );
const handleSubmitCustom = useCallback(() => { const handleSubmitCustom = useCallback(() => {
@@ -136,13 +126,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) { function handler(event: KeyboardEvent) {
const key = event.key; const key = event.key;
if ( if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "Enter" &&
key !== "Escape"
)
return;
if (key === "Escape") { if (key === "Escape") {
event.preventDefault(); event.preventDefault();
@@ -180,12 +164,7 @@ export function ProjectPickerModal() {
if (!serverId) return null; if (!serverId) return null;
return ( return (
<Modal <Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<View style={styles.overlay}> <View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} /> <Pressable style={styles.backdrop} onPress={handleClose} />
@@ -198,9 +177,7 @@ export function ProjectPickerModal() {
}, },
]} ]}
> >
<View <View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
style={[styles.header, { borderBottomColor: theme.colors.border }]}
>
<TextInput <TextInput
ref={inputRef} ref={inputRef}
value={query} value={query}
@@ -225,21 +202,11 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{isSubmitting ? ( {isSubmitting ? (
<Text <Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
Opening project... Opening project...
</Text> </Text>
) : options.length === 0 && !query.trim() ? ( ) : options.length === 0 && !query.trim() ? (
<Text <Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
Start typing a path Start typing a path
</Text> </Text>
) : ( ) : (
@@ -266,10 +233,7 @@ export function ProjectPickerModal() {
/> />
</View> </View>
<Text <Text
style={[ style={[styles.rowText, { color: theme.colors.foreground }]}
styles.rowText,
{ color: theme.colors.foreground },
]}
numberOfLines={1} numberOfLines={1}
> >
{shortenPath(path)} {shortenPath(path)}
@@ -1,12 +1,12 @@
import { Bot } from 'lucide-react-native' import { Bot } from "lucide-react-native";
import { ClaudeIcon } from '@/components/icons/claude-icon' import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from '@/components/icons/codex-icon' import { CodexIcon } from "@/components/icons/codex-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = { const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot, claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot, codex: CodexIcon as unknown as typeof Bot,
} };
export function getProviderIcon(provider: string): typeof Bot { export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot return PROVIDER_ICONS[provider] ?? Bot;
} }
@@ -1,12 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
Platform,
} from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { Check, CircleHelp, X } from "lucide-react-native"; import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
@@ -68,52 +61,42 @@ interface QuestionFormCardProps {
const IS_WEB = Platform.OS === "web"; const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({ export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
permission,
onRespond,
isResponding,
}: QuestionFormCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input); const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({}); const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({}); const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState< const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
"submit" | "dismiss" | null
>(null);
const toggleOption = useCallback( const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
(qIndex: number, optIndex: number, multiSelect: boolean) => { setSelections((prev) => {
setSelections((prev) => { const current = prev[qIndex] ?? new Set<number>();
const current = prev[qIndex] ?? new Set<number>(); const next = new Set(current);
const next = new Set(current); if (multiSelect) {
if (multiSelect) { if (next.has(optIndex)) {
if (next.has(optIndex)) { next.delete(optIndex);
next.delete(optIndex);
} else {
next.add(optIndex);
}
} else { } else {
if (next.has(optIndex)) { next.add(optIndex);
next.clear();
} else {
next.clear();
next.add(optIndex);
}
} }
return { ...prev, [qIndex]: next }; } else {
}); if (next.has(optIndex)) {
setOtherTexts((prev) => { next.clear();
if (!prev[qIndex]) return prev; } else {
const next = { ...prev }; next.clear();
delete next[qIndex]; next.add(optIndex);
return next; }
}); }
}, return { ...prev, [qIndex]: next };
[] });
); setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
const setOtherText = useCallback((qIndex: number, text: string) => { const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -182,9 +165,7 @@ export function QuestionFormCard({
return ( return (
<View key={qIndex} style={styles.questionBlock}> <View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}> <View style={styles.questionHeader}>
<Text <Text style={[styles.questionText, { color: theme.colors.foreground }]}>
style={[styles.questionText, { color: theme.colors.foreground }]}
>
{q.question} {q.question}
</Text> </Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} /> <CircleHelp size={14} color={theme.colors.foregroundMuted} />
@@ -202,19 +183,12 @@ export function QuestionFormCard({
}, },
pressed && styles.optionItemPressed, pressed && styles.optionItemPressed,
]} ]}
onPress={() => onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
toggleOption(qIndex, optIndex, q.multiSelect)
}
disabled={isResponding} disabled={isResponding}
> >
<View style={styles.optionItemContent}> <View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}> <View style={styles.optionTextBlock}>
<Text <Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
style={[
styles.optionLabel,
{ color: theme.colors.foreground },
]}
>
{opt.label} {opt.label}
</Text> </Text>
{opt.description ? ( {opt.description ? (
@@ -230,10 +204,7 @@ export function QuestionFormCard({
</View> </View>
{isSelected ? ( {isSelected ? (
<View style={styles.optionCheckSlot}> <View style={styles.optionCheckSlot}>
<Check <Check size={16} color={theme.colors.foregroundMuted} />
size={16}
color={theme.colors.foregroundMuted}
/>
</View> </View>
) : null} ) : null}
</View> </View>
@@ -245,9 +216,8 @@ export function QuestionFormCard({
style={[ style={[
styles.otherInput, styles.otherInput,
{ {
borderColor: otherText.length > 0 borderColor:
? theme.colors.borderAccent otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
: theme.colors.border,
color: theme.colors.foreground, color: theme.colors.foreground,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
@@ -264,19 +234,12 @@ export function QuestionFormCard({
); );
})} })}
<View <View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
style={[
styles.actionsContainer,
!isMobile && styles.actionsContainerDesktop,
]}
>
<Pressable <Pressable
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.actionButton, styles.actionButton,
{ {
backgroundColor: hovered backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
? theme.colors.surface2
: theme.colors.surface1,
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
}, },
pressed && styles.optionItemPressed, pressed && styles.optionItemPressed,
@@ -285,19 +248,11 @@ export function QuestionFormCard({
disabled={isResponding} disabled={isResponding}
> >
{respondingAction === "dismiss" ? ( {respondingAction === "dismiss" ? (
<ActivityIndicator <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
size="small"
color={theme.colors.foregroundMuted}
/>
) : ( ) : (
<View style={styles.actionContent}> <View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} /> <X size={14} color={theme.colors.foregroundMuted} />
<Text <Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
style={[
styles.actionText,
{ color: theme.colors.foregroundMuted },
]}
>
Dismiss Dismiss
</Text> </Text>
</View> </View>
@@ -311,12 +266,8 @@ export function QuestionFormCard({
styles.actionButton, styles.actionButton,
{ {
backgroundColor: backgroundColor:
hovered && !disabled hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1,
? theme.colors.surface2 borderColor: disabled ? theme.colors.border : theme.colors.borderAccent,
: theme.colors.surface1,
borderColor: disabled
? theme.colors.border
: theme.colors.borderAccent,
opacity: disabled ? 0.5 : 1, opacity: disabled ? 0.5 : 1,
}, },
pressed && !disabled ? styles.optionItemPressed : null, pressed && !disabled ? styles.optionItemPressed : null,
@@ -326,27 +277,18 @@ export function QuestionFormCard({
disabled={!allAnswered || isResponding} disabled={!allAnswered || isResponding}
> >
{respondingAction === "submit" ? ( {respondingAction === "submit" ? (
<ActivityIndicator <ActivityIndicator size="small" color={theme.colors.foreground} />
size="small"
color={theme.colors.foreground}
/>
) : ( ) : (
<View style={styles.actionContent}> <View style={styles.actionContent}>
<Check <Check
size={14} size={14}
color={ color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/> />
<Text <Text
style={[ style={[
styles.actionText, styles.actionText,
{ {
color: allAnswered color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
? theme.colors.foreground
: theme.colors.foregroundMuted,
}, },
]} ]}
> >
@@ -73,8 +73,7 @@ export function ResizeHandle({
return; return;
} }
const pointerCurrent = const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio = const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize; (pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
@@ -91,7 +90,7 @@ export function ResizeHandle({
window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true }); window.addEventListener("pointerup", handlePointerUp, { once: true });
}, },
[direction, groupId, index, onResizeSplit, sizes] [direction, groupId, index, onResizeSplit, sizes],
); );
return ( return (
@@ -107,9 +106,7 @@ export function ResizeHandle({
pointerEvents="none" pointerEvents="none"
style={[ style={[
styles.highlight, styles.highlight,
direction === "horizontal" direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
? styles.highlightHorizontal
: styles.highlightVertical,
{ backgroundColor: theme.colors.accent }, { backgroundColor: theme.colors.accent },
]} ]}
/> />
@@ -2,13 +2,7 @@ import { useEffect, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native"; import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
function SkeletonPulse({ function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
const opacity = pulse.interpolate({ const opacity = pulse.interpolate({
inputRange: [0, 1], inputRange: [0, 1],
outputRange: [0.4, 0.8], outputRange: [0.4, 0.8],
@@ -33,7 +27,7 @@ export function SidebarAgentListSkeleton() {
duration: 1000, duration: 1000,
useNativeDriver: true, useNativeDriver: true,
}), }),
]) ]),
); );
animation.start(); animation.start();
@@ -55,10 +49,7 @@ export function SidebarAgentListSkeleton() {
<View style={styles.rows}> <View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => ( {Array.from({ length: 3 }).map((__, rowIdx) => (
<View <View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
key={`skeleton-row-${sectionIdx}-${rowIdx}`}
style={styles.row}
>
<SkeletonPulse pulse={pulse} style={styles.rowDot} /> <SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} /> <SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} /> <SkeletonPulse pulse={pulse} style={styles.rowBadge} />
File diff suppressed because it is too large Load Diff
@@ -5,4 +5,3 @@
// Metro's platform-specific extensions will pick the right one at runtime. // Metro's platform-specific extensions will pick the right one at runtime.
export * from "./sortable-inline-list.native"; export * from "./sortable-inline-list.native";
@@ -66,7 +66,7 @@ function SortableItem<T>({
const baseTransform = externalDndContext const baseTransform = externalDndContext
? undefined ? undefined
: CSS.Transform.toString( : CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
); );
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : ""; const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" "); const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -87,9 +87,7 @@ function SortableItem<T>({
? { ? {
attributes: attributes as unknown as Record<string, unknown>, attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>, listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as ( setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
node: unknown
) => void,
} }
: undefined, : undefined,
}; };
@@ -132,7 +130,7 @@ export function SortableInlineList<T>({
}): ReactElement { }): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null); const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = externalDndContext ? data : dragItems ?? data; const items = externalDndContext ? data : (dragItems ?? data);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -142,7 +140,7 @@ export function SortableInlineList<T>({
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) }),
); );
const handleDragStart = useCallback( const handleDragStart = useCallback(
@@ -154,7 +152,7 @@ export function SortableInlineList<T>({
setActiveId(String(event.active.id)); setActiveId(String(event.active.id));
onDragBegin?.(); onDragBegin?.();
}, },
[data, disabled, onDragBegin] [data, disabled, onDragBegin],
); );
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
@@ -169,21 +167,16 @@ export function SortableInlineList<T>({
} }
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
const oldIndex = items.findIndex( const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
(item, i) => keyExtractor(item, i) === active.id const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex); const newItems = arrayMove(items, oldIndex, newIndex);
onDragEnd?.(newItems); onDragEnd?.(newItems);
} }
} }
}, },
[disabled, items, keyExtractor, onDragEnd] [disabled, items, keyExtractor, onDragEnd],
); );
const ids = items.map((item, index) => keyExtractor(item, index)); const ids = items.map((item, index) => keyExtractor(item, index));
@@ -5,13 +5,13 @@ describe("shouldFocusPaneFromEventTarget", () => {
it("returns false for links and buttons", () => { it("returns false for links and buttons", () => {
expect( expect(
shouldFocusPaneFromEventTarget({ shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "A" } as Element), closest: () => ({ tagName: "A" }) as Element,
} as unknown as EventTarget) } as unknown as EventTarget),
).toBe(false); ).toBe(false);
expect( expect(
shouldFocusPaneFromEventTarget({ shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "BUTTON" } as Element), closest: () => ({ tagName: "BUTTON" }) as Element,
} as unknown as EventTarget) } as unknown as EventTarget),
).toBe(false); ).toBe(false);
}); });
@@ -19,7 +19,7 @@ describe("shouldFocusPaneFromEventTarget", () => {
expect( expect(
shouldFocusPaneFromEventTarget({ shouldFocusPaneFromEventTarget({
closest: () => null, closest: () => null,
} as unknown as EventTarget) } as unknown as EventTarget),
).toBe(true); ).toBe(true);
expect(shouldFocusPaneFromEventTarget(null)).toBe(true); expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
}); });
@@ -27,7 +27,7 @@ describe("computeTabDropPreview", () => {
targetTabs, targetTabs,
activeRect: { left: 180, width: 40 }, activeRect: { left: 180, width: 40 },
overRect: { left: 200, width: 100 }, overRect: { left: 200, width: 100 },
}) }),
).toEqual({ ).toEqual({
paneId: "target", paneId: "target",
insertionIndex: 2, insertionIndex: 2,
@@ -45,7 +45,7 @@ describe("computeTabDropPreview", () => {
targetTabs, targetTabs,
activeRect: { left: 280, width: 40 }, activeRect: { left: 280, width: 40 },
overRect: { left: 200, width: 100 }, overRect: { left: 200, width: 100 },
}) }),
).toEqual({ ).toEqual({
paneId: "target", paneId: "target",
insertionIndex: 3, insertionIndex: 3,
@@ -63,7 +63,7 @@ describe("computeTabDropPreview", () => {
targetTabs, targetTabs,
activeRect: { left: 460, width: 40 }, activeRect: { left: 460, width: 40 },
overRect: { left: 400, width: 100 }, overRect: { left: 400, width: 100 },
}) }),
).toEqual({ ).toEqual({
paneId: "pane", paneId: "pane",
insertionIndex: 3, insertionIndex: 3,
@@ -22,9 +22,7 @@ interface ComputeTabDropPreviewInput {
}; };
} }
export function computeTabDropPreview( export function computeTabDropPreview(input: ComputeTabDropPreviewInput): TabDropPreview | null {
input: ComputeTabDropPreviewInput
): TabDropPreview | null {
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId); const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
if (targetIndex < 0 || input.overRect.width <= 0) { if (targetIndex < 0 || input.overRect.width <= 0) {
return null; return null;
+64 -50
View File
@@ -1,4 +1,14 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import { import {
DndContext, DndContext,
DragOverlay, DragOverlay,
@@ -45,7 +55,12 @@ import {
WorkspaceTabIcon, WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation"; } from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store"; import {
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
interface SplitContainerProps { interface SplitContainerProps {
@@ -65,7 +80,10 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__" | "__new_tab_terminal__"; paneId?: string }) => void; onSelectNewTabOption: (selection: {
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
paneId?: string;
}) => void;
onNewTerminalTab: (input: { paneId?: string }) => void; onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__"; newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
@@ -100,8 +118,7 @@ interface SplitPaneDropData {
paneId: string; paneId: string;
} }
interface SplitNodeViewProps interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout"> {
extends Omit<SplitContainerProps, "layout"> {
node: SplitNode; node: SplitNode;
uiTabs: WorkspaceTab[]; uiTabs: WorkspaceTab[];
focusedPaneId: string; focusedPaneId: string;
@@ -135,14 +152,14 @@ interface SplitPaneViewProps
const dropCollisionDetection: CollisionDetection = (args) => { const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args); const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter( const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab" (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
); );
if (tabHits.length > 0) { if (tabHits.length > 0) {
return tabHits; return tabHits;
} }
const paneHits = pointerHits.filter( const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop" (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
); );
if (paneHits.length > 0) { if (paneHits.length > 0) {
return paneHits; return paneHits;
@@ -192,7 +209,7 @@ export function SplitContainer({
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) }),
); );
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
@@ -215,11 +232,7 @@ export function SplitContainer({
}, []); }, []);
const updateDropPreview = useCallback( const updateDropPreview = useCallback(
( (event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
event:
| Pick<DragMoveEvent, "active" | "over">
| Pick<DragOverEvent, "active" | "over">
) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined; const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as const overData = event.over?.data.current as
| WorkspaceTabDragData | WorkspaceTabDragData
@@ -268,7 +281,7 @@ export function SplitContainer({
left: overRect.left, left: overRect.left,
width: overRect.width, width: overRect.width,
}, },
}) }),
); );
return; return;
} }
@@ -305,7 +318,7 @@ export function SplitContainer({
}), }),
}); });
}, },
[panesById, uiTabs] [panesById, uiTabs],
); );
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
@@ -346,8 +359,15 @@ export function SplitContainer({
if (activeData.paneId === overData.paneId) { if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex); const nextTabs = arrayMove(
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId)); sourceTabs,
sourceIndex,
resolvedTabDropPreview.insertionIndex,
);
onReorderTabsInPane(
activeData.paneId,
nextTabs.map((tab) => tab.tabId),
);
} }
setDropPreview(null); setDropPreview(null);
setTabDropPreview(null); setTabDropPreview(null);
@@ -383,7 +403,15 @@ export function SplitContainer({
setDropPreview(null); setDropPreview(null);
setTabDropPreview(null); setTabDropPreview(null);
}, },
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs] [
dropPreview,
onMoveTabToPane,
onReorderTabsInPane,
onSplitPane,
panesById,
tabDropPreview,
uiTabs,
],
); );
return ( return (
@@ -492,8 +520,7 @@ function DragOverlayTabChipInner({
workspaceId={normalizedWorkspaceId} workspaceId={normalizedWorkspaceId}
> >
{(presentation) => { {(presentation) => {
const label = const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
presentation.titleState === "loading" ? "Loading..." : presentation.label;
return ( return (
<View <View
@@ -508,10 +535,7 @@ function DragOverlayTabChipInner({
<WorkspaceTabIcon presentation={presentation} active size={14} /> <WorkspaceTabIcon presentation={presentation} active size={14} />
<Text <Text
numberOfLines={1} numberOfLines={1}
style={[ style={[styles.dragOverlayLabel, { color: theme.colors.foreground }]}
styles.dragOverlayLabel,
{ color: theme.colors.foreground },
]}
> >
{label} {label}
</Text> </Text>
@@ -593,8 +617,9 @@ function SplitNodeView({
} }
const groupSizes = const groupSizes =
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ?? useWorkspaceLayoutStore(
node.group.sizes; (state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id],
) ?? node.group.sizes;
return ( return (
<View <View
@@ -695,12 +720,9 @@ function SplitPaneView({
pane, pane,
tabs: uiTabs, tabs: uiTabs,
}), }),
[pane, uiTabs] [pane, uiTabs],
);
const paneTabs = useMemo(
() => paneState.tabs.map((tab) => tab.descriptor),
[paneState.tabs]
); );
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null; const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>( const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() => () =>
@@ -710,12 +732,7 @@ function SplitPaneView({
isCloseHovered: hoveredCloseTabKey === tab.key, isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: closingTabIds.has(tab.tabId), isClosingTab: closingTabIds.has(tab.tabId),
})), })),
[ [activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs],
activeTabDescriptor?.key,
closingTabIds,
hoveredCloseTabKey,
paneTabs,
]
); );
const paneContent = useMemo( const paneContent = useMemo(
() => () =>
@@ -726,11 +743,7 @@ function SplitPaneView({
tab: activeTabDescriptor, tab: activeTabDescriptor,
}) })
: null, : null,
[ [activeTabDescriptor, buildPaneContentModel, pane.id],
activeTabDescriptor,
buildPaneContentModel,
pane.id,
]
); );
useEffect(() => { useEffect(() => {
@@ -771,11 +784,7 @@ function SplitPaneView({
}, [onFocusPane, pane.id]); }, [onFocusPane, pane.id]);
return ( return (
<View <View ref={paneRef} collapsable={false} style={styles.pane}>
ref={paneRef}
collapsable={false}
style={styles.pane}
>
<View style={styles.paneTabs}> <View style={styles.paneTabs}>
<WorkspaceDesktopTabsRow <WorkspaceDesktopTabsRow
paneId={pane.id} paneId={pane.id}
@@ -796,13 +805,18 @@ function SplitPaneView({
onNewTerminalTab={onNewTerminalTab} onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"} newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onReorderTabs={(nextTabs) => { onReorderTabs={(nextTabs) => {
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId)); onReorderTabsInPane(
pane.id,
nextTabs.map((tab) => tab.tabId),
);
}} }}
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })} onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })} onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
externalDndContext externalDndContext
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null} tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/> />
</View> </View>
@@ -810,7 +824,7 @@ function SplitPaneView({
{paneContent ? ( {paneContent ? (
<WorkspacePaneContent content={paneContent} /> <WorkspacePaneContent content={paneContent} />
) : ( ) : (
renderPaneEmptyState?.() ?? null (renderPaneEmptyState?.() ?? null)
)} )}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} /> <SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View> </View>
@@ -23,11 +23,7 @@ export function buildSplitDropZoneId(paneId: string): string {
return `split-pane-drop:${paneId}`; return `split-pane-drop:${paneId}`;
} }
export function SplitDropZone({ export function SplitDropZone({ paneId, active, preview }: SplitDropZoneProps) {
paneId,
active,
preview,
}: SplitDropZoneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { setNodeRef } = useDroppable({ const { setNodeRef } = useDroppable({
id: buildSplitDropZoneId(paneId), id: buildSplitDropZoneId(paneId),
@@ -66,11 +62,7 @@ export function SplitDropZone({
} }
return ( return (
<View <View ref={setNodeRef as any} style={styles.overlay} pointerEvents="none">
ref={setNodeRef as any}
style={styles.overlay}
pointerEvents="none"
>
{previewStyles ? ( {previewStyles ? (
<> <>
<View pointerEvents="none" style={previewStyles.overlay} /> <View pointerEvents="none" style={previewStyles.overlay} />
@@ -89,10 +81,8 @@ export function resolveSplitDropPosition(input: {
}): SplitDropZonePosition { }): SplitDropZonePosition {
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2); const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2); const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
const insideCenterX = const insideCenterX = input.x >= centerInsetX && input.x <= input.width - centerInsetX;
input.x >= centerInsetX && input.x <= input.width - centerInsetX; const insideCenterY = input.y >= centerInsetY && input.y <= input.height - centerInsetY;
const insideCenterY =
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
if (insideCenterX && insideCenterY) { if (insideCenterX && insideCenterY) {
return "center"; return "center";
@@ -9,11 +9,7 @@ import {
} from "react-native"; } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { useBottomAnchorController } from "./use-bottom-anchor-controller"; import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type { import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
StreamRenderInput,
StreamStrategy,
StreamViewportHandle,
} from "./stream-strategy";
import { import {
createStreamStrategy, createStreamStrategy,
isNearBottomForStreamRenderStrategy, isNearBottomForStreamRenderStrategy,
@@ -92,7 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
strategy, strategy,
isViewportSettling: isNativeViewportSettling, isViewportSettling: isNativeViewportSettling,
}), }),
[isNativeViewportSettling, strategy] [isNativeViewportSettling, strategy],
); );
const scrollToBottom = useCallback( const scrollToBottom = useCallback(
@@ -109,7 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}; };
onNearBottomChange(true); onNearBottomChange(true);
}, },
[onNearBottomChange] [onNearBottomChange],
); );
const bottomAnchorController = useBottomAnchorController({ const bottomAnchorController = useBottomAnchorController({
@@ -159,7 +155,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const subscriptions = keyboardEvents.map((eventName) => const subscriptions = keyboardEvents.map((eventName) =>
Keyboard.addListener(eventName, () => { Keyboard.addListener(eventName, () => {
markNativeViewportSettling(); markNativeViewportSettling();
}) }),
); );
return () => { return () => {
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
@@ -228,7 +224,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}); });
} }
}, },
[bottomAnchorController, onNearBottomChange, strategy] [bottomAnchorController, onNearBottomChange, strategy],
); );
const handleListLayout = useCallback( const handleListLayout = useCallback(
@@ -257,7 +253,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportHeight, viewportHeight,
}); });
}, },
[bottomAnchorController, markNativeViewportSettling] [bottomAnchorController, markNativeViewportSettling],
); );
const handleContentSizeChange = useCallback( const handleContentSizeChange = useCallback(
@@ -275,7 +271,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight, contentHeight: nextContentHeight,
}); });
}, },
[bottomAnchorController] [bottomAnchorController],
); );
const renderItem = useCallback( const renderItem = useCallback(
@@ -283,7 +279,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows); const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null; return rendered ? <Fragment>{rendered}</Fragment> : null;
}, },
[historyRows, renderers] [historyRows, renderers],
); );
const liveHeaderContent = useMemo(() => { const liveHeaderContent = useMemo(() => {
@@ -336,12 +332,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
export function createNativeStreamStrategy(): StreamStrategy { export function createNativeStreamStrategy(): StreamStrategy {
const strategy = createStreamStrategy({ const strategy = createStreamStrategy({
render: (renderInput) => ( render: (renderInput) => <NativeStreamViewport {...renderInput} strategy={strategy} />,
<NativeStreamViewport
{...renderInput}
strategy={strategy}
/>
),
orderTailReverse: true, orderTailReverse: true,
orderHeadReverse: true, orderHeadReverse: true,
assistantTurnTraversalStep: 1, assistantTurnTraversalStep: 1,
File diff suppressed because it is too large Load Diff
+16 -44
View File
@@ -1,10 +1,7 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native"; import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model";
StreamHistoryBoundary,
StreamRenderSegments,
} from "./agent-stream-render-model";
import type { import type {
BottomAnchorLocalRequest, BottomAnchorLocalRequest,
BottomAnchorRouteRequest, BottomAnchorRouteRequest,
@@ -49,21 +46,9 @@ export type StreamViewportHandle = {
}; };
export type StreamSegmentRenderers = { export type StreamSegmentRenderers = {
renderHistoryVirtualizedRow: ( renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
item: StreamItem, renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
index: number, renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
items: StreamItem[]
) => ReactNode;
renderHistoryMountedRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderLiveHeadRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderLiveAuxiliary: () => ReactNode; renderLiveAuxiliary: () => ReactNode;
}; };
@@ -96,18 +81,16 @@ export interface StreamStrategy {
getNeighborItem: ( getNeighborItem: (
items: StreamItem[], items: StreamItem[],
index: number, index: number,
relation: NeighborRelation relation: NeighborRelation,
) => StreamItem | undefined; ) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string; collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean; isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number; getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: ( getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null, component: ReactElement | ComponentType<any> | null,
gapSize: number gapSize: number,
) => StreamEdgeSlotProps; ) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
| MaintainVisibleContentPositionConfig
| undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean; getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean; getOverlayScrollbarInverted: () => boolean;
@@ -137,15 +120,12 @@ type StreamStrategyConfig = {
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4; const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
export function createStreamStrategy( export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy {
config: StreamStrategyConfig
): StreamStrategy {
return { return {
render: config.render, render: config.render,
orderTail: (streamItems) => orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems, config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) => orderHead: (streamHead) => (config.orderHeadReverse ? [...streamHead].reverse() : streamHead),
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
getNeighborIndex: (index, relation) => getNeighborIndex: (index, relation) =>
relation === "above" relation === "above"
? index + config.assistantTurnTraversalStep ? index + config.assistantTurnTraversalStep
@@ -197,15 +177,14 @@ export function createStreamStrategy(
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted, getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () => shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion, config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () => shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange,
config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom, shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList, shouldUseVirtualizedList: () => config.useVirtualizedList,
}; };
} }
export function resolveStreamRenderStrategy( export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput input: ResolveStreamRenderStrategyInput,
): StreamStrategy { ): StreamStrategy {
if (input.platform === "web") { if (input.platform === "web") {
return createWebStreamStrategy({ return createWebStreamStrategy({
@@ -226,7 +205,7 @@ export function resolveBottomAnchorTransportBehavior(input: {
return { return {
verificationDelayFrames: Math.max( verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames, baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES,
), ),
verificationRetryMode: "recheck", verificationRetryMode: "recheck",
}; };
@@ -260,11 +239,7 @@ export function getStreamNeighborItem(params: {
index: number; index: number;
relation: NeighborRelation; relation: NeighborRelation;
}): StreamItem | undefined { }): StreamItem | undefined {
return params.strategy.getNeighborItem( return params.strategy.getNeighborItem(params.items, params.index, params.relation);
params.items,
params.index,
params.relation
);
} }
export function collectAssistantTurnContentForStreamRenderStrategy(params: { export function collectAssistantTurnContentForStreamRenderStrategy(params: {
@@ -272,14 +247,11 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
items: StreamItem[]; items: StreamItem[];
startIndex: number; startIndex: number;
}): string { }): string {
return params.strategy.collectAssistantTurnContent( return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
params.items,
params.startIndex
);
} }
export function isNearBottomForStreamRenderStrategy( export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamStrategy } params: StreamNearBottomInput & { strategy: StreamStrategy },
): boolean { ): boolean {
return params.strategy.isNearBottom({ return params.strategy.isNearBottom({
offsetY: params.offsetY, offsetY: params.offsetY,
@@ -292,7 +264,7 @@ export function isNearBottomForStreamRenderStrategy(
export function getBottomOffsetForStreamRenderStrategy( export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & { params: StreamViewportMetrics & {
strategy: StreamStrategy; strategy: StreamStrategy;
} },
): number { ): number {
return params.strategy.getBottomOffset({ return params.strategy.getBottomOffset({
contentHeight: params.contentHeight, contentHeight: params.contentHeight,
+5 -14
View File
@@ -26,8 +26,7 @@ function ensureSharedStepLoopStarted(): void {
} }
sharedLoopStarted = true; sharedLoopStarted = true;
const elapsedMs = const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
(Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT; sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
sharedStepProgress.value = withTiming( sharedStepProgress.value = withTiming(
DOT_COUNT, DOT_COUNT,
@@ -47,19 +46,13 @@ function ensureSharedStepLoopStarted(): void {
easing: Easing.linear, easing: Easing.linear,
}), }),
-1, -1,
false false,
); );
} },
); );
} }
export function SyncedLoader({ export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
size = 10,
color,
}: {
size?: number;
color: string;
}) {
useEffect(() => { useEffect(() => {
ensureSharedStepLoopStarted(); ensureSharedStepLoopStarted();
}, []); }, []);
@@ -94,9 +87,7 @@ export function SyncedLoader({
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => { {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS); const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS; const columnIndex = dotIndex % GRID_COLUMNS;
const sequenceIndex = DOT_SEQUENCE.indexOf( const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
dotIndex as (typeof DOT_SEQUENCE)[number]
);
return ( return (
<SpinnerDot <SpinnerDot
@@ -318,10 +318,7 @@ export default function TerminalEmulator({
initialOutputText, initialOutputText,
theme: mountedThemeRef.current, theme: mountedThemeRef.current,
}); });
appliedChunkSequenceRef.current = Math.max( appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence));
0,
Math.floor(initialOutputChunkSequence)
);
return () => { return () => {
runtime.unmount(); runtime.unmount();
@@ -469,10 +466,7 @@ export default function TerminalEmulator({
}, [streamKey]); }, [streamKey]);
useEffect(() => { useEffect(() => {
const maxScrollOffset = Math.max( const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
0,
viewportMetrics.contentSize - viewportMetrics.viewportSize
);
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset); const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) { if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
setIsScrollVisible(false); setIsScrollVisible(false);
@@ -504,11 +498,7 @@ export default function TerminalEmulator({
setIsScrollActive(false); setIsScrollActive(false);
scrollActiveTimeoutRef.current = null; scrollActiveTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS); }, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
}, [ }, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]);
viewportMetrics.contentSize,
viewportMetrics.offset,
viewportMetrics.viewportSize,
]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -528,7 +518,7 @@ export default function TerminalEmulator({
contentSize: viewportMetrics.contentSize, contentSize: viewportMetrics.contentSize,
offset: viewportMetrics.offset, offset: viewportMetrics.offset,
}), }),
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize] [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize],
); );
useEffect(() => { useEffect(() => {
@@ -569,11 +559,7 @@ export default function TerminalEmulator({
window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging); window.removeEventListener("pointercancel", stopDragging);
}; };
}, [ }, [isDraggingScrollbar, scrollbarGeometry.maxHandleOffset, scrollbarGeometry.maxScrollOffset]);
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
]);
const handleVisible = const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered); scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
@@ -590,16 +576,13 @@ export default function TerminalEmulator({
: SCROLLBAR_HANDLE_WIDTH_IDLE; : SCROLLBAR_HANDLE_WIDTH_IDLE;
const thumbRegionOffset = Math.max( const thumbRegionOffset = Math.max(
0, 0,
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING,
); );
const thumbRegionHeight = Math.min( const thumbRegionHeight = Math.min(
viewportMetrics.viewportSize - thumbRegionOffset, viewportMetrics.viewportSize - thumbRegionOffset,
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2 scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2,
);
const handleInsetTop = Math.max(
0,
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
); );
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs = const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS; isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
@@ -677,7 +660,7 @@ export default function TerminalEmulator({
dragStartOffsetRef.current = clamp( dragStartOffsetRef.current = clamp(
viewportMetrics.offset, viewportMetrics.offset,
0, 0,
scrollbarGeometry.maxScrollOffset scrollbarGeometry.maxScrollOffset,
); );
dragStartClientYRef.current = event.clientY; dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true); setIsDraggingScrollbar(true);
@@ -703,8 +686,7 @@ export default function TerminalEmulator({
opacity: handleOpacity, opacity: handleOpacity,
transitionProperty: "opacity, width, background-color", 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`, transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}} }}
/> />
</div> </div>
+72 -134
View File
@@ -1,21 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
ActivityIndicator,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { Plus, X } from "lucide-react-native"; import { Plus, X } from "lucide-react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import Svg, { import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
@@ -28,9 +17,7 @@ import {
} from "@/utils/terminal-keys"; } from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
getWorkspaceTerminalSession,
} from "@/terminal/runtime/workspace-terminal-session";
import { import {
TerminalStreamController, TerminalStreamController,
type TerminalStreamControllerStatus, type TerminalStreamControllerStatus,
@@ -109,13 +96,7 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI
<View style={styles.terminalTabCloseGradient} pointerEvents="none"> <View style={styles.terminalTabCloseGradient} pointerEvents="none">
<Svg width="100%" height="100%" preserveAspectRatio="none"> <Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs> <Defs>
<SvgLinearGradient <SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
id={gradientId}
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<Stop offset="0%" stopColor={color} stopOpacity={0} /> <Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset="10%" stopColor={color} stopOpacity={1} /> <Stop offset="10%" stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} /> <Stop offset="100%" stopColor={color} stopOpacity={1} />
@@ -138,8 +119,7 @@ export function TerminalPane({
const isScreenFocused = useIsFocused(); const isScreenFocused = useIsFocused();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile = const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList); const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
@@ -163,21 +143,18 @@ export function TerminalPane({
scopeKey, scopeKey,
maxOutputChars: MAX_OUTPUT_CHARS, maxOutputChars: MAX_OUTPUT_CHARS,
}), }),
[scopeKey] [scopeKey],
); );
const outputSession = workspaceTerminalSession.outputSession; const outputSession = workspaceTerminalSession.outputSession;
const subscribeOutputSession = useCallback( const subscribeOutputSession = useCallback(
(listener: () => void) => outputSession.subscribe(listener), (listener: () => void) => outputSession.subscribe(listener),
[outputSession] [outputSession],
);
const getOutputSessionState = useCallback(
() => outputSession.getState(),
[outputSession]
); );
const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]);
const outputState = useSyncExternalStore( const outputState = useSyncExternalStore(
subscribeOutputSession, subscribeOutputSession,
getOutputSessionState, getOutputSessionState,
getOutputSessionState getOutputSessionState,
); );
const selectedOutputState = useMemo(() => { const selectedOutputState = useMemo(() => {
if (outputState.selectedTerminalId === selectedTerminalId) { if (outputState.selectedTerminalId === selectedTerminalId) {
@@ -204,9 +181,7 @@ export function TerminalPane({
const [focusRequestToken, setFocusRequestToken] = useState(0); const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null); const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>( const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
null
);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId); const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]); const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
@@ -217,7 +192,7 @@ export function TerminalPane({
(next: string | null) => { (next: string | null) => {
onSelectedTerminalIdChange?.(next); onSelectedTerminalIdChange?.(next);
}, },
[onSelectedTerminalIdChange] [onSelectedTerminalIdChange],
); );
useEffect(() => { useEffect(() => {
@@ -237,7 +212,7 @@ export function TerminalPane({
clearHoverOutTimeout(); clearHoverOutTimeout();
setHoveredTerminalId(terminalId); setHoveredTerminalId(terminalId);
}, },
[clearHoverOutTimeout] [clearHoverOutTimeout],
); );
const handleTerminalTabHoverOut = useCallback( const handleTerminalTabHoverOut = useCallback(
@@ -245,12 +220,10 @@ export function TerminalPane({
clearHoverOutTimeout(); clearHoverOutTimeout();
hoverOutTimeoutRef.current = setTimeout(() => { hoverOutTimeoutRef.current = setTimeout(() => {
setHoveredTerminalId((current) => (current === terminalId ? null : current)); setHoveredTerminalId((current) => (current === terminalId ? null : current));
setHoveredCloseTerminalId((current) => setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
current === terminalId ? null : current
);
}, 50); }, 50);
}, },
[clearHoverOutTimeout] [clearHoverOutTimeout],
); );
const handleTerminalCloseHoverIn = useCallback( const handleTerminalCloseHoverIn = useCallback(
@@ -259,7 +232,7 @@ export function TerminalPane({
setHoveredTerminalId(terminalId); setHoveredTerminalId(terminalId);
setHoveredCloseTerminalId(terminalId); setHoveredCloseTerminalId(terminalId);
}, },
[clearHoverOutTimeout] [clearHoverOutTimeout],
); );
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => { const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
@@ -308,7 +281,7 @@ export function TerminalPane({
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => { setTimeout(() => {
requestTerminalReflow(); requestTerminalReflow();
}, delayMs) }, delayMs),
); );
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]); }, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
@@ -324,7 +297,7 @@ export function TerminalPane({
} }
runOnJS(pulseKeyboardRefits)(); runOnJS(pulseKeyboardRefits)();
}, },
[pulseKeyboardRefits] [pulseKeyboardRefits],
); );
useFocusEffect( useFocusEffect(
@@ -337,7 +310,7 @@ export function TerminalPane({
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => { setTimeout(() => {
requestTerminalReflow(); requestTerminalReflow();
}, delayMs) }, delayMs),
); );
return () => { return () => {
@@ -345,7 +318,7 @@ export function TerminalPane({
clearTimeout(handle); clearTimeout(handle);
} }
}; };
}, [requestTerminalReflow, selectedTerminalId]) }, [requestTerminalReflow, selectedTerminalId]),
); );
const terminalsQuery = useQuery({ const terminalsQuery = useQuery({
@@ -386,12 +359,7 @@ export function TerminalPane({
}, [client, isConnected]); }, [client, isConnected]);
useEffect(() => { useEffect(() => {
if ( if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) {
!manageTerminalDirectorySubscription ||
!client ||
!isConnected ||
!cwd.startsWith("/")
) {
return; return;
} }
@@ -494,21 +462,18 @@ export function TerminalPane({
streamControllerRef.current?.pruneResumeOffsets({ terminalIds }); streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
}, [outputSession, terminals]); }, [outputSession, terminals]);
const handleStreamControllerStatus = useCallback( const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
(status: TerminalStreamControllerStatus) => { setIsAttaching(status.isAttaching);
setIsAttaching(status.isAttaching); setStreamError(status.error);
setStreamError(status.error); if (status.terminalId && typeof status.streamId === "number") {
if (status.terminalId && typeof status.streamId === "number") { setActiveStream({
setActiveStream({ terminalId: status.terminalId,
terminalId: status.terminalId, streamId: status.streamId,
streamId: status.streamId, });
}); return;
return; }
} setActiveStream(null);
setActiveStream(null); }, []);
},
[]
);
useEffect(() => { useEffect(() => {
streamControllerRef.current?.dispose(); streamControllerRef.current?.dispose();
@@ -566,16 +531,14 @@ export function TerminalPane({
}, [isScreenFocused, outputSession, selectedTerminalId]); }, [isScreenFocused, outputSession, selectedTerminalId]);
const activeStreamId = const activeStreamId =
activeStream && activeStream.terminalId === selectedTerminalId activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
? activeStream.streamId
: null;
const getCurrentActiveStreamId = useCallback(() => { const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null; return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []); }, []);
const selectedTerminal = useMemo( const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null, () => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
[terminals, selectedTerminalId] [terminals, selectedTerminalId],
); );
const handleCreateTerminal = useCallback(() => { const handleCreateTerminal = useCallback(() => {
createTerminalMutation.mutate(); createTerminalMutation.mutate();
@@ -618,7 +581,7 @@ export function TerminalPane({
}); });
return true; return true;
}, },
[client] [client],
); );
const flushPendingTerminalInput = useCallback(() => { const flushPendingTerminalInput = useCallback(() => {
@@ -650,10 +613,7 @@ export function TerminalPane({
const handleCloseTerminal = useCallback( const handleCloseTerminal = useCallback(
async (terminalId: string) => { async (terminalId: string) => {
if ( if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) {
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminalId
) {
return; return;
} }
@@ -671,7 +631,7 @@ export function TerminalPane({
killTerminalMutation.mutate(terminalId); killTerminalMutation.mutate(terminalId);
}, },
[killTerminalMutation] [killTerminalMutation],
); );
const clearPendingModifiers = useCallback(() => { const clearPendingModifiers = useCallback(() => {
@@ -679,15 +639,13 @@ export function TerminalPane({
}, []); }, []);
const sendTerminalKey = useCallback( const sendTerminalKey = useCallback(
( (input: {
input: { key: string;
key: string; ctrl: boolean;
ctrl: boolean; shift: boolean;
shift: boolean; alt: boolean;
alt: boolean; meta?: boolean;
meta?: boolean; }): boolean => {
}
): boolean => {
if (!client || !selectedTerminalIdRef.current) { if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({ enqueuePendingTerminalInput({
type: "key", type: "key",
@@ -718,12 +676,7 @@ export function TerminalPane({
} }
return true; return true;
}, },
[ [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
client,
dispatchTerminalInputEntry,
enqueuePendingTerminalInput,
getCurrentActiveStreamId,
]
); );
const handleTerminalData = useCallback( const handleTerminalData = useCallback(
@@ -782,7 +735,7 @@ export function TerminalPane({
modifiers.shift, modifiers.shift,
sendTerminalKey, sendTerminalKey,
enqueuePendingTerminalInput, enqueuePendingTerminalInput,
] ],
); );
const handleTerminalResize = useCallback( const handleTerminalResize = useCallback(
@@ -794,11 +747,7 @@ export function TerminalPane({
const normalizedRows = Math.floor(rows); const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols); const normalizedCols = Math.floor(cols);
const previous = lastReportedSizeRef.current; const previous = lastReportedSizeRef.current;
if ( if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
previous &&
previous.rows === normalizedRows &&
previous.cols === normalizedCols
) {
return; return;
} }
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols }; lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
@@ -808,36 +757,33 @@ export function TerminalPane({
cols: normalizedCols, cols: normalizedCols,
}); });
}, },
[client, selectedTerminalId] [client, selectedTerminalId],
); );
const handleTerminalKey = useCallback( const handleTerminalKey = useCallback(
async (input: { async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => {
sendTerminalKey(input); sendTerminalKey(input);
}, },
[sendTerminalKey] [sendTerminalKey],
); );
const handlePendingModifiersConsumed = useCallback(() => { const handlePendingModifiersConsumed = useCallback(() => {
clearPendingModifiers(); clearPendingModifiers();
}, [clearPendingModifiers]); }, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback((sequence: number) => { const handleOutputChunkConsumed = useCallback(
outputSession.consume({ sequence }); (sequence: number) => {
}, [outputSession]); outputSession.consume({ sequence });
},
[outputSession],
);
const toggleModifier = useCallback( const toggleModifier = useCallback(
(modifier: keyof ModifierState) => { (modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
requestTerminalFocus(); requestTerminalFocus();
}, },
[requestTerminalFocus] [requestTerminalFocus],
); );
const sendVirtualKey = useCallback( const sendVirtualKey = useCallback(
@@ -859,7 +805,7 @@ export function TerminalPane({
modifiers.shift, modifiers.shift,
requestTerminalFocus, requestTerminalFocus,
sendTerminalKey, sendTerminalKey,
] ],
); );
if (!client || !isConnected) { if (!client || !isConnected) {
@@ -870,17 +816,12 @@ export function TerminalPane({
); );
} }
const queryError = const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const isCreating = createTerminalMutation.isPending; const isCreating = createTerminalMutation.isPending;
const createError = const createError =
createTerminalMutation.error instanceof Error createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null;
? createTerminalMutation.error.message
: null;
const closeError = const closeError =
killTerminalMutation.error instanceof Error killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null;
? killTerminalMutation.error.message
: null;
const combinedError = streamError ?? closeError ?? createError ?? queryError; const combinedError = streamError ?? closeError ?? createError ?? queryError;
return ( return (
@@ -898,13 +839,11 @@ export function TerminalPane({
const isTabHovered = hoveredTerminalId === terminal.id; const isTabHovered = hoveredTerminalId === terminal.id;
const isCloseHovered = hoveredCloseTerminalId === terminal.id; const isCloseHovered = hoveredCloseTerminalId === terminal.id;
const isClosingTerminal = const isClosingTerminal =
killTerminalMutation.isPending && killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id;
killTerminalMutation.variables === terminal.id; const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal;
const shouldShowCloseButton =
isTabHovered || isCloseHovered || isClosingTerminal;
const gradientId = `terminal-close-gradient-${terminal.id.replace( const gradientId = `terminal-close-gradient-${terminal.id.replace(
/[^a-zA-Z0-9_-]/g, /[^a-zA-Z0-9_-]/g,
"-" "-",
)}`; )}`;
return ( return (
<Pressable <Pressable
@@ -946,9 +885,7 @@ export function TerminalPane({
> >
{({ hovered = false, pressed = false }) => { {({ hovered = false, pressed = false }) => {
const iconColor = const iconColor =
hovered || pressed hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
? theme.colors.foreground
: theme.colors.foregroundMuted;
return ( return (
<> <>
<TerminalCloseGradient <TerminalCloseGradient
@@ -1044,11 +981,7 @@ export function TerminalPane({
)} )}
{isAttaching && isScreenFocused ? ( {isAttaching && isScreenFocused ? (
<View <View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
style={styles.attachOverlay}
pointerEvents="none"
testID="terminal-attach-loading"
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View> </View>
) : null} ) : null}
@@ -1077,7 +1010,12 @@ export function TerminalPane({
(hovered || pressed) && styles.keyButtonHovered, (hovered || pressed) && styles.keyButtonHovered,
]} ]}
> >
<Text style={[styles.keyButtonText, modifiers[modifier] && styles.keyButtonTextActive]}> <Text
style={[
styles.keyButtonText,
modifiers[modifier] && styles.keyButtonTextActive,
]}
>
{MODIFIER_LABELS[modifier]} {MODIFIER_LABELS[modifier]}
</Text> </Text>
</Pressable> </Pressable>

Some files were not shown because too many files have changed in this diff Show More