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

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

View File

@@ -1,7 +0,0 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}

32
biome.json Normal file
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
}
}

237
package-lock.json generated
View File

@@ -24,10 +24,10 @@
"@modelcontextprotocol/sdk": "^1.27.1"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react": "19.1.4",
"react-dom": "19.1.4",
"typescript": "^5.9.3"
@@ -2559,11 +2559,10 @@
}
},
"node_modules/@biomejs/biome": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz",
"integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
@@ -2576,20 +2575,20 @@
"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"
"@biomejs/cli-darwin-arm64": "2.4.8",
"@biomejs/cli-darwin-x64": "2.4.8",
"@biomejs/cli-linux-arm64": "2.4.8",
"@biomejs/cli-linux-arm64-musl": "2.4.8",
"@biomejs/cli-linux-x64": "2.4.8",
"@biomejs/cli-linux-x64-musl": "2.4.8",
"@biomejs/cli-win32-arm64": "2.4.8",
"@biomejs/cli-win32-x64": "2.4.8"
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz",
"integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==",
"cpu": [
"arm64"
],
@@ -2604,9 +2603,9 @@
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz",
"integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==",
"cpu": [
"x64"
],
@@ -2621,9 +2620,9 @@
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz",
"integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==",
"cpu": [
"arm64"
],
@@ -2638,9 +2637,9 @@
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz",
"integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==",
"cpu": [
"arm64"
],
@@ -2655,9 +2654,9 @@
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz",
"integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==",
"cpu": [
"x64"
],
@@ -2672,9 +2671,9 @@
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz",
"integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==",
"cpu": [
"x64"
],
@@ -2689,9 +2688,9 @@
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz",
"integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==",
"cpu": [
"arm64"
],
@@ -2706,9 +2705,9 @@
}
},
"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==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz",
"integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==",
"cpu": [
"x64"
],
@@ -34907,6 +34906,170 @@
"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": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",

View File

@@ -23,8 +23,8 @@
"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",
"test": "npm run test --workspaces --if-present",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format": "biome format --write .",
"format:check": "biome format .",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --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"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react": "19.1.4",
"react-dom": "19.1.4",
"typescript": "^5.9.3"

View File

@@ -65,8 +65,7 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
NSMicrophoneUsageDescription:
"This app needs access to the microphone for voice commands.",
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
bundleIdentifier: variant.packageId,
@@ -92,9 +91,7 @@ export default {
"android.permission.CAMERA",
],
package: variant.packageId,
...(variant.googleServicesFile
? { googleServicesFile: variant.googleServicesFile }
: {}),
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
output: "single",

View File

@@ -1,15 +1,12 @@
import { test as base, expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './helpers/daemon-registry';
import { test as base, expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
baseURL: async ({}, use) => {
const metroPort = process.env.E2E_METRO_PORT;
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}`);
},
@@ -22,19 +19,19 @@ test.beforeEach(async ({ page }) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
'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.'
"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.",
);
}
if (daemonPort === '6767') {
if (daemonPort === "6767") {
throw new Error(
'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.'
"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.",
);
}
if (!metroPort) {
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).
await page.route(/:(6767)\b/, (route) => route.abort());
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[] = [];
consoleEntries.set(page, entries);
page.on('console', (message) => {
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
@@ -60,7 +57,7 @@ test.beforeEach(async ({ page }) => {
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
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({
serverId,
@@ -74,7 +71,7 @@ test.beforeEach(async ({ page }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
@@ -83,15 +80,15 @@ test.beforeEach(async ({ page }) => {
}
}
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
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;
}
await testInfo.attach('browser-console', {
body: entries.join('\n'),
contentType: 'text/plain',
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
});

View File

@@ -1,11 +1,11 @@
import { spawn, type ChildProcess, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { Buffer } from 'node:buffer';
import dotenv from 'dotenv';
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
type WaitForServerOptions = {
host?: string;
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to acquire port')));
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
}
},
dump() {
return lines.join('\n');
return lines.join("\n");
},
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
if (!getRecentOutput) {
return '';
return "";
}
const output = getRecentOutput().trim();
if (!output) {
return '';
return "";
}
return `\nRecent output:\n${output}`;
}
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
const {
host = '127.0.0.1',
timeoutMs = 15000,
label,
childProcess,
getRecentOutput,
} = options;
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
const start = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - start < timeoutMs) {
if (childProcess && childProcess.exitCode !== null) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
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();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on('error', reject);
socket.on("error", reject);
});
return;
} catch (error) {
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
}
const reason =
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
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) {
return;
}
child.kill('SIGTERM');
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
child.kill("SIGKILL");
}
resolve();
}, 5000);
child.once('exit', () => {
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
return 'empty response body';
return "empty response body";
}
if (trimmed.length <= 240) {
return trimmed;
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
try {
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
method: 'GET',
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
},
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
const body = await response.text();
console.warn(
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
);
return false;
} catch (error) {
console.warn(
`[e2e] OPENAI_API_KEY probe request failed: ${
error instanceof Error ? error.message : String(error)
}`
}`,
);
return false;
}
@@ -196,42 +192,42 @@ type OfferPayload = {
};
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 {
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
if (existsSync(relayDistEntry)) {
return;
}
console.log('[e2e] Building @getpaseo/relay for daemon startup');
execSync('npm run build --workspace=@getpaseo/relay', {
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
cwd: repoRoot,
stdio: 'inherit',
stdio: "inherit",
});
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer=';
const marker = "#offer=";
const idx = url.indexOf(marker);
if (idx === -1) {
throw new Error(`missing ${marker} fragment: ${url}`);
}
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>;
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
if (!offer.serverId) throw new Error('offer.serverId missing');
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
if (!offer.serverId) throw new Error("offer.serverId missing");
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
return offer as OfferPayload;
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..');
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, '.env.test');
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
@@ -239,13 +235,17 @@ export default async function globalSetup() {
const port = await getAvailablePort();
let relayPort = 0;
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();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const cleanup = async () => {
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
@@ -256,24 +256,30 @@ export default async function globalSetup() {
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? 'openai' : 'local';
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
".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(
'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.'
"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.",
);
}
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
try {
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
@@ -285,18 +291,21 @@ export default async function globalSetup() {
let relayReadyForSelectedPort = false;
relayProcess = spawn(
'npx',
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
},
);
relayProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -311,8 +320,11 @@ export default async function globalSetup() {
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -330,7 +342,7 @@ export default async function globalSetup() {
try {
await waitForServer(relayPort, {
label: 'Relay dev server',
label: "Relay dev server",
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
@@ -353,15 +365,15 @@ export default async function globalSetup() {
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump
)}`
relayLineBuffer.dump,
)}`,
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump
)}`
relayLineBuffer.dump,
)}`,
);
}
@@ -380,40 +392,46 @@ export default async function globalSetup() {
? lastRelayStartupError.message
: String(lastRelayStartupError);
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
const appDir = path.resolve(__dirname, '..');
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
const appDir = path.resolve(__dirname, "..");
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: 'none', // Don't auto-open browser
BROWSER: "none", // Don't auto-open browser
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
metroProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
const tsxBin = execSync('which tsx').toString().trim();
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
@@ -421,33 +439,33 @@ export default async function globalSetup() {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
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_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: '1',
PASEO_VOICE_MODE_ENABLED: '1',
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: 'openai',
PASEO_VOICE_TTS_PROVIDER: 'openai',
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: 'development',
NODE_ENV: "development",
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = '';
daemonProcess.stdout?.on('data', (data: Buffer) => {
stdoutBuffer += data.toString('utf8');
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
let stdoutBuffer = "";
daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
@@ -456,13 +474,13 @@ export default async function globalSetup() {
const clean = stripAnsi(trimmed);
try {
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);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes('pairing_offer')) {
if (match && clean.includes("pairing_offer")) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
@@ -476,8 +494,11 @@ export default async function globalSetup() {
}
});
daemonProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
@@ -487,12 +508,12 @@ export default async function globalSetup() {
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: 'Paseo daemon',
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: 'Metro web server',
label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
@@ -503,11 +524,11 @@ export default async function globalSetup() {
await Promise.race([
offerPromise,
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) {
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;
@@ -516,11 +537,13 @@ export default async function globalSetup() {
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
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 () => {
await cleanup();
console.log('[e2e] Test daemon stopped');
console.log("[e2e] Test daemon stopped");
};
} catch (error) {
await cleanup();

View File

@@ -34,10 +34,7 @@ export type DaemonClientInstance = {
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number
): Promise<{ status: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonWsUrl(): string {
@@ -86,14 +83,16 @@ export function createReplyTurn(label: string): {
};
}
async function loadDaemonClientConstructor(): Promise<new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance> {
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
@@ -138,7 +137,7 @@ export async function seedBottomAnchorAgent(input: {
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
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);
if (finish.status !== "idle") {
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 =
candidates.sort(
(left, right) =>
right.scrollHeight -
right.clientHeight -
(left.scrollHeight - left.clientHeight)
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
)[0] ?? (root as HTMLElement);
const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight);
const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max(
0,
contentHeight - (offsetY + viewportHeight)
);
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
return {
offsetY,
@@ -218,7 +212,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise<vo
deltaY: -step,
bubbles: true,
cancelable: true,
})
}),
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
@@ -267,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number
previousContentHeight: number,
): Promise<ScrollMetrics> {
await expect
.poll(async () => {
@@ -280,8 +274,8 @@ export async function waitForContentGrowth(
export async function getChatContainerKey(page: Page): Promise<string | null> {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}

View File

@@ -1,20 +1,19 @@
import { expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './daemon-registry';
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_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') {
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
if (port === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
}
return port;
}
@@ -24,25 +23,38 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
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 raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (connections.some((c: any) => c?.type === 'directTcp' && 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 });
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: any) =>
c?.type === "directTcp" &&
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) {
return;
@@ -57,12 +69,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon, preferences }) => {
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences }
{ daemon, preferences },
);
await page.reload();
@@ -73,81 +85,98 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
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 registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw };
});
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;
try {
registry = JSON.parse(snapshot.registryRaw);
} 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) {
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];
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
if (daemon.serverId !== expectedServerId) {
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
}
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
if (
Array.isArray(connections) &&
connections.some(
(c: any) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
}
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 {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
}
}
export const gotoAppShell = async (page: Page) => {
await page.goto('/');
await page.goto("/");
await ensureE2EStorageSeeded(page);
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole('textbox', { name: 'Message agent...' });
if (!(await composer.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();
const composer = page.getByRole("textbox", { name: "Message agent..." });
if (
!(await composer
.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
.poll(
@@ -155,7 +184,7 @@ export const gotoHome = async (page: Page) => {
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 }
{ timeout: 10000 },
)
.toBe(true);
@@ -173,7 +202,7 @@ export const gotoHome = async (page: Page) => {
export const openSettings = async (page: Page) => {
const serverId = process.env.E2E_SERVER_ID;
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.
@@ -189,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker');
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
const worktreePicker = page.getByTestId("worktree-attach-picker");
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
const closeBottomSheet = async () => {
const bottomSheetBackdrop = page
.getByRole('button', { name: 'Bottom sheet backdrop' })
.first();
const bottomSheetHandle = page
.getByRole('slider', { name: 'Bottom sheet handle' })
.first();
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!(await bottomSheetBackdrop.isVisible())) {
return;
}
await bottomSheetBackdrop.click({ force: true });
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(200);
}
if (await bottomSheetBackdrop.isVisible()) {
@@ -225,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
return;
}
const attachToggle = page.getByTestId('worktree-attach-toggle');
const attachToggle = page.getByTestId("worktree-attach-toggle");
if (await attachToggle.isVisible()) {
await attachToggle.click({ force: true });
await page.waitForTimeout(200);
@@ -246,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
}
await expect
.poll(async () => pickerInputVisible(), { timeout: 10000 })
.toBe(true);
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
}
const trimmedDirectory = directory.replace(/\/+$/, '');
const activeInput =
(await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
const trimmedDirectory = directory.replace(/\/+$/, "");
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
await activeInput.fill(trimmedDirectory);
if (activeInput === directorySearchInput) {
// Combobox custom rows can be either plain path labels or prefixed labels.
const plainOption = page
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
.first();
const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
.first();
if (await plainOption.isVisible().catch(() => false)) {
@@ -274,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await prefixedUseOption.click({ force: true });
} else {
// Fallback: accept highlighted option (directory suggestion).
await activeInput.press('Enter');
await activeInput.press("Enter");
}
} else {
// Legacy path picker fallback.
await activeInput.press('Enter');
await activeInput.press("Enter");
}
// Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith('/var/')) {
if (trimmedDirectory.startsWith("/var/")) {
directoryCandidates.add(`/private${trimmedDirectory}`);
}
if (trimmedDirectory.startsWith('/private/var/')) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
if (trimmedDirectory.startsWith("/private/var/")) {
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 () => {
const text = await workingDirectorySelect.innerText().catch(() => '');
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
}, { timeout: 30000 }).toBe(true);
await expect
.poll(
async () => {
const text = await workingDirectorySelect.innerText().catch(() => "");
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
},
{ timeout: 30000 },
)
.toBe(true);
};
export const ensureHostSelected = async (page: Page) => {
@@ -318,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
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;
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;
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.
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const;
});
@@ -342,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible();
if (await input.isEditable()) {
return;
}
const selectHostLabel = page.getByText('Select host', { exact: true });
const selectHostLabel = page.getByText("Select host", { exact: true });
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
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 localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
.first();
if (await localhostOption.isVisible()) {
await localhostOption.click();
@@ -369,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
};
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 preferFastThinkingOption(page);
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,
// 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> {
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();
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)) {
return;
}
}
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return;
}
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
.trim()
.toLowerCase();
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
@@ -407,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
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))) {
return;
}
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
const preferredLabels = ["low", "minimal", "off", "medium"];
let selected = false;
for (const label of preferredLabels) {
const option = menu
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
.first();
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true });
@@ -426,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
const options = menu.getByRole('button');
const options = menu.getByRole("button");
const count = await options.count();
for (let index = 0; index < count; index += 1) {
const option = options.nth(index);
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
if (!label) {
continue;
}
@@ -444,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
return;
}
@@ -462,15 +495,17 @@ export interface AgentConfig {
export const selectProvider = async (page: Page, provider: string) => {
const normalizedProvider = provider.trim();
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
.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();
if (
await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
@@ -481,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
if (await providerTrigger.isVisible().catch(() => false)) {
await providerTrigger.click();
} else {
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
await expect(providerLabel).toBeVisible();
await providerLabel.click();
}
const dialog = page.getByRole('dialog').last();
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
const dialog = page.getByRole("dialog").last();
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(normalizedProvider);
}
const option = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first();
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click();
};
@@ -502,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
export const selectModel = async (page: Page, model: string) => {
const normalizedModel = model.trim();
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
.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();
if (
await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
@@ -521,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText('MODEL', { exact: true }).first();
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
// 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 });
// Type to search/filter models
await searchInput.fill(normalizedModel);
const dialog = page.getByRole('dialog');
const dialog = page.getByRole("dialog");
const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first();
const exactVisible = await exactOption.isVisible().catch(() => false);
if (exactVisible) {
@@ -543,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
} else {
// Modern labels include version suffixes (for example "Haiku 4.5"), so
// select the first filtered result using keyboard confirm.
await searchInput.press('Enter');
await searchInput.press("Enter");
}
// Wait for dropdown to close
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 });
};
export const selectMode = async (page: Page, mode: string) => {
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();
if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click();
} else {
const modeLabel = page.getByText('MODE', { exact: true }).first();
const modeLabel = page.getByText("MODE", { exact: true }).first();
await expect(modeLabel).toBeVisible();
await modeLabel.click();
}
// 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 });
// Type to filter modes
await searchInput.fill(mode);
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
.first();
const dialog = page.getByRole("dialog");
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click({ force: true });
@@ -605,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
export const createAgentInRepo = async (
page: Page,
config: Pick<AgentConfig, 'directory' | 'prompt'>
config: Pick<AgentConfig, "directory" | "prompt">,
) => {
await gotoHome(page);
await ensureHostSelected(page);
@@ -614,25 +649,25 @@ export const createAgentInRepo = async (
};
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 });
};
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 acceptButton.click();
};
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 denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// 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
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);
// Wait for the tool call result to appear
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
// If no specific message, just wait for the button to disappear
});
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });

View File

@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {}
options: { durationMs?: number; intervalMs?: number } = {},
): Promise<string[][]> {
const durationMs = options.durationMs ?? 2_500;
const intervalMs = options.intervalMs ?? 50;

View File

@@ -1,38 +1,38 @@
import { expect, type Page } from '@playwright/test';
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
import { gotoHome } from './app';
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
const parts = normalized.split('/').filter(Boolean);
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, '');
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]);
if (trimmed.startsWith('/var/')) {
if (trimmed.startsWith("/var/")) {
candidates.add(`/private${trimmed}`);
}
if (trimmed.startsWith('/private/var/')) {
candidates.add(trimmed.replace(/^\/private/, ''));
if (trimmed.startsWith("/private/var/")) {
candidates.add(trimmed.replace(/^\/private/, ""));
}
return Array.from(candidates);
}
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
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: {
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string }
input: { title: string; subtitle: string },
): Promise<void> {
const titleLocator = page.getByTestId('workspace-header-title');
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000,
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
}
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 input.fill(marker);
await input.press('Enter');
await input.press("Enter");
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}

View File

@@ -1,7 +1,7 @@
import { execSync } from 'node:child_process';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
path: string;
@@ -9,29 +9,29 @@ type TempRepo = {
};
export const createTempGitRepo = async (
prefix = 'paseo-e2e-',
options?: { withRemote?: boolean }
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
): Promise<TempRepo> => {
// 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 withRemote = options?.withRemote ?? false;
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.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { 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.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) {
// 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 });
execSync(`git init --bare -b main ${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 init --bare -b main ${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" });
}
return {

View File

@@ -1,10 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
ignores: ["dist/*"],
},
]);

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
// 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({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
@@ -16,17 +17,17 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
reporter: [["list"]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
},
],
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs

View File

@@ -1,8 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
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({
testDir: "./e2e",

View File

@@ -1,11 +1,11 @@
import { defineConfig, devices } from '@playwright/test'
import { defineConfig, devices } from "@playwright/test";
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({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
@@ -13,17 +13,17 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
reporter: [["list"]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: 'Desktop Safari',
use: { ...devices['Desktop Safari'] },
name: "Desktop Safari",
use: { ...devices["Desktop Safari"] },
},
],
})
});

View File

@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
}`,
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
@@ -108,5 +108,5 @@ rl.question(
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
},
);

View File

@@ -1,36 +1,36 @@
export const WELCOME_ROUTE = '/welcome'
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null
hasTimedOut: boolean
isDesktopStartupRace: boolean
daemonCount: number
pathname: string
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false
return false;
}
if (input.hasTimedOut) {
return false
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null
hasTimedOut: boolean
pathname: string
isDesktopStartupRace: boolean
daemonCount: number
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false
return false;
}
if (input.pathname !== '/' && input.pathname !== '') {
return false
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0
return input.isDesktopStartupRace || input.daemonCount > 0;
}

View File

@@ -2,9 +2,7 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -43,7 +41,7 @@ export default function HostAgentReadyRoute() {
serverId,
workspaceId: normalizedCwd,
target: { kind: "agent", agentId },
}) as any
}) as any,
);
}
}, [agentCwd, agentId, router, serverId]);
@@ -87,7 +85,7 @@ export default function HostAgentReadyRoute() {
serverId,
workspaceId: cwd,
target: { kind: "agent", agentId },
}) as any
}) as any,
);
return;
}

View File

@@ -17,11 +17,11 @@ export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
const sessionAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
);
const sessionWorkspaces = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
useEffect(() => {
@@ -44,12 +44,10 @@ export default function HostIndexRoute() {
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
const visibleWorkspaces = sessionWorkspaces
? Array.from(sessionWorkspaces.values())
: [];
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
visibleWorkspaces.sort((left, right) => {
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
@@ -63,7 +61,7 @@ export default function HostIndexRoute() {
serverId,
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any
}) as any,
);
return;
}
@@ -78,14 +76,7 @@ export default function HostIndexRoute() {
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [
pathname,
preferencesLoading,
router,
serverId,
sessionAgents,
sessionWorkspaces,
]);
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
return null;
}

View File

@@ -1,80 +1,82 @@
import { useEffect, useRef } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import { useEffect, useRef } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === 'string') {
return value.trim()
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0]
return typeof firstValue === 'string' ? firstValue.trim() : ''
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return ''
return "";
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === 'agent') {
return { kind: 'agent', agentId: openIntent.agentId }
if (openIntent.kind === "agent") {
return { kind: "agent", agentId: openIntent.agentId };
}
if (openIntent.kind === 'terminal') {
return { kind: 'terminal', terminalId: openIntent.terminalId }
if (openIntent.kind === "terminal") {
return { kind: "terminal", terminalId: openIntent.terminalId };
}
if (openIntent.kind === 'file') {
return { kind: 'file', path: openIntent.path }
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
return { kind: 'draft', draftId: openIntent.draftId }
return { kind: "draft", draftId: openIntent.draftId };
}
export default function HostWorkspaceLayout() {
const router = useRouter()
const consumedIntentRef = useRef<string | null>(null)
const router = useRouter();
const consumedIntentRef = useRef<string | null>(null);
const params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
}>()
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[]
}>()
const serverId = getParamValue(params.serverId)
const workspaceValue = getParamValue(params.workspaceId)
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
const openValue = getParamValue(globalParams.open)
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!openValue) {
return
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
return
return;
}
consumedIntentRef.current = consumptionKey
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue)
const openIntent = parseWorkspaceOpenIntent(openValue);
const route = openIntent
? prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === 'agent',
pin: openIntent.kind === "agent",
})
: buildHostWorkspaceRoute(serverId, workspaceId)
: buildHostWorkspaceRoute(serverId, workspaceId);
router.replace(route as any)
}, [openValue, router, serverId, workspaceId])
router.replace(route as any);
}, [openValue, router, serverId, workspaceId]);
if (openValue) {
return null
return null;
}
return (
@@ -83,5 +85,5 @@ export default function HostWorkspaceLayout() {
serverId={serverId}
workspaceId={workspaceId}
/>
)
);
}

View File

@@ -1,82 +1,82 @@
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
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 {
const runtime = getHostRuntimeStore()
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
}
)
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter()
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
const router = useRouter();
const pathname = usePathname();
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true)
}, STARTUP_TIMEOUT_MS)
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer)
}
}, [])
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!onlineServerId) {
return
return;
}
if (pathname !== '/' && pathname !== '') {
return
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any)
}, [onlineServerId, pathname, router])
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
useEffect(() => {
if (
@@ -88,10 +88,10 @@ export default function Index() {
daemonCount: daemons.length,
})
) {
return
return;
}
router.replace(WELCOME_ROUTE as any)
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
if (
shouldWaitOnStartupRace({
@@ -102,12 +102,12 @@ export default function Index() {
pathname,
})
) {
return <StartupSplashScreen />
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />
return <WelcomeScreen />;
}
return null
return null;
}

View File

@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostRootRoute,
buildHostSettingsRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
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(
useCallback(
(state) => {
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(
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
router.replace(buildHostSettingsRoute(settingsServerId) as any);
}
},
[router, source, sourceServerId, targetServerId]
[router, source, sourceServerId, targetServerId],
);
const closeToSource = useCallback(() => {
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
if (targetServerId && offer.serverId !== targetServerId) {
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;
}
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable
style={styles.permissionButton}
onPress={() => void requestPermission()}
>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>
Point your camera at the pairing QR code.
</Text>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing

View File

@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
return null;
}
if (preferences.serverId) {
const match = daemons.find(
(daemon) => daemon.serverId === preferences.serverId
);
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}

View File

@@ -1,5 +1,5 @@
import { WelcomeScreen } from '@/components/welcome-screen'
import { WelcomeScreen } from "@/components/welcome-screen";
export default function WelcomeRoute() {
return <WelcomeScreen />
return <WelcomeScreen />;
}

View File

@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: {
attachment: AttachmentMetadata;
url: string;
}) => Promise<void>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
}): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true,
});
})
}),
);
},
};

View File

@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
}
export async function encodeAttachmentsForSend(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!attachments || attachments.length === 0) {
return undefined;
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
});
return null;
}
})
}),
);
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;
}
export async function resolveAttachmentPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment });
}
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
}
export async function deleteAttachments(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<void> {
if (!attachments || attachments.length === 0) {
return;
@@ -113,7 +111,7 @@ export async function deleteAttachments(
error,
});
}
})
}),
);
}

View File

@@ -13,15 +13,11 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = await import(
"./web/indexeddb-attachment-store"
);
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = await import(
"./native/native-file-attachment-store"
);
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
return createNativeFileAttachmentStore();
}

View File

@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import {
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined
attachment: AttachmentMetadata | null | undefined,
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [
attachment?.id,
attachment?.storageType,
attachment?.storageKey,
attachment?.mimeType,
]);
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
return url;
}

View File

@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
function runTx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>
run: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
const source = input.source;
if (source.kind === "blob") {
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 };
}
@@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
store.get(id)
store.get(id),
);
if (!record?.blob) {
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
try {
await runTx(db, "readwrite", (store) =>
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
);
} finally {
db.close();
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const cursorRequest = store.openCursor();
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 = () => {

View File

@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: '600',
textAlign: 'center',
fontWeight: "600",
textAlign: "center",
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: '500',
fontWeight: "500",
},
processTextActive: {
color: theme.colors.primaryForeground,
@@ -147,20 +147,21 @@ export function ActiveProcesses({
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
viewMode === 'orchestrator' && styles.processTextActive,
]}>Orchestrator</Text>
<Text
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
>
Orchestrator
</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
const isActive = viewMode === "agent" && activeAgentId === agent.id;
return (
<Pressable
@@ -174,15 +175,21 @@ export function ActiveProcesses({
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
isActive && styles.processTextActive,
]}>{agent.id.substring(0, 8)}</Text>
<Text style={[styles.processText, isActive && styles.processTextActive]}>
{agent.id.substring(0, 8)}
</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
<View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
{agent.currentModeId && (
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
<View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
)}
</Pressable>
);

View File

@@ -1,15 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import {
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -121,8 +113,7 @@ export function AdaptiveModalSheet({
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -148,19 +139,14 @@ export function AdaptiveModalSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
if (isMobile) {
@@ -180,11 +166,7 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -209,11 +191,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -254,13 +232,12 @@ export function AdaptiveModalSheet({
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;
}
return <TextInput ref={ref} {...props} />;
}
},
);

View File

@@ -60,8 +60,17 @@ export function AddHostMethodModal({
}, [onPasteLink]);
return (
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
<AdaptiveModalSheet
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} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
@@ -79,7 +88,11 @@ export function AddHostMethodModal({
</Pressable>
) : 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} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>

View File

@@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0)
)
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
@@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
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 raw = (() => {
@@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (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 (
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) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
@@ -125,15 +133,25 @@ export interface AddHostModalProps {
onClose: () => void;
targetServerId?: string;
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 daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null);
@@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
} finally {
setIsSaving(false);
}
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
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>
<View style={styles.field}>

View File

@@ -1,76 +1,77 @@
import { useState } from 'react';
import { View, Text, Pressable } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
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 {
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
function getToolIcon(toolKind?: string): string {
switch (toolKind) {
case 'read':
return '📖';
case 'edit':
return '✏️';
case 'delete':
return '🗑️';
case 'move':
return '📦';
case 'search':
return '🔍';
case 'execute':
return '▶️';
case 'think':
return '💭';
case 'fetch':
return '🌐';
case 'switch_mode':
return '🔄';
case "read":
return "📖";
case "edit":
return "✏️";
case "delete":
return "🗑️";
case "move":
return "📦";
case "search":
return "🔍";
case "execute":
return "▶️";
case "think":
return "💭";
case "fetch":
return "🌐";
case "switch_mode":
return "🔄";
default:
return '🔧';
return "🔧";
}
}
function getStatusColor(status?: string): string {
switch (status) {
case 'pending':
return '#9ca3af';
case 'in_progress':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case "pending":
return "#9ca3af";
case "in_progress":
return "#fbbf24";
case "completed":
return "#22c55e";
case "failed":
return "#ef4444";
default:
return '#6b7280';
return "#6b7280";
}
}
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === 'thought';
const isThought = item.messageType === "thought";
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && (
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
)}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
{item.text}
</Text>
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
</View>
);
}
@@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
return (
<View style={stylesheet.toolCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.toolHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(item.startTimestamp)}
</Text>
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
<View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(item.status) },
]}
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
@@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.input, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
</View>
)}
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.output, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
</View>
)}
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>
No details available
</Text>
<Text style={stylesheet.emptyText}>No details available</Text>
)}
</View>
)}
@@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== 'plan') {
if (update.kind !== "plan") {
return null;
}
return (
<View style={stylesheet.planCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.planHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>
📋 Tasks ({update.entries.length})
</Text>
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}>
<Text
style={[
stylesheet.planEntryStatus,
{ color: getStatusColor(entry.status) },
]}
>
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
</Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View>
@@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
return (
<View style={stylesheet.unknownCard}>
<Pressable
onPress={() => setShowDrawer(!showDrawer)}
style={stylesheet.unknownHeader}
>
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
@@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
{showDrawer && (
<View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}>
{JSON.stringify(update, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
</View>
)}
</View>
@@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message
if ('kind' in item && item.kind === 'grouped_text') {
if ("kind" in item && item.kind === "grouped_text") {
return <GroupedTextItem item={item} />;
}
// 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} />;
}
@@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const update = activity.update;
// Tasks
if (update.kind === 'plan') {
if (update.kind === "plan") {
return <PlanItem update={update} timestamp={activity.timestamp} />;
}
// Available commands update
if (update.kind === 'available_commands_update') {
if (update.kind === "available_commands_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
@@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
}
// Current mode update
if (update.kind === 'current_mode_update') {
if (update.kind === "current_mode_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>
Mode changed to: {update.currentModeId}
</Text>
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
</View>
);
}
@@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({
},
thoughtText: {
color: theme.colors.foregroundMuted,
fontStyle: 'italic',
fontStyle: "italic",
},
toolCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
toolHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
toolHeaderLeft: {
flex: 1,
},
toolTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
@@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: 'italic',
fontStyle: "italic",
},
planCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
planHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
planHeaderLeft: {
@@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
planEntry: {
flexDirection: 'row',
alignItems: 'flex-start',
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
@@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
unknownHeader: {
padding: theme.spacing[3],
@@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2],
alignSelf: 'flex-start',
alignSelf: "flex-start",
},
unknownBadgeText: {
color: theme.colors.foreground,

View File

@@ -1,13 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
TextInput,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -17,7 +10,18 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
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 type {
AgentMode,
@@ -107,19 +111,14 @@ export function DropdownField({
testID={testID}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
>
<Text
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
numberOfLines={1}
>
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
{value || placeholder}
</Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
{!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
</View>
);
}
@@ -176,7 +175,7 @@ export function SelectField({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
@@ -184,9 +183,7 @@ export function SelectField({
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<View style={styles.selectFieldContainer}>
@@ -231,12 +228,7 @@ interface DropdownSheetProps {
}
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
}
export function DropdownSheet({
@@ -267,19 +259,14 @@ export function DropdownSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
return (
@@ -450,16 +437,14 @@ export function FormSelectTrigger({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
const normalizedPlaceholder = (placeholder ?? "").trim();
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<Pressable
@@ -481,9 +466,7 @@ export function FormSelectTrigger({
>
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
<View style={styles.compactSelectValueContainer}>
{showLabel ? (
<Text style={styles.compactSelectLabel}>{label}</Text>
) : null}
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
) : (
@@ -540,7 +523,7 @@ export function AgentConfigRow({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
@@ -554,9 +537,7 @@ export function AgentConfigRow({
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [
{ id: "", label: "Auto" },
];
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
opts.push({
id: model.id,
@@ -572,7 +553,7 @@ export function AgentConfigRow({
id: option.id,
label: option.label,
})),
[thinkingOptions]
[thinkingOptions],
);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
@@ -609,7 +590,9 @@ export function AgentConfigRow({
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
testID="draft-model-select"
/>
@@ -638,7 +621,9 @@ export function AgentConfigRow({
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
/>
</View>
@@ -664,7 +649,7 @@ export function AssistantDropdown({
const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider
(definition) => definition.id === selectedProvider,
);
const options = useMemo(
@@ -673,7 +658,7 @@ export function AssistantDropdown({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -720,9 +705,9 @@ export function PermissionsDropdown({
const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ??
"Default"
"Default")
: "Automatic";
const options = useMemo(
@@ -732,7 +717,7 @@ export function PermissionsDropdown({
label: mode.label,
description: mode.description,
})),
[modeOptions]
[modeOptions],
);
const handleOpen = useCallback(() => {
@@ -751,9 +736,7 @@ export function PermissionsDropdown({
onPress={handleOpen}
disabled={disabled || !hasOptions}
helperText={
hasOptions
? undefined
: "This assistant does not expose selectable permissions."
hasOptions ? undefined : "This assistant does not expose selectable permissions."
}
controlRef={anchorRef}
/>
@@ -795,7 +778,7 @@ export function ModelDropdown({
const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error
@@ -834,7 +817,7 @@ export function ModelDropdown({
onSelect(id);
}
},
[onClear, onSelect]
[onClear, onSelect],
);
return (
@@ -882,7 +865,7 @@ export function WorkingDirectoryDropdown({
const options = useMemo(
() => suggestedPaths.map((path) => ({ id: path, label: path })),
[suggestedPaths]
[suggestedPaths],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -957,9 +940,7 @@ export function ToggleRow({
</View>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? (
<Text style={styles.helperText}>{description}</Text>
) : null}
{description ? <Text style={styles.helperText}>{description}</Text> : null}
</View>
</Pressable>
);
@@ -1052,9 +1033,7 @@ export function GitOptionsSection({
<View style={styles.gitOptionsContainer}>
<Pressable
testID="worktree-create-toggle"
onPress={() =>
onWorktreeModeChange(isCreateMode ? "none" : "create")
}
onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1077,9 +1056,7 @@ export function GitOptionsSection({
<Pressable
testID="worktree-attach-toggle"
onPress={() =>
onWorktreeModeChange(isAttachMode ? "none" : "attach")
}
onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1143,8 +1120,15 @@ export function GitOptionsSection({
placeholderTextColor={defaultTheme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
<Pressable
onPress={handleConfirmEdit}
hitSlop={8}
style={styles.baseBranchIconButton}
>
<Check
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
@@ -1159,17 +1143,11 @@ export function GitOptionsSection({
</View>
) : null}
{baseBranchError ? (
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
{gitValidationError ? (
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
{gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
</View>
);
}

View File

@@ -1,31 +1,31 @@
import { describe, expect, it } from 'vitest'
import { resolveStatusControlMode } from './agent-input-area.status-controls'
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
describe('resolveStatusControlMode', () => {
it('uses ready mode when no controlled status controls are provided', () => {
expect(resolveStatusControlMode(undefined)).toBe('ready')
})
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
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(
resolveStatusControlMode({
providerDefinitions: [],
selectedProvider: 'codex',
selectedProvider: "codex",
onSelectProvider: () => undefined,
modeOptions: [],
selectedMode: '',
selectedMode: "",
onSelectMode: () => undefined,
models: [],
selectedModel: '',
selectedModel: "",
onSelectModel: () => undefined,
isModelLoading: false,
allProviderModels: new Map(),
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: '',
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
})
).toBe('draft')
})
})
}),
).toBe("draft");
});
});

View File

@@ -1,5 +1,5 @@
import type { DraftAgentStatusBarProps } from './agent-status-bar'
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? 'draft' : 'ready'
return statusControls ? "draft" : "ready";
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,107 +6,107 @@ import {
RefreshControl,
FlatList,
type ListRenderItem,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useCallback, useMemo, useState, type ReactElement } from 'react'
import { router } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { formatTimeAgo } from '@/utils/time'
import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { Archive } from 'lucide-react-native'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from "expo-router";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
agents: AggregatedAgent[]
showCheckoutInfo?: boolean
isRefreshing?: boolean
onRefresh?: () => void
selectedAgentId?: string
onAgentSelect?: () => void
listFooterComponent?: ReactElement | null
showAttentionIndicator?: boolean
agents: AggregatedAgent[];
showCheckoutInfo?: boolean;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
}
type FlatListItem =
| { type: 'header'; key: string; title: string }
| { type: 'agent'; key: string; agent: AggregatedAgent }
| { type: "header"; key: string; title: string }
| { type: "agent"; key: string; agent: AggregatedAgent };
function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
const activityStart = new Date(
lastActivityAt.getFullYear(),
lastActivityAt.getMonth(),
lastActivityAt.getDate()
)
lastActivityAt.getDate(),
);
if (activityStart.getTime() >= todayStart.getTime()) {
return 'Today'
return "Today";
}
if (activityStart.getTime() >= yesterdayStart.getTime()) {
return 'Yesterday'
return "Yesterday";
}
const diffTime = todayStart.getTime() - activityStart.getTime()
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return 'This week'
return "This week";
}
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) {
case 'initializing':
return 'Starting'
case 'idle':
return 'Idle'
case 'running':
return 'Running'
case 'error':
return 'Error'
case 'closed':
return 'Closed'
case "initializing":
return "Starting";
case "idle":
return "Idle";
case "running":
return "Running";
case "error":
return "Error";
case "closed":
return "Closed";
default:
return status
return status;
}
}
function SessionBadge({
label,
icon,
tone = 'neutral',
tone = "neutral",
}: {
label: string
icon?: ReactElement
tone?: 'neutral' | 'warning' | 'danger'
label: string;
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
return (
<View
style={[
styles.badge,
tone === 'warning' && styles.badgeWarning,
tone === 'danger' && styles.badgeDanger,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
]}
>
{icon}
<Text
style={[
styles.badgeText,
tone === 'warning' && styles.badgeTextWarning,
tone === 'danger' && styles.badgeTextDanger,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
]}
>
{label}
</Text>
</View>
)
);
}
function SessionRow({
@@ -117,19 +117,19 @@ function SessionRow({
onPress,
onLongPress,
}: {
agent: AggregatedAgent
isMobile: boolean
selectedAgentId?: string
showAttentionIndicator: boolean
onPress: (agent: AggregatedAgent) => void
onLongPress: (agent: AggregatedAgent) => void
agent: AggregatedAgent;
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}) {
const { theme } = useUnistyles()
const timeAgo = formatTimeAgo(agent.lastActivityAt)
const agentKey = `${agent.serverId}:${agent.id}`
const isSelected = selectedAgentId === agentKey
const statusLabel = formatStatusLabel(agent.status)
const projectPath = shortenPath(agent.cwd)
const { theme } = useUnistyles();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd);
return (
<Pressable
@@ -149,7 +149,7 @@ function SessionRow({
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
{agent.title || 'New session'}
{agent.title || "New session"}
</Text>
{agent.archivedAt ? (
<SessionBadge
@@ -199,10 +199,9 @@ function SessionRow({
</View>
) : null}
</Pressable>
)
);
}
export function AgentList({
agents,
isRefreshing = false,
@@ -212,88 +211,88 @@ export function AgentList({
listFooterComponent,
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
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 isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient)
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(agent: AggregatedAgent) => {
if (isActionSheetVisible) {
return
return;
}
const serverId = agent.serverId
const agentId = agent.id
const serverId = agent.serverId;
const agentId = agent.id;
onAgentSelect?.()
onAgentSelect?.();
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
target: { kind: 'agent', agentId },
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
})
router.navigate(route as any)
});
router.navigate(route as any);
},
[isActionSheetVisible, onAgentSelect]
)
[isActionSheetVisible, onAgentSelect],
);
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent)
}, [])
setActionAgent(agent);
}, []);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null)
}, [])
setActionAgent(null);
}, []);
const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !actionClient) {
return
return;
}
void actionClient.archiveAgent(actionAgent.id)
setActionAgent(null)
}, [actionAgent, actionClient])
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, actionClient]);
const flatItems = useMemo((): FlatListItem[] => {
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const
const buckets = new Map<string, AggregatedAgent[]>()
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt)
const existing = buckets.get(label) ?? []
existing.push(agent)
buckets.set(label, existing)
const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [];
existing.push(agent);
buckets.set(label, existing);
}
const result: FlatListItem[] = []
const result: FlatListItem[] = [];
for (const label of order) {
const data = buckets.get(label)
const data = buckets.get(label);
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) {
result.push({ type: 'agent', key: `${agent.serverId}:${agent.id}`, agent })
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
}
}
return result
}, [agents])
return result;
}, [agents]);
const renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => {
if (item.type === 'header') {
if (item.type === "header") {
return (
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text>
</View>
)
);
}
return (
<SessionRow
@@ -304,12 +303,12 @@ export function AgentList({
onPress={handleAgentPress}
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 (
<>
@@ -350,7 +349,7 @@ export function AgentList({
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -380,7 +379,7 @@ export function AgentList({
</View>
</Modal>
</>
)
);
}
const styles = StyleSheet.create((theme) => ({
@@ -399,8 +398,8 @@ const styles = StyleSheet.create((theme) => ({
},
sectionHeading: {
marginTop: theme.spacing[2],
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2],
@@ -411,8 +410,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
row: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: {
@@ -429,15 +428,15 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
rowTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
rowMetaRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[1],
marginTop: 2,
},
@@ -456,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
sessionTitle: {
flexShrink: 1,
fontSize: theme.fontSize.sm,
fontWeight: '400',
fontWeight: "400",
color: theme.colors.foreground,
opacity: 0.86,
},
@@ -464,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 1,
},
sessionMetaText: {
maxWidth: '100%',
maxWidth: "100%",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
@@ -486,11 +485,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 72,
textAlign: 'right' as const,
textAlign: "right" as const,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
@@ -498,10 +497,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
badgeWarning: {
backgroundColor: 'rgba(245, 158, 11, 0.12)',
backgroundColor: "rgba(245, 158, 11, 0.12)",
},
badgeDanger: {
backgroundColor: 'rgba(239, 68, 68, 0.14)',
backgroundColor: "rgba(239, 68, 68, 0.14)",
},
badgeText: {
fontSize: theme.fontSize.xs,
@@ -516,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({
},
sheetOverlay: {
flex: 1,
justifyContent: 'flex-end',
justifyContent: "flex-end",
},
sheetBackdrop: {
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
backgroundColor: "rgba(0,0,0,0.35)",
},
sheetContainer: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
gap: theme.spacing[4],
},
sheetHandle: {
alignSelf: 'center',
alignSelf: "center",
width: 40,
height: 4,
borderRadius: theme.borderRadius.full,
@@ -546,18 +545,18 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: 'center',
textAlign: "center",
},
sheetButtonRow: {
flexDirection: 'row',
flexDirection: "row",
gap: theme.spacing[3],
},
sheetButton: {
flex: 1,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[4],
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
},
sheetArchiveButton: {
backgroundColor: theme.colors.primary,
@@ -578,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
}))
}));

View File

@@ -1,72 +1,72 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import {
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
} from './agent-status-bar.utils'
} from "./agent-status-bar.utils";
describe('getStatusSelectorHint', () => {
it('explains what each editable status control does', () => {
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
expect(getStatusSelectorHint('model')).toBe('Change model')
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
})
})
describe("getStatusSelectorHint", () => {
it("explains what each editable status control does", () => {
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
});
});
describe('normalizeModelId', () => {
it('treats empty and default values as unset', () => {
expect(normalizeModelId('')).toBeNull()
expect(normalizeModelId(' default ')).toBeNull()
expect(normalizeModelId(undefined)).toBeNull()
})
describe("normalizeModelId", () => {
it("treats empty and default values as unset", () => {
expect(normalizeModelId("")).toBeNull();
expect(normalizeModelId(" default ")).toBeNull();
expect(normalizeModelId(undefined)).toBeNull();
});
it('returns trimmed model ids', () => {
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex')
})
})
it("returns trimmed model ids", () => {
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
});
});
describe('resolveAgentModelSelection', () => {
it('prefers runtime model over configured model', () => {
describe("resolveAgentModelSelection", () => {
it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
thinkingOptions: [{ id: 'low', label: 'Low' }],
defaultThinkingOptionId: 'low',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [{ id: "low", label: "Low" }],
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
configuredModelId: 'b',
runtimeModelId: "a",
configuredModelId: "b",
explicitThinkingOptionId: null,
})
});
expect(selection.activeModelId).toBe('a')
expect(selection.displayModel).toBe('Model A')
expect(selection.selectedThinkingId).toBe('low')
})
expect(selection.activeModelId).toBe("a");
expect(selection.displayModel).toBe("Model A");
expect(selection.selectedThinkingId).toBe("low");
});
it('uses explicit thinking option when provided', () => {
it("uses explicit thinking option when provided", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' },
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: 'low',
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
runtimeModelId: "a",
configuredModelId: null,
explicitThinkingOptionId: 'high',
})
explicitThinkingOptionId: "high",
});
expect(selection.selectedThinkingId).toBe('high')
expect(selection.displayThinking).toBe('High')
})
})
expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe("High");
});
});

View File

@@ -1,129 +1,130 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import {
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
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 { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from '@server/server/agent/provider-manifest'
} from "@server/server/agent/provider-manifest";
import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from '@/components/agent-status-bar.utils'
} from "@/components/agent-status-bar.utils";
type StatusOption = {
id: string
label: string
}
id: string;
label: string;
};
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
type StatusSelector = "provider" | "mode" | "model" | "thinking";
type ControlledAgentStatusBarProps = {
provider: string
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
modeOptions?: StatusOption[]
selectedModeId?: string
onSelectMode?: (modeId: string) => void
modelOptions?: StatusOption[]
selectedModelId?: string
onSelectModel?: (modelId: string) => void
thinkingOptions?: StatusOption[]
selectedThinkingOptionId?: string
onSelectThinkingOption?: (thinkingOptionId: string) => void
disabled?: boolean
isModelLoading?: boolean
}
provider: string;
providerOptions?: StatusOption[];
selectedProviderId?: string;
onSelectProvider?: (providerId: string) => void;
modeOptions?: StatusOption[];
selectedModeId?: string;
onSelectMode?: (modeId: string) => void;
modelOptions?: StatusOption[];
selectedModelId?: string;
onSelectModel?: (modelId: string) => void;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
};
export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[]
selectedProvider: AgentProvider
onSelectProvider: (provider: AgentProvider) => void
modeOptions: AgentMode[]
selectedMode: string
onSelectMode: (modeId: string) => void
models: AgentModelDefinition[]
selectedModel: string
onSelectModel: (modelId: string) => void
isModelLoading: boolean
allProviderModels: Map<string, AgentModelDefinition[]>
isAllModelsLoading: boolean
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void
disabled?: boolean
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
models: AgentModelDefinition[];
selectedModel: string;
onSelectModel: (modelId: string) => void;
isModelLoading: boolean;
allProviderModels: Map<string, AgentModelDefinition[]>;
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
disabled?: boolean;
}
interface AgentStatusBarProps {
agentId: string
serverId: string
agentId: 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) {
return fallback
return fallback;
}
const selected = options.find((option) => option.id === selectedId)
return selected?.label ?? fallback
const selected = options.find((option) => option.id === selectedId);
return selected?.label ?? fallback;
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
} as const
} as const;
function getModeIconColor(
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 {
switch (colorTier) {
case 'default':
return palette.blue[500]
case 'safe':
return palette.green[500]
case 'moderate':
return palette.amber[500]
case 'dangerous':
return palette.red[500]
case 'readonly':
return palette.purple[500]
case "default":
return palette.blue[500];
case "safe":
return palette.green[500];
case "moderate":
return palette.amber[500];
case "dangerous":
return palette.red[500];
case "readonly":
return palette.purple[500];
default:
return palette.blue[500]
return palette.blue[500];
}
}
function ControlledStatusBar({
provider,
providerOptions,
@@ -141,71 +142,83 @@ function ControlledStatusBar({
disabled = false,
isModelLoading = false,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles()
const isWeb = Platform.OS === 'web'
const [prefsOpen, setPrefsOpen] = useState(false)
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null)
const modeAnchorRef = useRef<View>(null)
const modelAnchorRef = useRef<View>(null)
const thinkingAnchorRef = useRef<View>(null)
const providerAnchorRef = useRef<View>(null);
const modeAnchorRef = useRef<View>(null);
const modelAnchorRef = useRef<View>(null);
const thinkingAnchorRef = useRef<View>(null);
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
const canSelectModel = Boolean(onSelectModel)
const canSelectProvider = Boolean(
onSelectProvider && providerOptions && providerOptions.length > 0,
);
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0);
const canSelectModel = Boolean(onSelectModel);
const canSelectThinking = Boolean(
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0
)
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider')
const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default')
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0)
? 'Loading models...'
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
? "Loading models..."
: findOptionLabel(modelOptions, selectedModelId, "Auto");
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
const ProviderIcon = getProviderIcon(provider)
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider);
const hasAnyControl =
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length)
Boolean(thinkingOptions?.length);
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[]>(
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[providerOptions]
)
[providerOptions],
);
const comboboxModeOptions = useMemo<ComboboxOption[]>(
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modeOptions]
)
[modeOptions],
);
const comboboxModelOptions = useMemo<ComboboxOption[]>(
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions]
)
[modelOptions],
);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions]
)
[thinkingOptions],
);
const renderModeOption = useCallback(
({ option, 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
({
option,
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 (
<ComboboxItem
label={option.label}
@@ -214,24 +227,24 @@ function ControlledStatusBar({
onPress={onPress}
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
/>
)
);
},
[provider, theme.colors.foreground]
)
[provider, theme.colors.foreground],
);
const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null)
setOpenSelector(nextOpen ? selector : null);
},
[]
)
[],
);
const handleSelectorPress = useCallback(
(selector: StatusSelector) => {
handleOpenChange(selector)(openSelector !== selector)
handleOpenChange(selector)(openSelector !== selector);
},
[handleOpenChange, openSelector]
)
[handleOpenChange, openSelector],
);
return (
<View style={styles.container}>
@@ -243,11 +256,11 @@ function ControlledStatusBar({
ref={providerAnchorRef}
collapsable={false}
disabled={disabled || !canSelectProvider}
onPress={() => handleSelectorPress('provider')}
onPress={() => handleSelectorPress("provider")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'provider') && styles.modeBadgePressed,
(pressed || openSelector === "provider") && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -259,11 +272,11 @@ function ControlledStatusBar({
</Pressable>
<Combobox
options={comboboxProviderOptions}
value={selectedProviderId ?? ''}
value={selectedProviderId ?? ""}
onSelect={(id) => onSelectProvider?.(id)}
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
anchorRef={providerAnchorRef}
desktopPlacement="top-start"
/>
@@ -273,7 +286,7 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -283,11 +296,11 @@ function ControlledStatusBar({
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress('mode')}
onPress={() => handleSelectorPress("mode")}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -302,16 +315,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("mode")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ''}
value={selectedModeId ?? ""}
onSelect={(id) => onSelectMode?.(id)}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
@@ -322,7 +335,7 @@ function ControlledStatusBar({
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -332,11 +345,11 @@ function ControlledStatusBar({
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => handleSelectorPress('model')}
onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'model') && styles.modeBadgePressed,
(pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -349,16 +362,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ''}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
@@ -368,7 +381,7 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<>
<Tooltip
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -378,11 +391,11 @@ function ControlledStatusBar({
ref={thinkingAnchorRef}
collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => handleSelectorPress('thinking')}
onPress={() => handleSelectorPress("thinking")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
(pressed || openSelector === "thinking") && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -395,16 +408,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("thinking")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''}
value={selectedThinkingOptionId ?? ""}
onSelect={(id) => onSelectThinkingOption?.(id)}
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
anchorRef={thinkingAnchorRef}
desktopPlacement="top-start"
/>
@@ -415,16 +428,15 @@ function ControlledStatusBar({
<>
<Pressable
onPress={() => setPrefsOpen(true)}
style={({ pressed }) => [
styles.prefsButton,
pressed && styles.prefsButtonPressed,
]}
style={({ pressed }) => [styles.prefsButton, pressed && styles.prefsButtonPressed]}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<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>
<AdaptiveModalSheet
@@ -436,8 +448,8 @@ function ControlledStatusBar({
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
@@ -471,8 +483,8 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
@@ -493,8 +505,8 @@ function ControlledStatusBar({
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id)
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
const visuals = getModeVisuals(provider, mode.id);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
key={mode.id}
@@ -504,7 +516,7 @@ function ControlledStatusBar({
>
{mode.label}
</DropdownMenuItem>
)
);
})}
</DropdownMenuContent>
</DropdownMenu>
@@ -514,8 +526,8 @@ function ControlledStatusBar({
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
@@ -549,8 +561,8 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
@@ -584,68 +596,68 @@ function ControlledStatusBar({
</>
)}
</View>
)
);
}
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: [
'providerModels',
"providerModels",
serverId,
agent?.provider ?? '__missing_provider__',
agent?.cwd ?? '__missing_cwd__',
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
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) {
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 =
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
agent?.currentModeId ||
'default'
"default";
const modelSelection = resolveAgentModelSelection({
models,
runtimeModelId: agent?.runtimeInfo?.model,
configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId,
})
});
const modeOptions = useMemo<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [agent?.availableModes])
}));
}, [agent?.availableModes]);
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }))
}, [models])
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
id: option.id,
label: option.label,
}))
}, [modelSelection.thinkingOptions])
}));
}, [modelSelection.thinkingOptions]);
if (!agent) {
return null
return null;
}
return (
@@ -654,41 +666,41 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions={
modeOptions.length > 0
? modeOptions
: [{ id: agent.currentModeId ?? '', label: displayMode }]
: [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
onSelectMode={(modeId) => {
if (!client) {
return
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn('[AgentStatusBar] setAgentMode failed', error)
})
console.warn("[AgentStatusBar] setAgentMode failed", error);
});
}}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
onSelectModel={(modelId) => {
if (!client) {
return
return;
}
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}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
if (!client) {
return
return;
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn('[AgentStatusBar] setAgentThinkingOption failed', error)
})
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client}
/>
)
);
}
export function DraftAgentStatusBar({
@@ -710,25 +722,25 @@ export function DraftAgentStatusBar({
onSelectThinkingOption,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === 'web'
const isWeb = Platform.OS === "web";
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
return [{ id: "", label: "Default" }];
}
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [modeOptions])
}));
}, [modeOptions]);
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
}, [thinkingOptions])
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ''
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) {
return (
@@ -753,17 +765,17 @@ export function DraftAgentStatusBar({
disabled={disabled}
/>
</View>
)
);
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}));
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label })
modelOptions.push({ id: model.id, label: model.label });
}
return (
@@ -784,30 +796,30 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
)
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
},
modeBadge: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
modeIconBadge: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
borderRadius: theme.borderRadius.full,
},
modeBadgeHovered: {
@@ -831,11 +843,11 @@ const styles = StyleSheet.create((theme) => ({
},
prefsButton: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
@@ -850,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
sheetSelect: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
@@ -873,4 +885,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
}))
}));

View File

@@ -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 {
switch (selector) {
case 'thinking':
return 'Thinking mode'
case 'model':
return 'Change model'
case 'mode':
return 'Change permission mode'
case "thinking":
return "Thinking mode";
case "model":
return "Change model";
case "mode":
return "Change permission mode";
}
}
export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
if (!normalized || normalized.toLowerCase() === 'default') {
return null
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return null;
}
return normalized
return normalized;
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null
runtimeModelId: string | null | undefined
configuredModelId: string | null | undefined
explicitThinkingOptionId: string | null | undefined
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
}) {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId)
const normalizedConfiguredModelId = normalizeModelId(configuredModelId)
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
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 displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: selectedModel?.defaultThinkingOptionId ?? null
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
return {
selectedModel,
@@ -54,5 +57,5 @@ export function resolveAgentModelSelection(input: {
thinkingOptions,
selectedThinkingId,
displayThinking,
}
};
}

View File

@@ -128,7 +128,7 @@ function splitOrderedTail(params: {
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput
input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native",

View File

@@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
@@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
@@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => {
strategy: forward,
items: chronological,
startIndex: forwardStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
const inverted = resolveStreamRenderStrategy({
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
strategy: inverted,
items: invertedItems,
startIndex: invertedStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
});
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "above",
})
}),
).toBeUndefined();
expect(
getStreamNeighborItem({
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "below",
})
}),
).toBeUndefined();
});
});
@@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
});
@@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
expect(
getBottomOffsetForStreamRenderStrategy({
strategy,
viewportHeight: 300,
contentHeight: 1000,
})
}),
).toBe(0);
});
@@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => {
strategy,
viewportHeight: 320,
contentHeight: 1000,
})
}),
).toBe(680);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(0);
});
@@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(39);
});
});
@@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
const previousThreshold =
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted =
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
expect(getWebMountedRecentStreamItems()).toBe(
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
@@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
previousThreshold;
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold;
}
if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
previousMounted;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
}
}
});

View File

@@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null {
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}

View File

@@ -1,35 +1,35 @@
import { useState } from 'react'
import { View, Text } from 'react-native'
import { StyleSheet } from 'react-native-unistyles'
import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { Button } from '@/components/ui/button'
import type { Theme } from '@/styles/theme'
import { useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
interface ArchivedAgentCalloutProps {
serverId: string
agentId: string
serverId: string;
agentId: string;
}
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const insets = useSafeAreaInsets()
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const [isUnarchiving, setIsUnarchiving] = useState(false)
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' })
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
async function handleUnarchive() {
if (!client || !isConnected || isUnarchiving) return
setIsUnarchiving(true)
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
try {
await client.refreshAgent(agentId)
await client.refreshAgent(agentId);
} catch (error) {
console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error)
setIsUnarchiving(false)
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}
@@ -53,36 +53,36 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
</View>
</View>
</Animated.View>
)
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
flexDirection: 'column',
position: 'relative',
flexDirection: "column",
position: "relative",
},
inputAreaContainer: {
position: 'relative',
position: "relative",
minHeight: FOOTER_HEIGHT,
marginHorizontal: 'auto',
alignItems: 'center',
width: '100%',
overflow: 'visible',
marginHorizontal: "auto",
alignItems: "center",
width: "100%",
overflow: "visible",
padding: theme.spacing[4],
},
inputAreaContent: {
width: '100%',
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
callout: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
paddingVertical: {
xs: theme.spacing[3],
md: theme.spacing[4],
@@ -96,4 +96,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
})) as any) as Record<string, any>
})) as any) as Record<string, any>;

View File

@@ -1,10 +1,4 @@
import {
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</Text>
</View>
<View style={styles.headerActions}>
<View
style={[
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
@@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>
Image viewing not yet implemented
</Text>
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
</View>
) : (
<View style={styles.codeContainer}>
@@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>
{content.length.toLocaleString()} characters
</Text>
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
</View>
</View>
</View>

View File

@@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null {
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 [copied, setCopied] = useState(false);
@@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
</View>
{info.debugRecordingPath ? (
<Pressable style={styles.pathRow} onPress={handleCopyPath} accessibilityLabel="Copy raw audio path">
<Text
numberOfLines={2}
style={[styles.pathText, { color: theme.colors.foreground }]}
>
<Pressable
style={styles.pathRow}
onPress={handleCopyPath}
accessibilityLabel="Copy raw audio path"
>
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
{info.debugRecordingPath}
</Text>
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
@@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
)}
{stats ? (
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>
{stats}
</Text>
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
) : null}
</View>
);

View File

@@ -1,24 +1,24 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
import { getProviderIcon } from '@/components/provider-icons'
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
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 {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: AgentProvider, modelId: string) => void
isLoading: boolean
disabled?: boolean
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
disabled?: boolean;
}
export function CombinedModelSelector({
@@ -30,43 +30,46 @@ export function CombinedModelSelector({
isLoading,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles()
const anchorRef = useRef<View>(null)
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState<'groups' | DrillDownView>('groups')
const [searchQuery, setSearchQuery] = useState('')
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [searchQuery, setSearchQuery] = useState("");
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open)
if (open) {
const models = allProviderModels.get(selectedProvider)
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider })
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (open) {
const models = allProviderModels.get(selectedProvider);
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider });
}
} else {
setView("groups");
setSearchQuery("");
}
} else {
setView('groups')
setSearchQuery('')
}
}, [allProviderModels, selectedProvider])
},
[allProviderModels, selectedProvider],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId)
setIsOpen(false)
setView('groups')
setSearchQuery('')
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView("groups");
setSearchQuery("");
},
[onSelect]
)
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider)
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider)
if (!models) return isLoading ? 'Loading...' : 'Auto'
const model = models.find((m) => m.id === selectedModel)
return model?.label ?? 'Auto'
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Auto";
const model = models.find((m) => m.id === selectedModel);
return model?.label ?? "Auto";
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return (
<>
@@ -99,7 +102,7 @@ export function CombinedModelSelector({
desktopPlacement="top-start"
title="Select model"
>
{view === 'groups' ? (
{view === "groups" ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
@@ -107,8 +110,8 @@ export function CombinedModelSelector({
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider })
setSearchQuery('')
setView({ provider });
setSearchQuery("");
}}
/>
) : (
@@ -122,14 +125,14 @@ export function CombinedModelSelector({
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView('groups')
setSearchQuery('')
setView("groups");
setSearchQuery("");
}}
/>
)}
</Combobox>
</>
)
);
}
function GroupsView({
@@ -140,21 +143,21 @@ function GroupsView({
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: string, modelId: string) => void
onDrillDown: (provider: string) => void
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles()
const { theme } = useUnistyles();
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? []
const isInline = models.length <= INLINE_MODEL_THRESHOLD
const ProvIcon = getProviderIcon(def.id)
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
@@ -193,10 +196,10 @@ function GroupsView({
</Pressable>
)}
</View>
)
);
})}
</View>
)
);
}
function DrillDownModelView({
@@ -210,27 +213,27 @@ function DrillDownModelView({
onSelect,
onBack,
}: {
provider: string
providerDefinitions: AgentProviderDefinition[]
models: AgentModelDefinition[]
selectedProvider: string
selectedModel: string
searchQuery: string
onSearchChange: (query: string) => void
onSelect: (provider: string, modelId: string) => void
onBack: () => void
provider: string;
providerDefinitions: AgentProviderDefinition[];
models: AgentModelDefinition[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelect: (provider: string, modelId: string) => void;
onBack: () => void;
}) {
const { theme } = useUnistyles()
const ProvIcon = getProviderIcon(provider)
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider);
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models
const q = searchQuery.toLowerCase()
if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase();
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
)
}, [models, searchQuery])
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
);
}, [models, searchQuery]);
return (
<View>
@@ -251,7 +254,7 @@ function DrillDownModelView({
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === 'web'}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
@@ -270,18 +273,18 @@ function DrillDownModelView({
</View>
) : null}
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -303,8 +306,8 @@ const styles = StyleSheet.create((theme) => ({
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
@@ -315,8 +318,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -334,8 +337,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
drillDownCount: {
@@ -343,8 +346,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -363,10 +366,10 @@ const styles = StyleSheet.create((theme) => ({
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: 'center',
alignItems: "center",
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}))
}));

View File

@@ -1,12 +1,4 @@
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -54,16 +46,8 @@ const CommandCenterRow = memo(function CommandCenterRow({
export function CommandCenter() {
const { theme } = useUnistyles();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
} = useCommandCenter();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null);
@@ -77,11 +61,13 @@ export function CommandCenter() {
return;
}
const scrollNode =
(resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null)?.getScrollableNode?.() ?? null;
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
@@ -110,18 +96,16 @@ export function CommandCenter() {
const agentItems = items.filter((item) => item.kind === "agent");
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View
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 }]}>
<TextInput
@@ -131,10 +115,7 @@ export function CommandCenter() {
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[
styles.input,
{ color: theme.colors.foreground },
]}
style={[styles.input, { color: theme.colors.foreground }]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@@ -164,11 +145,7 @@ export function CommandCenter() {
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus
size={16}
strokeWidth={2.4}
color={theme.colors.foregroundMuted}
/>
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings
size={16}

View File

@@ -40,18 +40,8 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View style={styles.container}>
<View style={styles.row}>
<View
style={[
styles.dot,
isConnected ? styles.dotConnected : styles.dotDisconnected,
]}
/>
<Text
style={[
styles.text,
isConnected ? styles.textConnected : styles.textDisconnected,
]}
>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? "Connected" : "Disconnected"}
</Text>
</View>

View File

@@ -24,9 +24,7 @@ interface DictationControlsProps {
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function DictationControls({
@@ -66,12 +64,7 @@ export function DictationControls({
return (
<View style={styles.activeContainer}>
<View style={styles.meterWrapper}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
/>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
{formatDuration(duration)}
@@ -152,12 +145,7 @@ export function DictationOverlay({
}
return (
<View
style={[
overlayStyles.container,
{ backgroundColor: theme.colors.accent },
]}
>
<View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
@@ -180,12 +168,7 @@ export function DictationOverlay({
orientation="horizontal"
color={theme.colors.palette.white}
/>
<Text
style={[
overlayStyles.timerText,
{ color: theme.colors.palette.white },
]}
>
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
{formatDuration(duration)}
</Text>
</View>
@@ -205,26 +188,16 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator
size="small"
color={theme.colors.palette.white}
/>
<ActivityIndicator size="small" color={theme.colors.palette.white} />
</View>
) : isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<RefreshCcw
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : (
<>
@@ -232,10 +205,7 @@ export function DictationOverlay({
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
>
<Pencil
size={theme.iconSize.lg}
@@ -247,16 +217,9 @@ export function DictationOverlay({
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<ArrowUp
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
</>
)}

View File

@@ -1,6 +1,14 @@
import { View, Text, Pressable } from "react-native";
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";
@@ -53,7 +61,8 @@ export function DictationStatusNotice({
})();
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 (
<View
@@ -83,11 +92,7 @@ export function DictationStatusNotice({
{(meta || (actionLabel && onAction)) && (
<View style={styles.actionsRow}>
{meta ? (
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
) : (
<View />
)}
{meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
{actionLabel && onAction ? (
<Pressable
style={[

View File

@@ -71,7 +71,8 @@ export function DiffViewer({
key={segIdx}
style={[
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}

View File

@@ -41,7 +41,10 @@ export function DownloadToast() {
}
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}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
@@ -61,7 +64,7 @@ export function DownloadToast() {
: "Starting..."
: activeDownload.status === "complete"
? "Download complete"
: activeDownload.message ?? "Download failed"}
: (activeDownload.message ?? "Download failed")}
</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>

View File

@@ -5,10 +5,7 @@ import DraggableFlatList, {
type RenderItemParams,
} from "react-native-draggable-flatlist";
import { useUnistyles } from "react-native-unistyles";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -53,7 +50,7 @@ export function DraggableList<T>({
};
return renderItem(info);
},
[renderItem]
[renderItem],
);
const handleDragEnd = useCallback(
@@ -61,7 +58,7 @@ export function DraggableList<T>({
setIsDragging(false);
onDragEnd(newData);
},
[onDragEnd]
[onDragEnd],
);
const handleDragBegin = useCallback(() => {
@@ -74,12 +71,11 @@ export function DraggableList<T>({
}, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const shouldShowRefreshControl = showRefreshControl && !nestable;
const ListComponent: typeof DraggableFlatList = (nestable
? (NestableDraggableFlatList as any)
: DraggableFlatList) as any;
const ListComponent: typeof DraggableFlatList = (
nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
) as any;
return (
<ListComponent

View File

@@ -19,14 +19,8 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -79,7 +73,7 @@ function SortableItem<T>({
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
// out the dnd-kit scaling component.
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 combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -100,9 +94,7 @@ function SortableItem<T>({
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
};
@@ -152,7 +144,7 @@ export function DraggableList<T>({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const handleDragStart = useCallback(
@@ -161,7 +153,7 @@ export function DraggableList<T>({
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, onDragBegin]
[data, onDragBegin],
);
const handleDragEnd = useCallback(
@@ -172,12 +164,8 @@ export function DraggableList<T>({
setDragItems(null);
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && 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));
@@ -204,9 +192,7 @@ export function DraggableList<T>({
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined

View File

@@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
{hasImportCta ? (
<Pressable
onPress={onImportAgent}
style={[styles.button, styles.secondaryButton]}
>
<Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
<Download size={20} color={styles.secondaryButtonText.color} />
<Text style={styles.secondaryButtonText}>Import agent</Text>
</Pressable>

View File

@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
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 {
serverId: string;
@@ -44,8 +39,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
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 desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -68,7 +62,7 @@ export function ExplorerSidebar({
}
const maxWidth = Math.max(
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) {
setExplorerWidth(maxWidth);
@@ -102,7 +96,7 @@ export function ExplorerSidebar({
});
closeToAgent();
},
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const enableSidebarCloseGesture = isMobile && isOpen;
@@ -111,7 +105,7 @@ export function ExplorerSidebar({
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
// Swipe gesture to close (swipe right on mobile)
@@ -170,8 +164,7 @@ export function ExplorerSidebar({
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
@@ -200,7 +193,7 @@ export function ExplorerSidebar({
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
]
],
);
// Desktop resize gesture (drag left edge)
@@ -218,18 +211,15 @@ export function ExplorerSidebar({
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
);
const clampedWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(maxWidth, newWidth)
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth]
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
@@ -299,16 +289,11 @@ export function ExplorerSidebar({
}
return (
<Animated.View
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
@@ -351,8 +336,7 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const resolvedTab: ExplorerTab =
!isGit && activeTab === "changes" ? "files" : activeTab;
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -365,12 +349,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
resolvedTab === "changes" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
Changes
</Text>
</Pressable>
@@ -380,12 +359,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
resolvedTab === "files" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
Files
</Text>
</Pressable>

View File

@@ -1,10 +1,6 @@
import { View, Text, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
@@ -18,11 +14,7 @@ interface FileDropZoneProps {
const IS_WEB = Platform.OS === "web";
export function FileDropZone({
children,
onFilesDropped,
disabled = false,
}: FileDropZoneProps) {
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,

View File

@@ -38,10 +38,7 @@ import {
RotateCw,
X,
} from "lucide-react-native";
import type {
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
@@ -54,10 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
type SortOption,
} from "@/stores/panel-store";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
@@ -102,36 +96,32 @@ export function FileExplorerPane({
onOpenFile,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
);
const normalizedWorkspaceRoot = useMemo(
() => workspaceRoot.trim(),
[workspaceRoot]
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined
: undefined,
);
const {
@@ -156,9 +146,9 @@ export function FileExplorerPane({
const isDirectoryLoading = useCallback(
(path: string) =>
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(["."]));
@@ -235,7 +225,7 @@ export function FileExplorerPane({
});
}
},
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing]
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const handleOpenFile = useCallback(
@@ -246,7 +236,7 @@ export function FileExplorerPane({
selectExplorerEntry(entry.path);
onOpenFile?.(entry.path);
},
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
[hasWorkspaceScope, onOpenFile, selectExplorerEntry],
);
const handleEntryPress = useCallback(
@@ -257,7 +247,7 @@ export function FileExplorerPane({
}
handleOpenFile(entry);
},
[handleOpenFile, handleToggleDirectory]
[handleOpenFile, handleToggleDirectory],
);
const handleCopyPath = useCallback(
@@ -266,10 +256,10 @@ export function FileExplorerPane({
buildAbsoluteExplorerPath({
workspaceRoot: normalizedWorkspaceRoot,
entryPath: path,
})
}),
);
},
[normalizedWorkspaceRoot]
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
@@ -288,13 +278,7 @@ export function FileExplorerPane({
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
},
[
daemonProfile,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
]
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -320,7 +304,7 @@ export function FileExplorerPane({
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
})
}),
),
]);
return null;
@@ -342,7 +326,7 @@ export function FileExplorerPane({
easing: Easing.linear,
}),
-1,
false
false,
);
return;
}
@@ -366,7 +350,7 @@ export function FileExplorerPane({
if (finished) {
refreshIconRotation.value = 0;
}
}
},
);
}, [isRefreshFetching, refreshIconRotation]);
@@ -394,10 +378,7 @@ export function FileExplorerPane({
!directories.has(".") &&
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const showBackFromError = Boolean(error && selectedEntryPath);
const errorRecoveryPath = useMemo(
() => getErrorRecoveryPath(explorerState),
[explorerState]
);
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
const renderTreeRow = useCallback(
({ item }: ListRenderItemInfo<TreeRow>) => {
@@ -495,7 +476,7 @@ export function FileExplorerPane({
selectedEntryPath,
theme.colors,
theme.spacing,
]
],
);
const handleBackFromError = useCallback(() => {
@@ -515,7 +496,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
@@ -524,7 +505,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) {
@@ -536,9 +517,7 @@ export function FileExplorerPane({
}
return (
<View
style={styles.container}
>
<View style={styles.container}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
@@ -632,16 +611,7 @@ export function FileExplorerPane({
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"webp",
"ico",
]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
@@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean }
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
@@ -783,7 +753,7 @@ function buildTreeRows({
sortOption,
path: entry.path,
depth: depth + 1,
})
}),
);
}
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import React, { useCallback, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
Image as RNImage,
@@ -10,54 +10,54 @@ import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { Fonts } from '@/constants/theme'
import { useSessionStore, type ExplorerFile } from '@/stores/session-store'
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from '@/components/web-desktop-scrollbar'
} from "@/components/web-desktop-scrollbar";
import {
highlightCode,
darkHighlightColors,
lightHighlightColors,
type HighlightToken,
type HighlightStyle,
} from '@getpaseo/highlight'
} from "@getpaseo/highlight";
interface CodeLineProps {
tokens: HighlightToken[]
lineNumber: number
gutterWidth: number
colorMap: Record<HighlightStyle, string>
baseColor: string
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
colorMap: Record<HighlightStyle, string>;
baseColor: string;
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null
isLoading: boolean
showDesktopWebScrollbar: boolean
isMobile: boolean
filePath: string
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`
return `${size} B`;
}
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({
@@ -76,22 +76,22 @@ const CodeLine = React.memo(function CodeLine({
{tokens.map((token, index) => (
<Text
key={index}
style={{ color: token.style ? colorMap[token.style] ?? baseColor : baseColor }}
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
>
{token.text}
</Text>
))}
</Text>
</View>
)
})
);
});
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: 'row',
flexDirection: "row",
},
gutter: {
alignItems: 'flex-end',
alignItems: "flex-end",
paddingRight: theme.spacing[3],
flexShrink: 0,
},
@@ -107,7 +107,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
lineHeight: theme.fontSize.sm * 1.45,
flex: 1,
},
}))
}));
function FilePreviewBody({
preview,
@@ -116,46 +116,46 @@ function FilePreviewBody({
isMobile,
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles()
const isDark = theme.colors.surface0 === '#18181c'
const colorMap = isDark ? darkHighlightColors : lightHighlightColors
const baseColor = isDark ? '#c9d1d9' : '#24292f'
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar
const previewScrollRef = useRef<RNScrollView>(null)
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics()
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== 'text') {
return null
if (!preview || preview.kind !== "text") {
return null;
}
return highlightCode(preview.content ?? '', filePath)
}, [preview?.kind, preview?.content, filePath])
return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0
const digits = String(highlightedLines.length).length
return Math.max(digits * 8 + 12, 28)
}, [highlightedLines])
if (!highlightedLines) return 0;
const digits = String(highlightedLines.length).length;
return Math.max(digits * 8 + 12, 28);
}, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event)
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
)
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event)
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
)
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) {
return (
@@ -163,7 +163,7 @@ function FilePreviewBody({
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file</Text>
</View>
)
);
}
if (!preview) {
@@ -171,11 +171,11 @@ function FilePreviewBody({
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text>
</View>
)
);
}
if (preview.kind === 'text') {
const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]]
if (preview.kind === "text") {
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
{lines.map((tokens, index) => (
@@ -189,7 +189,7 @@ function FilePreviewBody({
/>
))}
</View>
)
);
return (
<View style={styles.previewScrollContainer}>
@@ -221,14 +221,14 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
)
);
}
if (preview.kind === 'image' && preview.content) {
if (preview.kind === "image" && preview.content) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -245,7 +245,7 @@ function FilePreviewBody({
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? 'image/png'};base64,${preview.content}`,
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.previewImage}
resizeMode="contain"
@@ -255,11 +255,11 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
)
);
}
return (
@@ -267,7 +267,7 @@ function FilePreviewBody({
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
)
);
}
export function FilePane({
@@ -275,33 +275,33 @@ export function FilePane({
workspaceRoot,
filePath,
}: {
serverId: string
workspaceRoot: string
filePath: string
serverId: string;
workspaceRoot: string;
filePath: string;
}) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot])
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath])
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const query = useQuery({
queryKey: ['workspaceFile', serverId, normalizedWorkspaceRoot, normalizedFilePath],
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
queryFn: async () => {
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(
normalizedWorkspaceRoot,
normalizedFilePath,
'file'
)
return { file: payload.file ?? null, error: payload.error ?? null }
"file",
);
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
})
});
return (
<View style={styles.container} testID="workspace-file-pane">
@@ -319,7 +319,7 @@ export function FilePane({
filePath={filePath}
/>
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
@@ -330,8 +330,8 @@ const styles = StyleSheet.create((theme) => ({
},
centerState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
loadingText: {
@@ -342,12 +342,12 @@ const styles = StyleSheet.create((theme) => ({
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
textAlign: 'center',
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: 'center',
textAlign: "center",
},
binaryMetaText: {
marginTop: theme.spacing[2],
@@ -368,11 +368,11 @@ const styles = StyleSheet.create((theme) => ({
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
},
previewImage: {
width: '100%',
width: "100%",
height: 420,
},
}))
}));

View File

@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
@@ -68,7 +66,7 @@ describe("git-actions-policy", () => {
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
})
}),
);
expect(noPrActions.primary).toBeNull();
@@ -117,18 +115,20 @@ describe("git-actions-policy", () => {
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
})
}),
);
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", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
})
}),
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");

View File

@@ -54,12 +54,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = [
"merge-branch",
"pr",
"merge-from-base",
"push",
];
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
});
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)!);
if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!);
@@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled:
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,

View File

@@ -49,7 +49,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
) : (
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
<Text style={styles.splitButtonText}>
{getActionDisplayLabel(gitActions.primary)}
</Text>
</View>
)}
</Pressable>

File diff suppressed because it is too large Load Diff

View File

@@ -11,21 +11,14 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
return (
<ScreenHeader
left={
<>
<Pressable
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (

View File

@@ -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,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
/>
</View>
);
@@ -46,17 +42,15 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<HeaderToggleButton
@@ -81,10 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={

View File

@@ -22,12 +22,7 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -37,9 +32,7 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left
: 0;
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();

View File

@@ -25,10 +25,34 @@ export function SourceControlPanelIcon({
strokeLinejoin="round"
/>
{/* Plus */}
<Line 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" />
<Line
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 */}
<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>
);
}

View File

@@ -16,7 +16,7 @@ export function KeyboardShortcutsDialog() {
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac]
[isDesktopApp, isMac],
);
return (

View File

@@ -9,103 +9,108 @@ import {
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
} from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
runOnJS,
useSharedValue,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
import { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list'
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
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'
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
useSidebarWorkspacesList,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
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 {
buildHostSessionsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from '@/utils/host-routes'
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
const DESKTOP_SIDEBAR_WIDTH = 320
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
const DESKTOP_SIDEBAR_WIDTH = 320;
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
interface LeftSidebarProps {
selectedAgentId?: string
selectedAgentId?: string;
}
interface HostOption {
id: string
label: string
description: string
id: string;
label: string;
description: string;
}
interface SidebarSharedProps {
theme: SidebarTheme
activeServerId: string | null
activeHostLabel: string
activeHostStatusColor: string
hostOptions: HostOption[]
hostTriggerRef: RefObject<View | null>
isHostPickerOpen: boolean
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
projects: SidebarProjectEntry[]
isInitialLoad: boolean
isRevalidating: boolean
isManualRefresh: boolean
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
handleRefresh: () => void
handleHostSelect: (nextServerId: string) => void
handleOpenProject: () => void
handleSettings: () => void
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: HostOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
setProjectCollapsed: SidebarShortcutModel["setProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleSettings: () => void;
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number
insetsBottom: number
isOpen: boolean
closeToAgent: () => void
handleViewMoreNavigate: () => void
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeToAgent: () => void;
handleViewMoreNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
isOpen: boolean
handleViewMore: () => void
isOpen: boolean;
handleViewMore: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
void _selectedAgentId
export const LeftSidebar = memo(function LeftSidebar({
selectedAgentId: _selectedAgentId,
}: LeftSidebarProps) {
void _selectedAgentId;
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const mobileView = usePanelStore((state) => state.mobileView)
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
const daemons = useHosts()
const runtime = getHostRuntimeStore()
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() =>
@@ -113,128 +118,132 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|'),
.join("|"),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|')
)
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
.join("|"),
);
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => {
if (daemons.length === 0) {
return null
return null;
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath)
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch
return routeMatch;
}
}
return daemons[0] ?? null
}, [activeServerIdFromPath, daemons])
const activeServerId = activeDaemon?.serverId ?? null
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return 'No host'
const trimmed = activeDaemon.label?.trim()
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId
}, [activeDaemon])
if (!activeDaemon) return "No host";
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]);
const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
: 'idle'
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
: "idle";
const activeHostStatusColor =
activeHostStatus === 'online'
activeHostStatus === "online"
? theme.colors.palette.green[400]
: activeHostStatus === 'connecting'
: activeHostStatus === "connecting"
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500]
: theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
),
})),
[daemons, runtime, runtimeConnectionStatusSignature]
)
const hostTriggerRef = useRef<View | null>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
[daemons, runtime, runtimeConnectionStatusSignature],
);
const hostTriggerRef = useRef<View | null>(null);
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({
serverId: activeServerId,
enabled: isOpen,
})
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects)
});
const {
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
} = useSidebarShortcutModel(projects);
const [isManualRefresh, setIsManualRefresh] = useState(false)
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true)
refreshAll()
}, [refreshAll])
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false)
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh])
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker(activeServerId)
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => {
closeToAgent()
void openProjectPicker()
}, [closeToAgent, openProjectPicker])
closeToAgent();
void openProjectPicker();
}, [closeToAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker()
}, [openProjectPicker])
void openProjectPicker();
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return
return;
}
closeToAgent()
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent])
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostSessionsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSessionsRoute(activeServerId) as any);
}, [activeServerId]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId)
setIsHostPickerOpen(false)
router.push(nextPath as any)
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
},
[pathname]
)
[pathname],
);
const sharedProps = {
theme,
@@ -255,7 +264,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
setProjectCollapsed,
handleRefresh,
handleHostSelect,
}
};
if (isMobile) {
return (
@@ -269,7 +278,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
)
);
}
return (
@@ -280,13 +289,13 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
)
})
);
});
function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles()
const pathname = usePathname()
const isActive = pathname.includes('/sessions')
const { theme } = useUnistyles();
const pathname = usePathname();
const isActive = pathname.includes("/sessions");
return (
<Pressable
@@ -308,14 +317,17 @@ function SessionsButton({ onPress }: { onPress: () => void }) {
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, (hovered || isActive) && styles.newAgentButtonTextHovered]}
style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
)
);
}
function MobileSidebar({
@@ -353,22 +365,22 @@ function MobileSidebar({
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
closeToAgent();
}, [closeToAgent]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return
return;
}
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
handleViewMoreNavigate()
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
@@ -376,7 +388,7 @@ function MobileSidebar({
handleViewMoreNavigate,
translateX,
windowWidth,
])
]);
const closeGesture = useMemo(
() =>
@@ -385,62 +397,62 @@ function MobileSidebar({
.enabled(isOpen)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch) {
return
return;
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (deltaX >= 10) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true
isGesturing.value = true;
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
animateToClose();
runOnJS(handleClose)();
} else {
animateToOpen()
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false
isGesturing.value = false;
}),
[
isOpen,
@@ -454,29 +466,29 @@ function MobileSidebar({
animateToClose,
animateToOpen,
handleClose,
]
)
],
);
const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom]
)
[windowWidth, insetsTop, insetsBottom],
);
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}))
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
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 (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -554,7 +566,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
<Shortcut keys={["⌘", "⇧", "O"]} />
</View>
</TooltipContent>
</Tooltip>
@@ -578,7 +590,7 @@ function MobileSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -592,7 +604,7 @@ function MobileSidebar({
</Animated.View>
</GestureDetector>
</View>
)
);
}
function DesktopSidebar({
@@ -619,15 +631,15 @@ function DesktopSidebar({
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const dragHandlers = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
if (!isOpen) {
return null
return null;
}
return (
@@ -697,7 +709,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
<Shortcut keys={["⌘", "⇧", "O"]} />
</View>
</TooltipContent>
</Tooltip>
@@ -721,7 +733,7 @@ function DesktopSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -732,29 +744,29 @@ function DesktopSidebar({
/>
</View>
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: 'hidden',
overflow: "hidden",
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
overflow: "hidden",
},
desktopSidebar: {
borderRightWidth: 1,
@@ -767,21 +779,21 @@ const styles = StyleSheet.create((theme) => ({
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: 'center',
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: 'none',
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
@@ -802,9 +814,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface1,
},
hostTrigger: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
@@ -826,9 +838,9 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
sidebarFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
@@ -841,16 +853,16 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
@@ -876,19 +888,19 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: 'center',
alignItems: "center",
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
}))
}));

File diff suppressed because it is too large Load Diff

View File

@@ -40,11 +40,7 @@ import {
TriangleAlertIcon,
Scissors,
} from "lucide-react-native";
import {
StyleSheet,
useUnistyles,
UnistylesRuntime,
} from "react-native-unistyles";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import Animated, {
Easing,
cancelAnimation,
@@ -53,23 +49,14 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { theme } from "@/styles/theme";
import {
createMarkdownStyles,
} from "@/styles/markdown-styles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import {
hasMeaningfulToolCallDetail,
@@ -159,10 +146,7 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true;
}
function getWheelEventElementTarget(
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
const { target } = event;
if (target instanceof HTMLElement) {
return target;
@@ -176,7 +160,7 @@ function getWheelEventElementTarget(
function canElementScrollInDirection(
element: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -192,7 +176,9 @@ function canElementScrollInDirection(
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
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) {
return false;
}
@@ -207,7 +193,7 @@ function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement,
startElement: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -226,14 +212,10 @@ function canScrollInsideDetailFromTarget(
return false;
}
function shouldStopDetailWheelPropagation(
detailRoot: HTMLElement,
event: WheelEvent
): boolean {
function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
const startElement = getWheelEventElementTarget(event, detailRoot);
const verticalDelta = event.deltaY;
const horizontalDelta =
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
@@ -245,12 +227,7 @@ function shouldStopDetailWheelPropagation(
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false;
const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget(
detailRoot,
startElement,
"x",
horizontalDelta
)
? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
: false;
if (hasVerticalIntent && hasHorizontalIntent) {
@@ -336,11 +313,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function UserMessageAttachmentThumbnail({
image,
}: {
image: UserMessageImageAttachment;
}) {
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
@@ -358,12 +331,10 @@ export const UserMessage = memo(function UserMessage({
}: UserMessageProps) {
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const showCopyButton =
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
return (
<View
@@ -372,20 +343,14 @@ export const UserMessage = memo(function UserMessage({
!resolvedDisableOuterSpacing && [
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup
? { marginBottom: theme.spacing[1] }
: undefined,
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
],
]}
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={
Platform.OS === "web" ? () => setMessageHovered(true) : undefined
}
onHoverOut={
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -396,10 +361,7 @@ export const UserMessage = memo(function UserMessage({
]}
>
{images.map((image, index) => (
<View
key={`${image.id}-${index}`}
style={userMessageStylesheet.imagePill}
>
<View key={`${image.id}-${index}`} style={userMessageStylesheet.imagePill}>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
@@ -475,11 +437,7 @@ function MarkdownLink({
const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") {
return (
<Text
accessibilityRole="link"
onPress={() => onPress(href)}
style={style}
>
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children}
</Text>
);
@@ -492,25 +450,25 @@ function MarkdownLink({
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
{children}
</Text>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>{children}</Text>
</Pressable>
);
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string
content: string,
): string | null {
const trimmed = content.trim();
if (!trimmed) {
return null;
}
const matches = markdownParser.linkify.match(trimmed) as
| Array<{ index: number; lastIndex: number; url: string }>
| null;
const matches = markdownParser.linkify.match(trimmed) as Array<{
index: number;
lastIndex: number;
url: string;
}> | null;
if (!matches || matches.length !== 1) {
return null;
}
@@ -604,9 +562,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
copied
? (copiedAccessibilityLabel ?? "Copied")
: (accessibilityLabel ?? "Copy turn")
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
}
>
{({ hovered }) => {
@@ -767,41 +723,38 @@ export const AssistantMessage = memo(function AssistantMessage({
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownParser = useMemo(
() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
return defaultValidateLink(url);
};
return parser;
},
[]
);
return defaultValidateLink(url);
};
return parser;
}, []);
const handleLinkPress = useCallback((url: string) => {
const fileTarget = onInlinePathPress
? parseAssistantFileLink(url, { workspaceRoot })
: null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
const handleLinkPress = useCallback(
(url: string) => {
const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
if (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;
}
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]);
},
[onInlinePathPress, workspaceRoot],
);
const markdownRules = useMemo(() => {
return {
@@ -810,7 +763,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
@@ -821,12 +774,9 @@ export const AssistantMessage = memo(function AssistantMessage({
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
>
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
@@ -835,12 +785,9 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
@@ -849,7 +796,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
@@ -860,16 +807,14 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => {
const content = node.content ?? "";
const isLinkedInlineCode =
nodeHasParentType(parent, "link") ||
(!Array.isArray(parent) &&
typeof parent?.attributes?.href === "string");
const parsed = onInlinePathPress && !isLinkedInlineCode
? parseInlinePathToken(content)
: null;
(!Array.isArray(parent) && typeof parent?.attributes?.href === "string");
const parsed =
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
if (parsed) {
return (
@@ -877,10 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
</Text>
@@ -902,81 +844,42 @@ export const AssistantMessage = memo(function AssistantMessage({
}
return (
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{content}
</Text>
);
},
bullet_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (
node: any,
children: ReactNode[],
parent: any,
styles: any
) => {
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered
? styles.ordered_list_icon
: styles.bullet_list_icon;
const contentStyle = isOrdered
? styles.ordered_list_content
: styles.bullet_list_content;
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
</View>
);
},
paragraph: (
node: any,
children: ReactNode[],
parent: any,
styles: any,
) => {
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View
key={node.key}
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
>
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
{children}
</View>
);
},
link: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
) => (
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<MarkdownLink
key={node.key}
href={node.attributes?.href ?? ""}
@@ -985,8 +888,10 @@ export const AssistantMessage = memo(function AssistantMessage({
>
{Children.map(children, (child) =>
isValidElement(child)
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
: child
? cloneElement(child, {
style: [(child.props as any).style, { color: styles.link.color }],
} as any)
: child,
)}
</MarkdownLink>
),
@@ -998,8 +903,7 @@ export const AssistantMessage = memo(function AssistantMessage({
testID="assistant-message"
style={[
assistantMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
assistantMessageStylesheet.containerSpacing,
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
]}
>
<Markdown
@@ -1109,8 +1013,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick,
disableOuterSpacing,
}: ActivityLogProps) {
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
@@ -1149,9 +1052,7 @@ export const ActivityLog = memo(function ActivityLog({
};
const displayMessage =
type === "artifact" && artifactType && title
? `${artifactType}: ${title}`
: message;
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
const isInteractive = type === "artifact" || metadata;
@@ -1172,12 +1073,7 @@ export const ActivityLog = memo(function ActivityLog({
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text
style={[
activityLogStylesheet.messageText,
{ color: config.color },
]}
>
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
{displayMessage}
</Text>
{metadata && (
@@ -1402,21 +1298,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
testID,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails);
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
testID ?? label
}`;
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
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 [labelRowHeight, setLabelRowHeight] = useState(0);
@@ -1426,77 +1318,64 @@ const ExpandableBadge = memo(function ExpandableBadge({
const [secondaryWidth, setSecondaryWidth] = useState(0);
const shimmerTranslateX = useSharedValue(0);
const totalShimmerChars =
label.trim().length + (secondaryLabel?.trim().length ?? 0);
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
const shimmerDuration = Math.max(
1,
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment)
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment),
);
const nativeShimmerPeakWidth = Math.max(
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 shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
const isNativeShimmer =
shouldMeasureNativeShimmer &&
labelRowWidth > 0 &&
labelRowHeight > 0;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel
? secondaryOffsetX + secondaryWidth
: labelOffsetX + labelWidth;
const webShimmerSpanWidth = Math.max(
1,
webShimmerSpanEndX - webShimmerSpanStartX
);
const webShimmerPeakWidth = Math.max(
42,
Math.min(120, webShimmerSpanWidth * 0.22)
);
const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
const webShimmerTrackEnd = webShimmerSpanEndX;
const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
setLabelRowHeight((previous) =>
Math.abs(previous - height) > 0.5 ? height : previous
);
}, [shouldMeasureNativeShimmer]);
const handleLabelRowLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
},
[shouldMeasureNativeShimmer],
);
const handleLabelLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setLabelWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer]);
const handleLabelLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer],
);
const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setSecondaryWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer, secondaryLabel]);
const handleSecondaryLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer, secondaryLabel],
);
useEffect(() => {
if (!isWebShimmer) {
@@ -1520,18 +1399,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
easing: Easing.linear,
}),
-1,
false
false,
);
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [
isNativeShimmer,
labelRowWidth,
nativeShimmerPeakWidth,
shimmerDuration,
shimmerTranslateX,
]);
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
@@ -1605,23 +1478,21 @@ const ExpandableBadge = memo(function ExpandableBadge({
: expandableBadgeStylesheet.containerSpacing),
style,
],
[isLastInSequence, resolvedDisableOuterSpacing, style]
[isLastInSequence, resolvedDisableOuterSpacing, style],
);
const pressableStyle = useMemo(
() => [
expandableBadgeStylesheet.pressable,
isPressed && isInteractive
? expandableBadgeStylesheet.pressablePressed
: null,
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
isExpanded && expandableBadgeStylesheet.pressableExpanded,
],
[isExpanded, isInteractive, isPressed]
[isExpanded, isInteractive, isPressed],
);
const accessibilityState = useMemo(
() => (isInteractive ? { expanded: isExpanded } : undefined),
[isExpanded, isInteractive]
[isExpanded, isInteractive],
);
const isActive = isHovered || isExpanded;
@@ -1632,7 +1503,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
isActive && expandableBadgeStylesheet.labelActive,
isLoading && expandableBadgeStylesheet.labelLoading,
],
[isActive, isLoading]
[isActive, isLoading],
);
const secondaryLabelStyle = useMemo(
@@ -1640,7 +1511,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.secondaryLabel,
isActive && expandableBadgeStylesheet.secondaryLabelActive,
],
[isActive]
[isActive],
);
const shimmerLabelTextStyle = useMemo(
@@ -1650,7 +1521,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerLabelStyle,
],
[isLoading, shimmerLabelStyle]
[isLoading, shimmerLabelStyle],
);
const shimmerSecondaryTextStyle = useMemo(
@@ -1659,7 +1530,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerSecondaryStyle,
],
[shimmerSecondaryStyle]
[shimmerSecondaryStyle],
);
const nativeShimmerTrackStyle = useMemo(
@@ -1667,7 +1538,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.nativeShimmerTrack,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeShimmerMaskStyle = useMemo(
@@ -1675,20 +1546,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerMaskRow,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeLabelMaskStyle = useMemo(
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
[]
[],
);
const nativeSecondaryMaskStyle = useMemo(
() => [
expandableBadgeStylesheet.secondaryLabel,
{ color: "#000000", opacity: 1 },
],
[]
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
[],
);
const nativeShimmerPeakCombinedStyle = useMemo(
@@ -1697,7 +1565,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
nativeShimmerPeakStyle,
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
],
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth]
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
);
const chevronStyle = useMemo(
@@ -1705,7 +1573,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.chevron,
isExpanded && expandableBadgeStylesheet.chevronExpanded,
],
[isExpanded]
[isExpanded],
);
const IconComponent = icon;
@@ -1723,10 +1591,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
return (
<View
style={containerStyle}
testID={testID}
>
<View style={containerStyle} testID={testID}>
<Pressable
onPress={isInteractive ? onToggle : undefined}
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
@@ -1770,21 +1635,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
<View style={expandableBadgeStylesheet.spacer} />
)}
{isWebShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<Text
style={shimmerLabelTextStyle}
numberOfLines={1}
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<Text style={shimmerLabelTextStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={shimmerSecondaryTextStyle}
numberOfLines={1}
>
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1793,25 +1649,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
) : null}
{isNativeShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<MaskedView
style={nativeShimmerTrackStyle}
maskElement={
<View style={nativeShimmerMaskStyle}>
<Text
style={nativeLabelMaskStyle}
numberOfLines={1}
>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={nativeSecondaryMaskStyle}
numberOfLines={1}
>
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1820,9 +1667,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
}
>
<View
style={nativeShimmerTrackStyle}
>
<View style={nativeShimmerTrackStyle}>
<Animated.View style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
@@ -1853,11 +1698,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{isInteractive && isHovered ? (
<ChevronRight
size={14}
color={theme.colors.foreground}
style={chevronStyle}
/>
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
) : null}
</View>
</Pressable>
@@ -1875,10 +1716,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
);
}, areExpandableBadgePropsEqual);
function areExpandableBadgePropsEqual(
previous: ExpandableBadgeProps,
next: ExpandableBadgeProps
) {
function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
if (previous.label !== next.label) return false;
if (previous.secondaryLabel !== next.secondaryLabel) return false;
if (previous.icon !== next.icon) return false;
@@ -1932,9 +1770,7 @@ export const ToolCall = memo(function ToolCall({
const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile =
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {
@@ -1950,12 +1786,11 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const displayDetail =
effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayDetail = effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayModel = useMemo(
() =>
@@ -1967,7 +1802,7 @@ export const ToolCall = memo(function ToolCall({
metadata,
cwd,
}),
[toolName, status, error, displayDetail, metadata, cwd]
[toolName, status, error, displayDetail, metadata, cwd],
);
const displayName = displayModel.displayName;
const summary = displayModel.summary;
@@ -1981,9 +1816,7 @@ export const ToolCall = memo(function ToolCall({
const secondaryLabel = summary;
// Check if there's any content to display
const hasDetails =
Boolean(error) ||
hasMeaningfulToolCallDetail(effectiveDetail);
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const handleToggle = useCallback(() => {

View File

@@ -18,12 +18,7 @@ export function ModeSelectorModal({
const { theme } = useUnistyles();
return (
<Modal
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
>
<Modal visible={visible} animationType="fade" transparent={true} onRequestClose={onClose}>
<Pressable style={styles.modalOverlay} onPress={onClose}>
<View style={styles.modeSelectorContent}>
{agent?.availableModes?.map((mode) => {
@@ -35,26 +30,13 @@ export function ModeSelectorModal({
onModeChange(mode.id);
onClose();
}}
style={[
styles.modeItem,
isActive && styles.modeItemActive,
]}
style={[styles.modeItem, isActive && styles.modeItemActive]}
>
<Text
style={[
styles.modeName,
isActive && styles.modeNameActive,
]}
>
<Text style={[styles.modeName, isActive && styles.modeNameActive]}>
{mode.label}
</Text>
{mode.description && (
<Text
style={[
styles.modeDescription,
isActive && styles.modeDescriptionActive,
]}
>
<Text style={[styles.modeDescription, isActive && styles.modeDescriptionActive]}>
{mode.description}
</Text>
)}

View File

@@ -81,10 +81,13 @@ export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: N
}, [label, onSave, onSkip]);
return (
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
<Text style={styles.helper}>
Optional. You can rename this later in Settings.
</Text>
<AdaptiveModalSheet
title="Name this host"
visible={visible}
onClose={onSkip}
testID="name-host-modal"
>
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>

View File

@@ -48,15 +48,25 @@ export interface PairLinkModalProps {
onClose: () => void;
targetServerId?: string;
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 daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false);
@@ -148,10 +158,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
}, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
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>
<View style={styles.field}>

View File

@@ -1,13 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -37,7 +29,7 @@ export function ProjectPickerModal() {
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const inputRef = useRef<TextInput>(null);
@@ -49,7 +41,7 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id
(workspace) => workspace.projectRootPath || workspace.id,
);
}, [workspaces]);
@@ -64,9 +56,7 @@ export function ProjectPickerModal() {
limit: 30,
});
return (
result.entries?.flatMap((entry) =>
entry.kind === "directory" ? [entry.path] : []
) ?? []
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
);
},
enabled: Boolean(client) && isConnected && open,
@@ -81,7 +71,7 @@ export function ProjectPickerModal() {
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[query, directorySuggestionsQuery.data, recommendedPaths]
[query, directorySuggestionsQuery.data, recommendedPaths],
);
const handleClose = useCallback(() => {
@@ -103,7 +93,7 @@ export function ProjectPickerModal() {
setIsSubmitting(false);
}
},
[client, openProject, serverId, setOpen]
[client, openProject, serverId, setOpen],
);
const handleSubmitCustom = useCallback(() => {
@@ -136,13 +126,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) {
const key = event.key;
if (
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "Enter" &&
key !== "Escape"
)
return;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
@@ -180,12 +164,7 @@ export function ProjectPickerModal() {
if (!serverId) return null;
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
@@ -198,9 +177,7 @@ export function ProjectPickerModal() {
},
]}
>
<View
style={[styles.header, { borderBottomColor: theme.colors.border }]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
ref={inputRef}
value={query}
@@ -225,21 +202,11 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Opening project...
</Text>
) : options.length === 0 && !query.trim() ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Start typing a path
</Text>
) : (
@@ -266,10 +233,7 @@ export function ProjectPickerModal() {
/>
</View>
<Text
style={[
styles.rowText,
{ color: theme.colors.foreground },
]}
style={[styles.rowText, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{shortenPath(path)}

View File

@@ -1,12 +1,12 @@
import { Bot } from 'lucide-react-native'
import { ClaudeIcon } from '@/components/icons/claude-icon'
import { CodexIcon } from '@/components/icons/codex-icon'
import { Bot } from "lucide-react-native";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
}
};
export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot
return PROVIDER_ICONS[provider] ?? Bot;
}

View File

@@ -1,12 +1,5 @@
import { useState, useCallback } from "react";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
@@ -68,52 +61,42 @@ interface QuestionFormCardProps {
const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({
permission,
onRespond,
isResponding,
}: QuestionFormCardProps) {
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<
"submit" | "dismiss" | null
>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
}
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
next.add(optIndex);
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
},
[]
);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
}
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) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -182,9 +165,7 @@ export function QuestionFormCard({
return (
<View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text
style={[styles.questionText, { color: theme.colors.foreground }]}
>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
@@ -202,19 +183,12 @@ export function QuestionFormCard({
},
pressed && styles.optionItemPressed,
]}
onPress={() =>
toggleOption(qIndex, optIndex, q.multiSelect)
}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text
style={[
styles.optionLabel,
{ color: theme.colors.foreground },
]}
>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
@@ -230,10 +204,7 @@ export function QuestionFormCard({
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check
size={16}
color={theme.colors.foregroundMuted}
/>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
@@ -245,9 +216,8 @@ export function QuestionFormCard({
style={[
styles.otherInput,
{
borderColor: otherText.length > 0
? theme.colors.borderAccent
: theme.colors.border,
borderColor:
otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
@@ -264,19 +234,12 @@ export function QuestionFormCard({
);
})}
<View
style={[
styles.actionsContainer,
!isMobile && styles.actionsContainerDesktop,
]}
>
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered
? theme.colors.surface2
: theme.colors.surface1,
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
@@ -285,19 +248,11 @@ export function QuestionFormCard({
disabled={isResponding}
>
{respondingAction === "dismiss" ? (
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text
style={[
styles.actionText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
</View>
@@ -311,12 +266,8 @@ export function QuestionFormCard({
styles.actionButton,
{
backgroundColor:
hovered && !disabled
? theme.colors.surface2
: theme.colors.surface1,
borderColor: disabled
? theme.colors.border
: theme.colors.borderAccent,
hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: disabled ? theme.colors.border : theme.colors.borderAccent,
opacity: disabled ? 0.5 : 1,
},
pressed && !disabled ? styles.optionItemPressed : null,
@@ -326,27 +277,18 @@ export function QuestionFormCard({
disabled={!allAnswered || isResponding}
>
{respondingAction === "submit" ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
/>
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={
allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.actionText,
{
color: allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted,
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
>

View File

@@ -73,8 +73,7 @@ export function ResizeHandle({
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
@@ -91,7 +90,7 @@ export function ResizeHandle({
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
[direction, groupId, index, onResizeSplit, sizes],
);
return (
@@ -107,9 +106,7 @@ export function ResizeHandle({
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal"
? styles.highlightHorizontal
: styles.highlightVertical,
direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>

View File

@@ -2,13 +2,7 @@ import { useEffect, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
@@ -33,7 +27,7 @@ export function SidebarAgentListSkeleton() {
duration: 1000,
useNativeDriver: true,
}),
])
]),
);
animation.start();
@@ -55,10 +49,7 @@ export function SidebarAgentListSkeleton() {
<View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => (
<View
key={`skeleton-row-${sectionIdx}-${rowIdx}`}
style={styles.row}
>
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />

File diff suppressed because it is too large Load Diff

View File

@@ -5,4 +5,3 @@
// Metro's platform-specific extensions will pick the right one at runtime.
export * from "./sortable-inline-list.native";

View File

@@ -66,7 +66,7 @@ function SortableItem<T>({
const baseTransform = externalDndContext
? undefined
: 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 combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -87,9 +87,7 @@ function SortableItem<T>({
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
};
@@ -132,7 +130,7 @@ export function SortableInlineList<T>({
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = externalDndContext ? data : dragItems ?? data;
const items = externalDndContext ? data : (dragItems ?? data);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -142,7 +140,7 @@ export function SortableInlineList<T>({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const handleDragStart = useCallback(
@@ -154,7 +152,7 @@ export function SortableInlineList<T>({
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, disabled, onDragBegin]
[data, disabled, onDragBegin],
);
const handleDragEnd = useCallback(
@@ -169,21 +167,16 @@ export function SortableInlineList<T>({
}
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
onDragEnd?.(newItems);
}
}
},
[disabled, items, keyExtractor, onDragEnd]
[disabled, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));

View File

@@ -5,13 +5,13 @@ describe("shouldFocusPaneFromEventTarget", () => {
it("returns false for links and buttons", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "A" } as Element),
} as unknown as EventTarget)
closest: () => ({ tagName: "A" }) as Element,
} as unknown as EventTarget),
).toBe(false);
expect(
shouldFocusPaneFromEventTarget({
closest: () => ({ tagName: "BUTTON" } as Element),
} as unknown as EventTarget)
closest: () => ({ tagName: "BUTTON" }) as Element,
} as unknown as EventTarget),
).toBe(false);
});
@@ -19,7 +19,7 @@ describe("shouldFocusPaneFromEventTarget", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: () => null,
} as unknown as EventTarget)
} as unknown as EventTarget),
).toBe(true);
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
});

View File

@@ -27,7 +27,7 @@ describe("computeTabDropPreview", () => {
targetTabs,
activeRect: { left: 180, width: 40 },
overRect: { left: 200, width: 100 },
})
}),
).toEqual({
paneId: "target",
insertionIndex: 2,
@@ -45,7 +45,7 @@ describe("computeTabDropPreview", () => {
targetTabs,
activeRect: { left: 280, width: 40 },
overRect: { left: 200, width: 100 },
})
}),
).toEqual({
paneId: "target",
insertionIndex: 3,
@@ -63,7 +63,7 @@ describe("computeTabDropPreview", () => {
targetTabs,
activeRect: { left: 460, width: 40 },
overRect: { left: 400, width: 100 },
})
}),
).toEqual({
paneId: "pane",
insertionIndex: 3,

View File

@@ -22,9 +22,7 @@ interface ComputeTabDropPreviewInput {
};
}
export function computeTabDropPreview(
input: ComputeTabDropPreviewInput
): TabDropPreview | null {
export function computeTabDropPreview(input: ComputeTabDropPreviewInput): TabDropPreview | null {
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
if (targetIndex < 0 || input.overRect.width <= 0) {
return null;

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 {
DndContext,
DragOverlay,
@@ -45,7 +55,12 @@ import {
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
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";
interface SplitContainerProps {
@@ -65,7 +80,10 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (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;
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
buildPaneContentModel: (input: {
@@ -100,8 +118,7 @@ interface SplitPaneDropData {
paneId: string;
}
interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout"> {
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout"> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string;
@@ -135,14 +152,14 @@ interface SplitPaneViewProps
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
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) {
return tabHits;
}
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) {
return paneHits;
@@ -192,7 +209,7 @@ export function SplitContainer({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
@@ -215,11 +232,7 @@ export function SplitContainer({
}, []);
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 overData = event.over?.data.current as
| WorkspaceTabDragData
@@ -268,7 +281,7 @@ export function SplitContainer({
left: overRect.left,
width: overRect.width,
},
})
}),
);
return;
}
@@ -305,7 +318,7 @@ export function SplitContainer({
}),
});
},
[panesById, uiTabs]
[panesById, uiTabs],
);
const handleDragEnd = useCallback(
@@ -346,8 +359,15 @@ export function SplitContainer({
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
const nextTabs = arrayMove(
sourceTabs,
sourceIndex,
resolvedTabDropPreview.insertionIndex,
);
onReorderTabsInPane(
activeData.paneId,
nextTabs.map((tab) => tab.tabId),
);
}
setDropPreview(null);
setTabDropPreview(null);
@@ -383,7 +403,15 @@ export function SplitContainer({
setDropPreview(null);
setTabDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
[
dropPreview,
onMoveTabToPane,
onReorderTabsInPane,
onSplitPane,
panesById,
tabDropPreview,
uiTabs,
],
);
return (
@@ -492,8 +520,7 @@ function DragOverlayTabChipInner({
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label =
presentation.titleState === "loading" ? "Loading..." : presentation.label;
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
return (
<View
@@ -508,10 +535,7 @@ function DragOverlayTabChipInner({
<WorkspaceTabIcon presentation={presentation} active size={14} />
<Text
numberOfLines={1}
style={[
styles.dragOverlayLabel,
{ color: theme.colors.foreground },
]}
style={[styles.dragOverlayLabel, { color: theme.colors.foreground }]}
>
{label}
</Text>
@@ -593,8 +617,9 @@ function SplitNodeView({
}
const groupSizes =
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
node.group.sizes;
useWorkspaceLayoutStore(
(state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id],
) ?? node.group.sizes;
return (
<View
@@ -695,12 +720,9 @@ function SplitPaneView({
pane,
tabs: uiTabs,
}),
[pane, uiTabs]
);
const paneTabs = useMemo(
() => paneState.tabs.map((tab) => tab.descriptor),
[paneState.tabs]
[pane, uiTabs],
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
@@ -710,12 +732,7 @@ function SplitPaneView({
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: closingTabIds.has(tab.tabId),
})),
[
activeTabDescriptor?.key,
closingTabIds,
hoveredCloseTabKey,
paneTabs,
]
[activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs],
);
const paneContent = useMemo(
() =>
@@ -726,11 +743,7 @@ function SplitPaneView({
tab: activeTabDescriptor,
})
: null,
[
activeTabDescriptor,
buildPaneContentModel,
pane.id,
]
[activeTabDescriptor, buildPaneContentModel, pane.id],
);
useEffect(() => {
@@ -771,11 +784,7 @@ function SplitPaneView({
}, [onFocusPane, pane.id]);
return (
<View
ref={paneRef}
collapsable={false}
style={styles.pane}
>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={styles.paneTabs}>
<WorkspaceDesktopTabsRow
paneId={pane.id}
@@ -796,13 +805,18 @@ function SplitPaneView({
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
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" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
@@ -810,7 +824,7 @@ function SplitPaneView({
{paneContent ? (
<WorkspacePaneContent content={paneContent} />
) : (
renderPaneEmptyState?.() ?? null
(renderPaneEmptyState?.() ?? null)
)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>

View File

@@ -23,11 +23,7 @@ export function buildSplitDropZoneId(paneId: string): string {
return `split-pane-drop:${paneId}`;
}
export function SplitDropZone({
paneId,
active,
preview,
}: SplitDropZoneProps) {
export function SplitDropZone({ paneId, active, preview }: SplitDropZoneProps) {
const { theme } = useUnistyles();
const { setNodeRef } = useDroppable({
id: buildSplitDropZoneId(paneId),
@@ -66,11 +62,7 @@ export function SplitDropZone({
}
return (
<View
ref={setNodeRef as any}
style={styles.overlay}
pointerEvents="none"
>
<View ref={setNodeRef as any} style={styles.overlay} pointerEvents="none">
{previewStyles ? (
<>
<View pointerEvents="none" style={previewStyles.overlay} />
@@ -89,10 +81,8 @@ export function resolveSplitDropPosition(input: {
}): SplitDropZonePosition {
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
const insideCenterX =
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY =
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
const insideCenterX = input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY = input.y >= centerInsetY && input.y <= input.height - centerInsetY;
if (insideCenterX && insideCenterY) {
return "center";

View File

@@ -9,11 +9,7 @@ import {
} from "react-native";
import type { StreamItem } from "@/types/stream";
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type {
StreamRenderInput,
StreamStrategy,
StreamViewportHandle,
} from "./stream-strategy";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
@@ -92,7 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
strategy,
isViewportSettling: isNativeViewportSettling,
}),
[isNativeViewportSettling, strategy]
[isNativeViewportSettling, strategy],
);
const scrollToBottom = useCallback(
@@ -109,7 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
onNearBottomChange(true);
},
[onNearBottomChange]
[onNearBottomChange],
);
const bottomAnchorController = useBottomAnchorController({
@@ -159,7 +155,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const subscriptions = keyboardEvents.map((eventName) =>
Keyboard.addListener(eventName, () => {
markNativeViewportSettling();
})
}),
);
return () => {
for (const subscription of subscriptions) {
@@ -228,7 +224,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
}
},
[bottomAnchorController, onNearBottomChange, strategy]
[bottomAnchorController, onNearBottomChange, strategy],
);
const handleListLayout = useCallback(
@@ -257,7 +253,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportHeight,
});
},
[bottomAnchorController, markNativeViewportSettling]
[bottomAnchorController, markNativeViewportSettling],
);
const handleContentSizeChange = useCallback(
@@ -275,7 +271,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight,
});
},
[bottomAnchorController]
[bottomAnchorController],
);
const renderItem = useCallback(
@@ -283,7 +279,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null;
},
[historyRows, renderers]
[historyRows, renderers],
);
const liveHeaderContent = useMemo(() => {
@@ -336,12 +332,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
export function createNativeStreamStrategy(): StreamStrategy {
const strategy = createStreamStrategy({
render: (renderInput) => (
<NativeStreamViewport
{...renderInput}
strategy={strategy}
/>
),
render: (renderInput) => <NativeStreamViewport {...renderInput} strategy={strategy} />,
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,7 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import type {
StreamHistoryBoundary,
StreamRenderSegments,
} from "./agent-stream-render-model";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model";
import type {
BottomAnchorLocalRequest,
BottomAnchorRouteRequest,
@@ -49,21 +46,9 @@ export type StreamViewportHandle = {
};
export type StreamSegmentRenderers = {
renderHistoryVirtualizedRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderHistoryMountedRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderLiveHeadRow: (
item: StreamItem,
index: number,
items: StreamItem[]
) => ReactNode;
renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveAuxiliary: () => ReactNode;
};
@@ -96,18 +81,16 @@ export interface StreamStrategy {
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation
relation: NeighborRelation,
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
gapSize: number
gapSize: number,
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
@@ -137,15 +120,12 @@ type StreamStrategyConfig = {
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
export function createStreamStrategy(
config: StreamStrategyConfig
): StreamStrategy {
export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy {
return {
render: config.render,
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
orderHead: (streamHead) => (config.orderHeadReverse ? [...streamHead].reverse() : streamHead),
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
@@ -197,15 +177,14 @@ export function createStreamStrategy(
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () =>
config.anchorBottomOnContentSizeChange,
shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
};
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
input: ResolveStreamRenderStrategyInput,
): StreamStrategy {
if (input.platform === "web") {
return createWebStreamStrategy({
@@ -226,7 +205,7 @@ export function resolveBottomAnchorTransportBehavior(input: {
return {
verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES,
),
verificationRetryMode: "recheck",
};
@@ -260,11 +239,7 @@ export function getStreamNeighborItem(params: {
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
return params.strategy.getNeighborItem(
params.items,
params.index,
params.relation
);
return params.strategy.getNeighborItem(params.items, params.index, params.relation);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
@@ -272,14 +247,11 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(
params.items,
params.startIndex
);
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamStrategy }
params: StreamNearBottomInput & { strategy: StreamStrategy },
): boolean {
return params.strategy.isNearBottom({
offsetY: params.offsetY,
@@ -292,7 +264,7 @@ export function isNearBottomForStreamRenderStrategy(
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamStrategy;
}
},
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,

View File

@@ -26,8 +26,7 @@ function ensureSharedStepLoopStarted(): void {
}
sharedLoopStarted = true;
const elapsedMs =
(Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
sharedStepProgress.value = withTiming(
DOT_COUNT,
@@ -47,19 +46,13 @@ function ensureSharedStepLoopStarted(): void {
easing: Easing.linear,
}),
-1,
false
false,
);
}
},
);
}
export function SyncedLoader({
size = 10,
color,
}: {
size?: number;
color: string;
}) {
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
useEffect(() => {
ensureSharedStepLoopStarted();
}, []);
@@ -94,9 +87,7 @@ export function SyncedLoader({
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS;
const sequenceIndex = DOT_SEQUENCE.indexOf(
dotIndex as (typeof DOT_SEQUENCE)[number]
);
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
return (
<SpinnerDot

View File

@@ -318,10 +318,7 @@ export default function TerminalEmulator({
initialOutputText,
theme: mountedThemeRef.current,
});
appliedChunkSequenceRef.current = Math.max(
0,
Math.floor(initialOutputChunkSequence)
);
appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence));
return () => {
runtime.unmount();
@@ -469,10 +466,7 @@ export default function TerminalEmulator({
}, [streamKey]);
useEffect(() => {
const maxScrollOffset = Math.max(
0,
viewportMetrics.contentSize - viewportMetrics.viewportSize
);
const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
setIsScrollVisible(false);
@@ -504,11 +498,7 @@ export default function TerminalEmulator({
setIsScrollActive(false);
scrollActiveTimeoutRef.current = null;
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
}, [
viewportMetrics.contentSize,
viewportMetrics.offset,
viewportMetrics.viewportSize,
]);
}, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]);
useEffect(() => {
return () => {
@@ -528,7 +518,7 @@ export default function TerminalEmulator({
contentSize: viewportMetrics.contentSize,
offset: viewportMetrics.offset,
}),
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize],
);
useEffect(() => {
@@ -569,11 +559,7 @@ export default function TerminalEmulator({
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [
isDraggingScrollbar,
scrollbarGeometry.maxHandleOffset,
scrollbarGeometry.maxScrollOffset,
]);
}, [isDraggingScrollbar, scrollbarGeometry.maxHandleOffset, scrollbarGeometry.maxScrollOffset]);
const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
@@ -590,16 +576,13 @@ export default function TerminalEmulator({
: SCROLLBAR_HANDLE_WIDTH_IDLE;
const thumbRegionOffset = Math.max(
0,
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING,
);
const thumbRegionHeight = Math.min(
viewportMetrics.viewportSize - thumbRegionOffset,
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2
);
const handleInsetTop = Math.max(
0,
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2,
);
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
@@ -677,7 +660,7 @@ export default function TerminalEmulator({
dragStartOffsetRef.current = clamp(
viewportMetrics.offset,
0,
scrollbarGeometry.maxScrollOffset
scrollbarGeometry.maxScrollOffset,
);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
@@ -703,8 +686,7 @@ export default function TerminalEmulator({
opacity: handleOpacity,
transitionProperty: "opacity, width, background-color",
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction:
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}}
/>
</div>

View File

@@ -1,21 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Plus, X } from "lucide-react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
@@ -28,9 +17,7 @@ import {
} from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
getWorkspaceTerminalSession,
} from "@/terminal/runtime/workspace-terminal-session";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
@@ -109,13 +96,7 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient
id={gradientId}
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset="10%" stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
@@ -138,8 +119,7 @@ export function TerminalPane({
const isScreenFocused = useIsFocused();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
@@ -163,21 +143,18 @@ export function TerminalPane({
scopeKey,
maxOutputChars: MAX_OUTPUT_CHARS,
}),
[scopeKey]
[scopeKey],
);
const outputSession = workspaceTerminalSession.outputSession;
const subscribeOutputSession = useCallback(
(listener: () => void) => outputSession.subscribe(listener),
[outputSession]
);
const getOutputSessionState = useCallback(
() => outputSession.getState(),
[outputSession]
[outputSession],
);
const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]);
const outputState = useSyncExternalStore(
subscribeOutputSession,
getOutputSessionState,
getOutputSessionState
getOutputSessionState,
);
const selectedOutputState = useMemo(() => {
if (outputState.selectedTerminalId === selectedTerminalId) {
@@ -204,9 +181,7 @@ export function TerminalPane({
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
null
);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
@@ -217,7 +192,7 @@ export function TerminalPane({
(next: string | null) => {
onSelectedTerminalIdChange?.(next);
},
[onSelectedTerminalIdChange]
[onSelectedTerminalIdChange],
);
useEffect(() => {
@@ -237,7 +212,7 @@ export function TerminalPane({
clearHoverOutTimeout();
setHoveredTerminalId(terminalId);
},
[clearHoverOutTimeout]
[clearHoverOutTimeout],
);
const handleTerminalTabHoverOut = useCallback(
@@ -245,12 +220,10 @@ export function TerminalPane({
clearHoverOutTimeout();
hoverOutTimeoutRef.current = setTimeout(() => {
setHoveredTerminalId((current) => (current === terminalId ? null : current));
setHoveredCloseTerminalId((current) =>
current === terminalId ? null : current
);
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
}, 50);
},
[clearHoverOutTimeout]
[clearHoverOutTimeout],
);
const handleTerminalCloseHoverIn = useCallback(
@@ -259,7 +232,7 @@ export function TerminalPane({
setHoveredTerminalId(terminalId);
setHoveredCloseTerminalId(terminalId);
},
[clearHoverOutTimeout]
[clearHoverOutTimeout],
);
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
@@ -308,7 +281,7 @@ export function TerminalPane({
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
}, delayMs),
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
@@ -324,7 +297,7 @@ export function TerminalPane({
}
runOnJS(pulseKeyboardRefits)();
},
[pulseKeyboardRefits]
[pulseKeyboardRefits],
);
useFocusEffect(
@@ -337,7 +310,7 @@ export function TerminalPane({
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
}, delayMs),
);
return () => {
@@ -345,7 +318,7 @@ export function TerminalPane({
clearTimeout(handle);
}
};
}, [requestTerminalReflow, selectedTerminalId])
}, [requestTerminalReflow, selectedTerminalId]),
);
const terminalsQuery = useQuery({
@@ -386,12 +359,7 @@ export function TerminalPane({
}, [client, isConnected]);
useEffect(() => {
if (
!manageTerminalDirectorySubscription ||
!client ||
!isConnected ||
!cwd.startsWith("/")
) {
if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) {
return;
}
@@ -494,21 +462,18 @@ export function TerminalPane({
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
}, [outputSession, terminals]);
const handleStreamControllerStatus = useCallback(
(status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
if (status.terminalId && typeof status.streamId === "number") {
setActiveStream({
terminalId: status.terminalId,
streamId: status.streamId,
});
return;
}
setActiveStream(null);
},
[]
);
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
if (status.terminalId && typeof status.streamId === "number") {
setActiveStream({
terminalId: status.terminalId,
streamId: status.streamId,
});
return;
}
setActiveStream(null);
}, []);
useEffect(() => {
streamControllerRef.current?.dispose();
@@ -566,16 +531,14 @@ export function TerminalPane({
}, [isScreenFocused, outputSession, selectedTerminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === selectedTerminalId
? activeStream.streamId
: null;
activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []);
const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
[terminals, selectedTerminalId]
[terminals, selectedTerminalId],
);
const handleCreateTerminal = useCallback(() => {
createTerminalMutation.mutate();
@@ -618,7 +581,7 @@ export function TerminalPane({
});
return true;
},
[client]
[client],
);
const flushPendingTerminalInput = useCallback(() => {
@@ -650,10 +613,7 @@ export function TerminalPane({
const handleCloseTerminal = useCallback(
async (terminalId: string) => {
if (
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminalId
) {
if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) {
return;
}
@@ -671,7 +631,7 @@ export function TerminalPane({
killTerminalMutation.mutate(terminalId);
},
[killTerminalMutation]
[killTerminalMutation],
);
const clearPendingModifiers = useCallback(() => {
@@ -679,15 +639,13 @@ export function TerminalPane({
}, []);
const sendTerminalKey = useCallback(
(
input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
}
): boolean => {
(input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
}): boolean => {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
@@ -718,12 +676,7 @@ export function TerminalPane({
}
return true;
},
[
client,
dispatchTerminalInputEntry,
enqueuePendingTerminalInput,
getCurrentActiveStreamId,
]
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
);
const handleTerminalData = useCallback(
@@ -782,7 +735,7 @@ export function TerminalPane({
modifiers.shift,
sendTerminalKey,
enqueuePendingTerminalInput,
]
],
);
const handleTerminalResize = useCallback(
@@ -794,11 +747,7 @@ export function TerminalPane({
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const previous = lastReportedSizeRef.current;
if (
previous &&
previous.rows === normalizedRows &&
previous.cols === normalizedCols
) {
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
@@ -808,36 +757,33 @@ export function TerminalPane({
cols: normalizedCols,
});
},
[client, selectedTerminalId]
[client, selectedTerminalId],
);
const handleTerminalKey = useCallback(
async (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => {
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
sendTerminalKey(input);
},
[sendTerminalKey]
[sendTerminalKey],
);
const handlePendingModifiersConsumed = useCallback(() => {
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback((sequence: number) => {
outputSession.consume({ sequence });
}, [outputSession]);
const handleOutputChunkConsumed = useCallback(
(sequence: number) => {
outputSession.consume({ sequence });
},
[outputSession],
);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
requestTerminalFocus();
},
[requestTerminalFocus]
[requestTerminalFocus],
);
const sendVirtualKey = useCallback(
@@ -859,7 +805,7 @@ export function TerminalPane({
modifiers.shift,
requestTerminalFocus,
sendTerminalKey,
]
],
);
if (!client || !isConnected) {
@@ -870,17 +816,12 @@ export function TerminalPane({
);
}
const queryError =
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const isCreating = createTerminalMutation.isPending;
const createError =
createTerminalMutation.error instanceof Error
? createTerminalMutation.error.message
: null;
createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null;
const closeError =
killTerminalMutation.error instanceof Error
? killTerminalMutation.error.message
: null;
killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null;
const combinedError = streamError ?? closeError ?? createError ?? queryError;
return (
@@ -898,13 +839,11 @@ export function TerminalPane({
const isTabHovered = hoveredTerminalId === terminal.id;
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
const isClosingTerminal =
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminal.id;
const shouldShowCloseButton =
isTabHovered || isCloseHovered || isClosingTerminal;
killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id;
const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal;
const gradientId = `terminal-close-gradient-${terminal.id.replace(
/[^a-zA-Z0-9_-]/g,
"-"
"-",
)}`;
return (
<Pressable
@@ -946,9 +885,7 @@ export function TerminalPane({
>
{({ hovered = false, pressed = false }) => {
const iconColor =
hovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted;
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<TerminalCloseGradient
@@ -1044,11 +981,7 @@ export function TerminalPane({
)}
{isAttaching && isScreenFocused ? (
<View
style={styles.attachOverlay}
pointerEvents="none"
testID="terminal-attach-loading"
>
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
@@ -1077,7 +1010,12 @@ export function TerminalPane({
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={[styles.keyButtonText, modifiers[modifier] && styles.keyButtonTextActive]}>
<Text
style={[
styles.keyButtonText,
modifiers[modifier] && styles.keyButtonTextActive,
]}
>
{MODIFIER_LABELS[modifier]}
</Text>
</Pressable>

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