Merge origin/main into dev (second round)
Resolve 21 conflicts from latest main (provider profiles, Windows fixes, 0.1.55-rc.1). Fix all type errors and syntax issues from merge artifacts.
This commit is contained in:
@@ -76,6 +76,31 @@ jobs:
|
|||||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
|
server-tests-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- name: Build relay dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Run Windows-critical server tests
|
||||||
|
working-directory: packages/server
|
||||||
|
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
|
||||||
|
|
||||||
app-tests:
|
app-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -55,6 +55,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||||
|
|
||||||
|
## Platform gating
|
||||||
|
|
||||||
|
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
|
||||||
|
|
||||||
|
### The four gates
|
||||||
|
|
||||||
|
| Gate | Type | When to use |
|
||||||
|
|---|---|---|
|
||||||
|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
|
||||||
|
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
|
||||||
|
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
|
||||||
|
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
|
||||||
|
|
||||||
|
### Decision matrix
|
||||||
|
|
||||||
|
| I need to... | Use |
|
||||||
|
|---|---|
|
||||||
|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
|
||||||
|
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
|
||||||
|
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
|
||||||
|
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
|
||||||
|
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
|
||||||
|
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- **Default is cross-platform.** Don't gate unless you have a specific reason.
|
||||||
|
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
|
||||||
|
```
|
||||||
|
hooks/
|
||||||
|
use-audio-recorder.web.ts ← uses Web Audio API
|
||||||
|
use-audio-recorder.native.ts ← uses expo-audio
|
||||||
|
```
|
||||||
|
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
||||||
|
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
||||||
|
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
||||||
|
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
||||||
|
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
|
||||||
|
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Ad-hoc daemon testing
|
||||||
|
|
||||||
|
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createPaseoDaemon } from "./bootstrap.js";
|
||||||
|
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "warn" });
|
||||||
|
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
|
||||||
|
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||||
|
await mkdir(paseoHome, { recursive: true });
|
||||||
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
|
|
||||||
|
const daemon = await createPaseoDaemon(
|
||||||
|
{
|
||||||
|
listen: "127.0.0.1:0", // OS picks a free port
|
||||||
|
paseoHome,
|
||||||
|
corsAllowedOrigins: [],
|
||||||
|
allowedHosts: true,
|
||||||
|
mcpEnabled: false,
|
||||||
|
staticDir,
|
||||||
|
mcpDebug: false,
|
||||||
|
agentClients: {},
|
||||||
|
agentStoragePath: path.join(paseoHome, "agents"),
|
||||||
|
relayEnabled: false,
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
|
// Add custom config here, e.g.:
|
||||||
|
// providerOverrides: { ... },
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
await daemon.start();
|
||||||
|
const target = daemon.getListenTarget();
|
||||||
|
const port = target!.type === "tcp" ? target!.port : null;
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${port}/ws`,
|
||||||
|
appVersion: "0.1.54", // see gotcha #1
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||||
|
|
||||||
|
// ... do your testing ...
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
await daemon.stop();
|
||||||
|
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||||
|
await rm(staticDir, { recursive: true, force: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
```bash
|
||||||
|
npx tsx packages/server/src/server/your-script.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the test helper
|
||||||
|
|
||||||
|
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
|
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||||
|
|
||||||
|
const daemon = await createTestPaseoDaemon();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||||
|
appVersion: "0.1.54",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||||
|
|
||||||
|
// ... test ...
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||||
|
```
|
||||||
|
|
||||||
|
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||||
|
|
||||||
|
## Common client methods
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Provider discovery
|
||||||
|
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||||
|
const models = await client.listProviderModels("claude");
|
||||||
|
const modes = await client.listProviderModes("claude");
|
||||||
|
|
||||||
|
// Agent lifecycle
|
||||||
|
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
|
||||||
|
await client.sendMessage(agent.id, "Hello");
|
||||||
|
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
### 1. appVersion gates provider visibility
|
||||||
|
|
||||||
|
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
||||||
|
|
||||||
|
Always pass `appVersion`:
|
||||||
|
```typescript
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${port}/ws`,
|
||||||
|
appVersion: "0.1.54",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Provider snapshots are async
|
||||||
|
|
||||||
|
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const entry = snapshot.entries.find((e) => e.provider === "gemini");
|
||||||
|
if (entry && entry.status !== "loading") break;
|
||||||
|
await new Promise((r) => setTimeout(r, 2_000));
|
||||||
|
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. fetchAgents is required before most operations
|
||||||
|
|
||||||
|
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
|
||||||
|
|
||||||
|
### 4. listen: "127.0.0.1:0" for port allocation
|
||||||
|
|
||||||
|
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
|
||||||
|
|
||||||
|
### 5. Script must live inside packages/server
|
||||||
|
|
||||||
|
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
|
||||||
|
|
||||||
|
### 6. Cleanup on failure
|
||||||
|
|
||||||
|
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// ... test logic ...
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await daemon.stop().catch(() => undefined);
|
||||||
|
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. ACP providers spawn real processes
|
||||||
|
|
||||||
|
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU=";
|
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+47
-19
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36169,16 +36169,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.54",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.54",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.54",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -36319,11 +36319,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.54",
|
"@getpaseo/relay": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.54",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36364,11 +36364,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.54",
|
"@getpaseo/cli": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.54",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -36403,7 +36403,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -36604,7 +36604,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -36630,7 +36630,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -36646,14 +36646,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.54",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/relay": "0.1.54",
|
"@getpaseo/relay": "0.1.55-rc.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -36672,6 +36672,7 @@
|
|||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
"p-limit": "^7.3.0",
|
||||||
"pi-acp": "^0.0.24",
|
"pi-acp": "^0.0.24",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -36949,6 +36950,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/p-limit": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"yocto-queue": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/raw-body": {
|
"packages/server/node_modules/raw-body": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
@@ -37046,6 +37062,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/yocto-queue": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/zod": {
|
"packages/server/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
@@ -37057,7 +37085,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ export async function clickTerminal(page: Page): Promise<void> {
|
|||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─── Tab title assertions ──────────────────────────────────────────────────
|
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Wait for any tab in the bar to display the given title text. */
|
/** Wait for any tab in the bar to display the given title text. */
|
||||||
@@ -188,6 +187,8 @@ export async function sampleTabsDuringTransition(
|
|||||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Create a temp git repo and return its path with a cleanup function. */
|
/** Create a temp git repo and return its path with a cleanup function. */
|
||||||
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
|
export async function createWorkspace(
|
||||||
|
prefix = "launcher-e2e-",
|
||||||
|
): ReturnType<typeof createTempGitRepo> {
|
||||||
return createTempGitRepo(prefix);
|
return createTempGitRepo(prefix);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
|
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
|
||||||
|
type NewWorkspaceDaemonClient = Pick<
|
||||||
|
ServerDaemonClient,
|
||||||
|
| "archivePaseoWorktree"
|
||||||
|
| "archiveWorkspace"
|
||||||
|
| "close"
|
||||||
|
| "connect"
|
||||||
|
| "createPaseoWorktree"
|
||||||
|
| "openProject"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type NewWorkspaceDaemonClientConfig = {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||||
|
|
||||||
|
export type OpenedProject = {
|
||||||
|
workspaceId: string;
|
||||||
|
projectKey: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
workspaceName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonPort(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
if (daemonPort === "6767") {
|
||||||
|
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||||
|
}
|
||||||
|
return daemonPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (
|
||||||
|
config: NewWorkspaceDaemonClientConfig,
|
||||||
|
) => NewWorkspaceDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWorkspace(payload: OpenProjectPayload) {
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
if (!payload.workspace) {
|
||||||
|
throw new Error("openProject returned no workspace.");
|
||||||
|
}
|
||||||
|
return payload.workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
||||||
|
const pathname = new URL(page.url()).pathname;
|
||||||
|
const match = pathname.match(
|
||||||
|
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
|
||||||
|
);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspaceIdFromSidebarRowTestId(
|
||||||
|
testId: string,
|
||||||
|
input: { serverId: string; previousWorkspaceId: string },
|
||||||
|
): string | null {
|
||||||
|
const prefix = `sidebar-workspace-row-${input.serverId}:`;
|
||||||
|
if (!testId.startsWith(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const workspaceId = testId.slice(prefix.length).trim();
|
||||||
|
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `app-e2e-new-workspace-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectViaDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<OpenedProject> {
|
||||||
|
const workspace = requireWorkspace(await client.openProject(repoPath));
|
||||||
|
return {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveWorkspaceFromDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error.message);
|
||||||
|
}
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveLocalWorkspaceFromDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = await client.archiveWorkspace(workspaceId);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
if (!payload.archivedAt) {
|
||||||
|
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorktreeViaDaemon(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
input: { cwd: string; slug: string },
|
||||||
|
): Promise<OpenedProject> {
|
||||||
|
const payload = await client.createPaseoWorktree({
|
||||||
|
cwd: input.cwd,
|
||||||
|
worktreeSlug: input.slug,
|
||||||
|
});
|
||||||
|
const workspace = requireWorkspace(payload);
|
||||||
|
return {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickNewWorkspaceButton(
|
||||||
|
page: Page,
|
||||||
|
input: { projectKey: string; projectDisplayName: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await projectRow.hover();
|
||||||
|
|
||||||
|
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertNewWorkspaceSidebarAndHeader(
|
||||||
|
page: Page,
|
||||||
|
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||||
|
): Promise<{ workspaceId: string }> {
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
const sidebarWorkspaceRows = page.locator(
|
||||||
|
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute("data-testid") ?? ""),
|
||||||
|
);
|
||||||
|
workspaceId =
|
||||||
|
sidebarRowTestIds
|
||||||
|
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
|
||||||
|
.find((id) => id !== null) ?? null;
|
||||||
|
if (workspaceId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||||
|
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"Expected a newly created workspace to load.",
|
||||||
|
`Current URL: ${page.url()}`,
|
||||||
|
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdWorkspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: workspaceLabelFromPath(workspaceId),
|
||||||
|
subtitle: input.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { workspaceId };
|
||||||
|
}
|
||||||
@@ -8,9 +8,7 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|||||||
export type TerminalPerfDaemonClient = {
|
export type TerminalPerfDaemonClient = {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
openProject(
|
openProject(cwd: string): Promise<{
|
||||||
cwd: string,
|
|
||||||
): Promise<{
|
|
||||||
workspace: { id: number; name: string; projectRootPath: string } | null;
|
workspace: { id: number; name: string; projectRootPath: string } | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import {
|
import { clickNewChat, clickTerminal } from "./launcher";
|
||||||
clickNewChat,
|
|
||||||
clickTerminal,
|
|
||||||
} from "./launcher";
|
|
||||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||||
|
|
||||||
function terminalSurface(page: Page) {
|
function terminalSurface(page: Page) {
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import type { SessionOutboundMessage } from "@server/shared/messages";
|
|||||||
type WorkspaceSetupDaemonClient = {
|
type WorkspaceSetupDaemonClient = {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
openProject(
|
openProject(cwd: string): Promise<{
|
||||||
cwd: string,
|
|
||||||
): Promise<{
|
|
||||||
workspace: {
|
workspace: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,9 +19,7 @@ type WorkspaceSetupDaemonClient = {
|
|||||||
} | null;
|
} | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
createPaseoWorktree(
|
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
|
||||||
input: { cwd: string; worktreeSlug?: string },
|
|
||||||
): Promise<{
|
|
||||||
workspace: {
|
workspace: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,15 +41,11 @@ type WorkspaceSetupDaemonClient = {
|
|||||||
agent: { id: string; cwd: string; workspaceId?: string | null };
|
agent: { id: string; cwd: string; workspaceId?: string | null };
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
fetchAgent(
|
fetchAgent(agentId: string): Promise<{
|
||||||
agentId: string,
|
|
||||||
): Promise<{
|
|
||||||
agent: { id: string; cwd: string } | null;
|
agent: { id: string; cwd: string } | null;
|
||||||
project: unknown | null;
|
project: unknown | null;
|
||||||
} | null>;
|
} | null>;
|
||||||
listTerminals(
|
listTerminals(cwd: string): Promise<{
|
||||||
cwd: string,
|
|
||||||
): Promise<{
|
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
terminals: Array<{ id: string; cwd: string; name: string }>;
|
terminals: Array<{ id: string; cwd: string; name: string }>;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
@@ -77,7 +69,11 @@ function getDaemonWsUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient
|
new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => WorkspaceSetupDaemonClient
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
@@ -149,7 +145,9 @@ export async function createWorkspaceFromSidebar(page: Page, repoPath: string):
|
|||||||
await expect(button).toBeEnabled({ timeout: 30_000 });
|
await expect(button).toBeEnabled({ timeout: 30_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
|
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
|
||||||
@@ -193,7 +191,10 @@ export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Pr
|
|||||||
.click();
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise<void> {
|
export async function waitForWorkspaceSetupDialogToClose(
|
||||||
|
page: Page,
|
||||||
|
timeoutMs = 45_000,
|
||||||
|
): Promise<void> {
|
||||||
const dialog = workspaceSetupDialog(page);
|
const dialog = workspaceSetupDialog(page);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -243,7 +244,9 @@ export async function expectSetupLogContains(page: Page, text: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||||
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({
|
await expect(
|
||||||
|
page.getByText("No setup commands ran for this workspace.", { exact: true }),
|
||||||
|
).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -276,7 +279,8 @@ export async function findWorktreeWorkspaceForProject(
|
|||||||
const workspace =
|
const workspace =
|
||||||
payload.entries.find(
|
payload.entries.find(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.projectRootPath === normalizedRepoPath && entry.workspaceDirectory !== normalizedRepoPath,
|
entry.projectRootPath === normalizedRepoPath &&
|
||||||
|
entry.workspaceDirectory !== normalizedRepoPath,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ test.describe("Tab creation", () => {
|
|||||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
const countAfterFirst = await countTabsOfKind(page, "draft");
|
||||||
|
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
await expect
|
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countAfterFirst + 1);
|
||||||
.poll(() => countTabsOfKind(page, "draft"))
|
|
||||||
.toBe(countAfterFirst + 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||||
@@ -201,12 +199,7 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
// Sample tabs at high frequency across the transition
|
// Sample tabs at high frequency across the transition
|
||||||
const snapshots = await sampleTabsDuringTransition(
|
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
|
||||||
page,
|
|
||||||
() => clickNewChat(page),
|
|
||||||
2_000,
|
|
||||||
30,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
for (const snapshot of snapshots) {
|
for (const snapshot of snapshots) {
|
||||||
@@ -246,12 +239,7 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
|
||||||
const elapsed = await measureTileTransition(
|
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
|
||||||
page,
|
|
||||||
() => clickNewChat(page),
|
|
||||||
composer,
|
|
||||||
10_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draft creation is fully in-memory — should be fast
|
// Draft creation is fully in-memory — should be fast
|
||||||
// We use a generous budget here because CI can be slow, but the key assertion
|
// We use a generous budget here because CI can be slow, but the key assertion
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import {
|
||||||
|
archiveWorkspaceFromDaemon,
|
||||||
|
archiveLocalWorkspaceFromDaemon,
|
||||||
|
assertNewWorkspaceSidebarAndHeader,
|
||||||
|
clickNewWorkspaceButton,
|
||||||
|
connectNewWorkspaceDaemonClient,
|
||||||
|
createWorktreeViaDaemon,
|
||||||
|
openProjectViaDaemon,
|
||||||
|
} from "./helpers/new-workspace";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
expectWorkspaceHeader,
|
||||||
|
switchWorkspaceViaSidebar,
|
||||||
|
workspaceLabelFromPath,
|
||||||
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
test.describe("New workspace flow", () => {
|
||||||
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
|
const localWorkspaceIds = new Set<string>();
|
||||||
|
const createdWorktreeIds = new Set<string>();
|
||||||
|
|
||||||
|
test.describe.configure({ timeout: 120_000 });
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
client = await connectNewWorkspaceDaemonClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (client) {
|
||||||
|
for (const workspaceId of createdWorktreeIds) {
|
||||||
|
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
for (const workspaceId of localWorkspaceIds) {
|
||||||
|
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createdWorktreeIds.clear();
|
||||||
|
localWorkspaceIds.clear();
|
||||||
|
await client?.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
||||||
|
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path);
|
||||||
|
const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path);
|
||||||
|
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
||||||
|
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
||||||
|
|
||||||
|
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: firstWorkspace.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: secondWorkspace.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(secondRepo.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: firstWorkspace.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await secondRepo.cleanup();
|
||||||
|
await firstRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRepo = await createTempGitRepo("new-workspace-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: openedProject.workspaceName,
|
||||||
|
subtitle: workspaceLabelFromPath(tempRepo.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
await clickNewWorkspaceButton(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||||
|
|
||||||
|
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdWorkspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
|
||||||
|
subtitle: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
|
||||||
|
has: page.getByText("New Agent", { exact: true }),
|
||||||
|
});
|
||||||
|
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,7 +57,10 @@ async function openProjectViaDaemon(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) {
|
async function openWorkspaceFromSidebar(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
workspaceId: string,
|
||||||
|
) {
|
||||||
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
@@ -65,10 +68,7 @@ async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, w
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForSidebarProject(
|
async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
projectName: string,
|
|
||||||
) {
|
|
||||||
const row = page
|
const row = page
|
||||||
.getByRole("button", {
|
.getByRole("button", {
|
||||||
name: new RegExp(escapeRegex(projectName), "i"),
|
name: new RegExp(escapeRegex(projectName), "i"),
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ import { execSync } from "node:child_process";
|
|||||||
import { realpathSync } from "node:fs";
|
import { realpathSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import {
|
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||||
clickTerminal,
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
waitForTabBar,
|
|
||||||
} from "./helpers/launcher";
|
|
||||||
import {
|
|
||||||
setupDeterministicPrompt,
|
|
||||||
waitForTerminalContent,
|
|
||||||
} from "./helpers/terminal-perf";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
@@ -88,10 +82,13 @@ test.describe("Workspace cwd correctness", () => {
|
|||||||
try {
|
try {
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
execSync(
|
||||||
cwd: repo.path,
|
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
stdio: "ignore",
|
{
|
||||||
});
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
worktreeCreated = true;
|
worktreeCreated = true;
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
@@ -132,5 +129,4 @@ test.describe("Workspace cwd correctness", () => {
|
|||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,17 +45,21 @@ async function expectScriptInCard(page: Page, scriptName: string): Promise<void>
|
|||||||
/** Asserts the script status dot indicates "running". */
|
/** Asserts the script status dot indicates "running". */
|
||||||
async function expectScriptRunning(page: Page, scriptName: string): Promise<void> {
|
async function expectScriptRunning(page: Page, scriptName: string): Promise<void> {
|
||||||
const card = page.getByTestId("workspace-hover-card");
|
const card = page.getByTestId("workspace-hover-card");
|
||||||
await expect(
|
await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute(
|
||||||
card.getByTestId(`hover-card-script-status-${scriptName}`),
|
"aria-label",
|
||||||
).toHaveAttribute("aria-label", "Running", { timeout: 10_000 });
|
"Running",
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asserts the script lifecycle is stopped. */
|
/** Asserts the script lifecycle is stopped. */
|
||||||
async function expectScriptStopped(page: Page, scriptName: string): Promise<void> {
|
async function expectScriptStopped(page: Page, scriptName: string): Promise<void> {
|
||||||
const card = page.getByTestId("workspace-hover-card");
|
const card = page.getByTestId("workspace-hover-card");
|
||||||
await expect(
|
await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute(
|
||||||
card.getByTestId(`hover-card-script-status-${scriptName}`),
|
"aria-label",
|
||||||
).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 });
|
"Stopped",
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asserts the script health label shown in the hover card. */
|
/** Asserts the script health label shown in the hover card. */
|
||||||
@@ -117,7 +121,8 @@ test.describe("Workspace hover card", () => {
|
|||||||
// Wait for setup completion via daemon (setup snapshots are per-session)
|
// Wait for setup completion via daemon (setup snapshots are per-session)
|
||||||
const completed = waitForWorkspaceSetupProgress(
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
client,
|
client,
|
||||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
);
|
);
|
||||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
cwd: repo.path,
|
cwd: repo.path,
|
||||||
@@ -182,7 +187,9 @@ test.describe("Workspace hover card", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
const wsRow = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspace.id}`);
|
const wsRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${getServerId()}:${workspace.workspace.id}`,
|
||||||
|
);
|
||||||
await expect(wsRow).toBeVisible({ timeout: 30_000 });
|
await expect(wsRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expectHoverCard(page, workspace.workspace.name);
|
await expectHoverCard(page, workspace.workspace.name);
|
||||||
|
|||||||
@@ -107,10 +107,13 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
try {
|
try {
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
execSync(
|
||||||
cwd: repo.path,
|
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
stdio: "ignore",
|
{
|
||||||
});
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
worktreeCreated = true;
|
worktreeCreated = true;
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
@@ -154,10 +157,13 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
try {
|
try {
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
execSync(
|
||||||
cwd: repo.path,
|
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
stdio: "ignore",
|
{
|
||||||
});
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
worktreeCreated = true;
|
worktreeCreated = true;
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import {
|
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||||
clickTerminal,
|
|
||||||
waitForTabBar,
|
|
||||||
} from "./helpers/launcher";
|
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
createWorkspaceThroughDaemon,
|
createWorkspaceThroughDaemon,
|
||||||
@@ -82,15 +79,11 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
|
|
||||||
// Verify terminal is listed under the worktree directory, not the original repo
|
// Verify terminal is listed under the worktree directory, not the original repo
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, {
|
||||||
async () =>
|
timeout: 30_000,
|
||||||
(await client.listTerminals(workspaceDir)).terminals.length > 0,
|
})
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
expect(
|
expect((await client.listTerminals(repo.path)).terminals.length).toBe(0);
|
||||||
(await client.listTerminals(repo.path)).terminals.length,
|
|
||||||
).toBe(0);
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
const repo = await createTempGitRepo("setup-open-", {
|
const repo = await createTempGitRepo("setup-open-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
worktree: {
|
worktree: {
|
||||||
setup: ["sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'"],
|
setup: [
|
||||||
|
"sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -77,7 +79,8 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
// so the browser session won't receive progress events).
|
// so the browser session won't receive progress events).
|
||||||
const completed = waitForWorkspaceSetupProgress(
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
client,
|
client,
|
||||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
);
|
);
|
||||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
cwd: repo.path,
|
cwd: repo.path,
|
||||||
@@ -138,7 +141,8 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
);
|
);
|
||||||
const completed = waitForWorkspaceSetupProgress(
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
client,
|
client,
|
||||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
);
|
);
|
||||||
|
|
||||||
await createWorkspaceThroughDaemon(client, {
|
await createWorkspaceThroughDaemon(client, {
|
||||||
@@ -243,7 +247,8 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
// Wait for setup completion via daemon (setup snapshots are per-session)
|
// Wait for setup completion via daemon (setup snapshots are per-session)
|
||||||
const completed = waitForWorkspaceSetupProgress(
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
client,
|
client,
|
||||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
);
|
);
|
||||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
cwd: repo.path,
|
cwd: repo.path,
|
||||||
@@ -296,7 +301,8 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
const completed = waitForWorkspaceSetupProgress(
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
client,
|
client,
|
||||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await client.createPaseoWorktree({
|
const result = await client.createPaseoWorktree({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.54",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.54",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.54",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import {
|
|||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
@@ -92,6 +93,22 @@ export type HostRuntimeBootstrapState = {
|
|||||||
retry: () => void;
|
retry: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const firstValue = value[0];
|
||||||
|
if (typeof firstValue !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = firstValue.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
phase: "starting-daemon",
|
phase: "starting-daemon",
|
||||||
error: null,
|
error: null,
|
||||||
@@ -103,7 +120,7 @@ function PushNotificationRouter() {
|
|||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -392,10 +409,10 @@ function AppContainer({
|
|||||||
const screenW = UnistylesRuntime.screen.width;
|
const screenW = UnistylesRuntime.screen.width;
|
||||||
const screenH = UnistylesRuntime.screen.height;
|
const screenH = UnistylesRuntime.screen.height;
|
||||||
const isElectron = getIsElectronRuntime();
|
const isElectron = getIsElectronRuntime();
|
||||||
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
|
const windowW = isWeb ? window.innerWidth : undefined;
|
||||||
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
|
const windowH = isWeb ? window.innerHeight : undefined;
|
||||||
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
|
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
||||||
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
|
const ua = isWeb ? navigator.userAgent : undefined;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"[layout-debug]",
|
"[layout-debug]",
|
||||||
@@ -580,7 +597,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
}, [settingsLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsLoading || Platform.OS !== "web") {
|
if (settingsLoading || isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,7 +806,14 @@ function RootStack() {
|
|||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
<Stack.Screen
|
||||||
|
name="h/[serverId]/workspace/[workspaceId]"
|
||||||
|
getId={({ params }) => {
|
||||||
|
const serverId = getRouteParamValue(params?.serverId);
|
||||||
|
const workspaceId = getRouteParamValue(params?.workspaceId);
|
||||||
|
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, usePathname, useRootNavigationState } from "expo-router";
|
import {
|
||||||
import { Platform } from "react-native";
|
useGlobalSearchParams,
|
||||||
|
useLocalSearchParams,
|
||||||
|
usePathname,
|
||||||
|
useRootNavigationState,
|
||||||
|
} from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
@@ -11,6 +15,7 @@ import {
|
|||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined): string {
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -91,7 +96,7 @@ function HostWorkspaceLayoutContent() {
|
|||||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||||
// skips search params). Strip ?open from the browser URL directly so the
|
// skips search params). Strip ?open from the browser URL directly so the
|
||||||
// address bar reflects the clean workspace route.
|
// address bar reflects the clean workspace route.
|
||||||
if (Platform.OS === "web" && typeof window !== "undefined") {
|
if (isWeb && typeof window !== "undefined") {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
if (url.searchParams.has("open")) {
|
if (url.searchParams.has("open")) {
|
||||||
url.searchParams.delete("open");
|
url.searchParams.delete("open");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Alert, Platform, Pressable, Text, View } from "react-native";
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
|
|||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
|
|||||||
}, [router, source, sourceServerId, targetServerId]);
|
}, [router, source, sourceServerId, targetServerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") return;
|
if (isWeb) return;
|
||||||
if (permission && permission.granted) return;
|
if (permission && permission.granted) return;
|
||||||
void requestPermission().catch(() => undefined);
|
void requestPermission().catch(() => undefined);
|
||||||
}, [permission, requestPermission]);
|
}, [permission, requestPermission]);
|
||||||
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
|
|||||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import type { AttachmentStore } from "@/attachments/types";
|
import type { AttachmentStore } from "@/attachments/types";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||||
|
|
||||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
if (isElectronRuntime()) {
|
if (isElectronRuntime()) {
|
||||||
const { createDesktopAttachmentStore } = await import(
|
const { createDesktopAttachmentStore } = await import(
|
||||||
"../desktop/attachments/desktop-attachment-store"
|
"../desktop/attachments/desktop-attachment-store"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
@@ -143,7 +144,7 @@ export function AdaptiveModalSheet({
|
|||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || !visible || Platform.OS !== "web" || typeof window === "undefined") return;
|
if (isMobile || !visible || !isWeb || typeof window === "undefined") return;
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -253,16 +254,16 @@ export function AdaptiveModalSheet({
|
|||||||
/>
|
/>
|
||||||
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
|
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
|
||||||
{onFilesDropped ? (
|
{onFilesDropped ? (
|
||||||
<FileDropZone onFilesDropped={onFilesDropped}>
|
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||||
{cardInner}
|
) : (
|
||||||
</FileDropZone>
|
cardInner
|
||||||
) : cardInner}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
// On web, use portal to overlay root for consistent stacking with toasts
|
// On web, use portal to overlay root for consistent stacking with toasts
|
||||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
return createPortal(desktopContent, getOverlayRoot());
|
return createPortal(desktopContent, getOverlayRoot());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Pressable, Text, View, Platform } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
option: {
|
option: {
|
||||||
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{Platform.OS !== "web" ? (
|
{isNative ? (
|
||||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||||
<QrCode size={18} color={theme.colors.foreground} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
|
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
|
||||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
|
|||||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
import { baseColors } from "@/styles/theme";
|
import { baseColors } from "@/styles/theme";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -168,7 +169,7 @@ export function SelectField({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -556,7 +557,11 @@ export function AgentConfigRow({
|
|||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||||
|
|
||||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
const selectedModeVisuals = getModeVisuals(
|
||||||
|
selectedProvider,
|
||||||
|
effectiveSelectedMode,
|
||||||
|
providerDefinitions,
|
||||||
|
);
|
||||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||||
|
|
||||||
|
|||||||
@@ -262,23 +262,20 @@ export function AgentList({
|
|||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAgentLongPress = useCallback(
|
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||||
(agent: AggregatedAgent) => {
|
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||||
const isRunning = agent.status === "running" || agent.status === "initializing";
|
if (isRunning) {
|
||||||
if (isRunning) {
|
setActionAgent(agent);
|
||||||
setActionAgent(agent);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setActionAgent(agent);
|
setActionAgent(agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void client.archiveAgent(agent.id);
|
void client.archiveAgent(agent.id);
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCloseActionSheet = useCallback(() => {
|
const handleCloseActionSheet = useCallback(() => {
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
@@ -288,7 +285,8 @@ export function AgentList({
|
|||||||
if (!actionAgent || !actionClient) {
|
if (!actionAgent || !actionClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void actionClient.archiveAgent(actionAgent.id);
|
// Timeout errors are swallowed — the daemon will still process the archive
|
||||||
|
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient]);
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
import { View, Text, Pressable, Keyboard } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
|
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||||
import {
|
import {
|
||||||
buildFavoriteModelKey,
|
buildFavoriteModelKey,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
@@ -40,7 +41,6 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
|
||||||
getModeVisuals,
|
getModeVisuals,
|
||||||
type AgentModeColorTier,
|
type AgentModeColorTier,
|
||||||
type AgentModeIcon,
|
type AgentModeIcon,
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,10 +60,6 @@ type StatusOption = {
|
|||||||
|
|
||||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||||
|
|
||||||
const PROVIDER_DEFINITION_MAP = new Map(
|
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
|
|
||||||
type ControlledAgentStatusBarProps = {
|
type ControlledAgentStatusBarProps = {
|
||||||
provider: string;
|
provider: string;
|
||||||
providerOptions?: StatusOption[];
|
providerOptions?: StatusOption[];
|
||||||
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean;
|
isModelLoading?: boolean;
|
||||||
providerDefinitions?: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||||
canSelectModelProvider?: (providerId: string) => boolean;
|
canSelectModelProvider?: (providerId: string) => boolean;
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
@@ -222,7 +219,6 @@ function ControlledStatusBar({
|
|||||||
onModelSelectorOpen,
|
onModelSelectorOpen,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
||||||
|
|
||||||
@@ -252,7 +248,9 @@ function ControlledStatusBar({
|
|||||||
thinkingOptions?.[0]?.label ?? "Unknown",
|
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||||
);
|
);
|
||||||
|
|
||||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
const modeVisuals = selectedModeId
|
||||||
|
? getModeVisuals(provider, selectedModeId, providerDefinitions)
|
||||||
|
: undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const ProviderIcon = getProviderIcon(provider);
|
||||||
@@ -300,9 +298,7 @@ function ControlledStatusBar({
|
|||||||
);
|
);
|
||||||
return map;
|
return map;
|
||||||
}, [modelOptions, provider]);
|
}, [modelOptions, provider]);
|
||||||
const effectiveProviderDefinitions =
|
const effectiveProviderDefinitions = providerDefinitions;
|
||||||
providerDefinitions ??
|
|
||||||
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
|
||||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||||
@@ -322,7 +318,7 @@ function ControlledStatusBar({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const visuals = getModeVisuals(provider, option.id);
|
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
|
||||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||||
return (
|
return (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
@@ -334,7 +330,7 @@ function ControlledStatusBar({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[provider, theme.colors.foreground],
|
[provider, providerDefinitions, theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
@@ -356,7 +352,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{isWeb ? (
|
{platformIsWeb ? (
|
||||||
<>
|
<>
|
||||||
{providerOptions && providerOptions.length > 0 ? (
|
{providerOptions && providerOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -745,7 +741,7 @@ function ControlledStatusBar({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" align="start">
|
<DropdownMenuContent side="top" align="start">
|
||||||
{modeOptions.map((mode) => {
|
{modeOptions.map((mode) => {
|
||||||
const visuals = getModeVisuals(provider, mode.id);
|
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
|
||||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -896,9 +892,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
const models = snapshotModels;
|
const models = snapshotModels;
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
const agentProviderDefinitions = useMemo(() => {
|
||||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
const definition = agent?.provider
|
||||||
|
? resolveProviderDefinition(agent.provider, snapshotEntries)
|
||||||
|
: undefined;
|
||||||
return definition ? [definition] : [];
|
return definition ? [definition] : [];
|
||||||
}, [agent?.provider]);
|
}, [agent?.provider, snapshotEntries]);
|
||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
const agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
@@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({
|
|||||||
onModelSelectorOpen,
|
onModelSelectorOpen,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
|
|
||||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||||
@@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({
|
|||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
|
||||||
if (isWeb) {
|
if (platformIsWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
@@ -1129,6 +1126,7 @@ export function DraftAgentStatusBar({
|
|||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider}
|
||||||
|
providerDefinitions={providerDefinitions}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={mappedModeOptions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
WORKING_INDICATOR_CYCLE_MS,
|
WORKING_INDICATOR_CYCLE_MS,
|
||||||
WORKING_INDICATOR_OFFSETS,
|
WORKING_INDICATOR_OFFSETS,
|
||||||
} from "@/utils/working-indicator";
|
} from "@/utils/working-indicator";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
@@ -222,7 +223,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
tail: streamItems,
|
tail: streamItems,
|
||||||
head: streamHead ?? [],
|
head: streamHead ?? [],
|
||||||
platform: Platform.OS === "web" ? "web" : "native",
|
platform: isWeb ? "web" : "native",
|
||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = platformIsWeb;
|
||||||
|
|
||||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
@@ -349,7 +349,7 @@ function ProviderSearchInput({
|
|||||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
if (autoFocus && platformIsWeb && inputRef.current) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -363,7 +363,7 @@ function ProviderSearchInput({
|
|||||||
<InputComponent
|
<InputComponent
|
||||||
ref={inputRef as any}
|
ref={inputRef as any}
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
|
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
|
||||||
placeholder="Search models..."
|
placeholder="Search models..."
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
||||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
@@ -601,7 +600,7 @@ export function CombinedModelSelector({
|
|||||||
}, [selectedModelLabel]);
|
}, [selectedModelLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (platformIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +614,7 @@ export function CombinedModelSelector({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [isOpen, isWeb]);
|
}, [isOpen, platformIsWeb]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -678,7 +677,7 @@ export function CombinedModelSelector({
|
|||||||
<ProviderSearchInput
|
<ProviderSearchInput
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQuery}
|
||||||
autoFocus={Platform.OS === "web"}
|
autoFocus={platformIsWeb}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -793,8 +792,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
level2Header: {
|
level2Header: {},
|
||||||
},
|
|
||||||
backButton: {
|
backButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||||
import { Plus, Settings } from "lucide-react-native";
|
import { Plus, Settings } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||||
return `${agent.serverId}:${agent.id}`;
|
return `${agent.serverId}:${agent.id}`;
|
||||||
@@ -90,7 +91,7 @@ export function CommandCenter() {
|
|||||||
}
|
}
|
||||||
}, [activeIndex, open]);
|
}, [activeIndex, open]);
|
||||||
|
|
||||||
if (Platform.OS !== "web" || !open) return null;
|
if (isNative || !open) return null;
|
||||||
|
|
||||||
const actionItems = items.filter((item) => item.kind === "action");
|
const actionItems = items.filter((item) => item.kind === "action");
|
||||||
const agentItems = items.filter((item) => item.kind === "agent");
|
const agentItems = items.filter((item) => item.kind === "agent");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -126,7 +127,7 @@ export function Composer({
|
|||||||
}: ComposerProps) {
|
}: ComposerProps) {
|
||||||
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
@@ -164,7 +165,7 @@ export function Composer({
|
|||||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||||
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
|
const isDesktopWebBreakpoint = isWeb && !isMobile;
|
||||||
const messagePlaceholder = isDesktopWebBreakpoint
|
const messagePlaceholder = isDesktopWebBreakpoint
|
||||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
@@ -225,7 +226,7 @@ export function Composer({
|
|||||||
}, [addImages, onAddImages]);
|
}, [addImages, onAddImages]);
|
||||||
|
|
||||||
const focusInput = useCallback(() => {
|
const focusInput = useCallback(() => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
focusWithRetries({
|
focusWithRetries({
|
||||||
focus: () => messageInputRef.current?.focus(),
|
focus: () => messageInputRef.current?.focus(),
|
||||||
isFocused: () => {
|
isFocused: () => {
|
||||||
@@ -438,7 +439,7 @@ export function Composer({
|
|||||||
case "message-input.dictation-confirm":
|
case "message-input.dictation-confirm":
|
||||||
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
||||||
case "message-input.focus":
|
case "message-input.focus":
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -632,14 +633,19 @@ export function Composer({
|
|||||||
style={({ hovered }) => [
|
style={({ hovered }) => [
|
||||||
styles.realtimeVoiceButton as any,
|
styles.realtimeVoiceButton as any,
|
||||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||||
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
|
(!isConnected || voice?.isVoiceSwitching
|
||||||
|
? styles.buttonDisabled
|
||||||
|
: undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{({ hovered }) =>
|
{({ hovered }) =>
|
||||||
voice?.isVoiceSwitching ? (
|
voice?.isVoiceSwitching ? (
|
||||||
<ActivityIndicator size="small" color="white" />
|
<ActivityIndicator size="small" color="white" />
|
||||||
) : (
|
) : (
|
||||||
<AudioLines size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
<AudioLines
|
||||||
|
size={buttonIconSize}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VS Code-style titlebar drag region for Electron.
|
* VS Code-style titlebar drag region for Electron.
|
||||||
@@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
|||||||
* Place as FIRST child of any positioned container that should be draggable.
|
* Place as FIRST child of any positioned container that should be draggable.
|
||||||
*/
|
*/
|
||||||
export function TitlebarDragRegion() {
|
export function TitlebarDragRegion() {
|
||||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
if (isNative || !getIsElectronRuntime()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
|
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
diffLines: DiffLine[];
|
diffLines: DiffLine[];
|
||||||
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
overflowWrap: "normal",
|
overflowWrap: "normal",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
StyleSheet as RNStyleSheet,
|
StyleSheet as RNStyleSheet,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
|||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
|
|||||||
|
|
||||||
// Mobile: full-screen overlay with gesture.
|
// Mobile: full-screen overlay with gesture.
|
||||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
|
||||||
|
|
||||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||||
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
|
|||||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||||
{/* Resize handle - absolutely positioned over left border */}
|
{/* Resize handle - absolutely positioned over left border */}
|
||||||
<GestureDetector gesture={resizeGesture}>
|
<GestureDetector gesture={resizeGesture}>
|
||||||
<View
|
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||||
style={[
|
|
||||||
styles.resizeHandle,
|
|
||||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { View, Text, Platform } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { useEffect } from "react";
|
||||||
import { Upload } from "lucide-react-native";
|
import { Upload } from "lucide-react-native";
|
||||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||||
import type { ImageAttachment } from "./message-input";
|
import type { ImageAttachment } from "./message-input";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface FileDropZoneProps {
|
interface FileDropZoneProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -52,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
|||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||||
{ value: "name", label: "Name" },
|
{ value: "name", label: "Name" },
|
||||||
@@ -91,7 +91,7 @@ export function FileExplorerPane({
|
|||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const daemonProfile = useMemo(
|
const daemonProfile = useMemo(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ScrollView as RNScrollView,
|
ScrollView as RNScrollView,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
type HighlightStyle,
|
type HighlightStyle,
|
||||||
} from "@getpaseo/highlight";
|
} from "@getpaseo/highlight";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -243,7 +243,7 @@ export function FilePane({
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
@@ -80,6 +79,7 @@ import {
|
|||||||
formatDiffGutterText,
|
formatDiffGutterText,
|
||||||
hasVisibleDiffTokens,
|
hasVisibleDiffTokens,
|
||||||
} from "@/utils/diff-rendering";
|
} from "@/utils/diff-rendering";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ type WrappedWebTextStyle = TextStyle & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return wrapLines
|
return wrapLines
|
||||||
@@ -454,7 +454,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
}}
|
}}
|
||||||
onPressOut={(event) => {
|
onPressOut={(event) => {
|
||||||
if (
|
if (
|
||||||
Platform.OS !== "web" &&
|
isNative &&
|
||||||
!pressHandledRef.current &&
|
!pressHandledRef.current &&
|
||||||
layoutYRef.current === 0 &&
|
layoutYRef.current === 0 &&
|
||||||
pressInRef.current
|
pressInRef.current
|
||||||
@@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
const canUseSplitLayout = isWeb && !isMobile;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import {
|
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||||
Platform,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
type PressableProps,
|
|
||||||
type StyleProp,
|
|
||||||
type ViewStyle,
|
|
||||||
} from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface HeaderToggleButtonState {
|
interface HeaderToggleButtonState {
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
||||||
const ariaExpandedProps =
|
const ariaExpandedProps =
|
||||||
Platform.OS === "web" && typeof expandedState === "boolean"
|
isWeb && typeof expandedState === "boolean"
|
||||||
? ({ "aria-expanded": expandedState } as any)
|
? ({ "aria-expanded": expandedState } as any)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ interface AiderIconProps {
|
|||||||
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
|
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
|
||||||
return (
|
return (
|
||||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||||
<Path
|
<Path d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z" />
|
||||||
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
|
|
||||||
/>
|
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
Platform,
|
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
StyleSheet as RNStyleSheet,
|
StyleSheet as RNStyleSheet,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -59,6 +58,7 @@ import {
|
|||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@ function MobileSidebar({
|
|||||||
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 = isWeb ? (isOpen ? "auto" : "none") : "box-none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
@@ -833,12 +833,7 @@ function DesktopSidebar({
|
|||||||
|
|
||||||
{/* Resize handle - absolutely positioned over right border */}
|
{/* Resize handle - absolutely positioned over right border */}
|
||||||
<GestureDetector gesture={resizeGesture}>
|
<GestureDetector gesture={resizeGesture}>
|
||||||
<View
|
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||||
style={[
|
|
||||||
styles.resizeHandle,
|
|
||||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
TextInputKeyPressEventData,
|
TextInputKeyPressEventData,
|
||||||
TextInputSelectionChangeEventData,
|
TextInputSelectionChangeEventData,
|
||||||
Image,
|
Image,
|
||||||
Platform,
|
|
||||||
BackHandler,
|
BackHandler,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +47,7 @@ import {
|
|||||||
markScrollInvestigationEvent,
|
markScrollInvestigationEvent,
|
||||||
markScrollInvestigationRender,
|
markScrollInvestigationRender,
|
||||||
} from "@/utils/scroll-jank-investigation";
|
} from "@/utils/scroll-jank-investigation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
@@ -121,14 +121,16 @@ export interface MessageInputRef {
|
|||||||
const MIN_INPUT_HEIGHT_MOBILE = 30;
|
const MIN_INPUT_HEIGHT_MOBILE = 30;
|
||||||
const MIN_INPUT_HEIGHT_DESKTOP = 46;
|
const MIN_INPUT_HEIGHT_DESKTOP = 46;
|
||||||
const MAX_INPUT_HEIGHT = 160;
|
const MAX_INPUT_HEIGHT = 160;
|
||||||
const IS_WEB = Platform.OS === "web";
|
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
|
||||||
const MIN_INPUT_HEIGHT = IS_WEB ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
|
|
||||||
|
|
||||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||||
TextInputKeyPressEventData & {
|
TextInputKeyPressEventData & {
|
||||||
metaKey?: boolean;
|
metaKey?: boolean;
|
||||||
ctrlKey?: boolean;
|
ctrlKey?: boolean;
|
||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
|
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||||
|
isComposing?: boolean;
|
||||||
|
keyCode?: number;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -232,7 +234,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
|
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
|
||||||
markScrollInvestigationRender(investigationComponentId);
|
markScrollInvestigationRender(investigationComponentId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -303,7 +305,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
getNativeElement: () => {
|
getNativeElement: () => {
|
||||||
if (!IS_WEB) return null;
|
if (!isWeb) return null;
|
||||||
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
|
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
|
||||||
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
|
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
|
||||||
return native instanceof HTMLElement ? native : null;
|
return native instanceof HTMLElement ? native : null;
|
||||||
@@ -338,7 +340,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !autoFocus) return;
|
if (!isWeb || !autoFocus) return;
|
||||||
return focusWithRetries({
|
return focusWithRetries({
|
||||||
focus: () => textInputRef.current?.focus(),
|
focus: () => textInputRef.current?.focus(),
|
||||||
isFocused: () => {
|
isFocused: () => {
|
||||||
@@ -381,7 +383,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IS_WEB && typeof requestAnimationFrame === "function") {
|
if (isWeb && typeof requestAnimationFrame === "function") {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
measureWebInputHeight("dictation");
|
measureWebInputHeight("dictation");
|
||||||
});
|
});
|
||||||
@@ -637,13 +639,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||||
}
|
}
|
||||||
}, [getWebTextArea]);
|
}, [getWebTextArea]);
|
||||||
|
|
||||||
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
||||||
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
|
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
|
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
|
||||||
@@ -657,7 +659,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !onAddImages) {
|
if (!isWeb || !onAddImages) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,7 +712,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || typeof ResizeObserver === "undefined") {
|
if (!isWeb || typeof ResizeObserver === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +766,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, [getWebElement, getWebTextArea]);
|
}, [getWebElement, getWebTextArea]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) {
|
if (!isWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
|
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
|
||||||
@@ -802,7 +804,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, [getWebTextArea]);
|
}, [getWebTextArea]);
|
||||||
|
|
||||||
function measureWebInputHeight(source: string): boolean {
|
function measureWebInputHeight(source: string): boolean {
|
||||||
if (!IS_WEB) return false;
|
if (!isWeb) return false;
|
||||||
const textarea = getWebTextArea();
|
const textarea = getWebTextArea();
|
||||||
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
||||||
const scrollHeight = textarea.scrollHeight ?? 0;
|
const scrollHeight = textarea.scrollHeight ?? 0;
|
||||||
@@ -856,7 +858,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||||
) {
|
) {
|
||||||
const contentHeight = event.nativeEvent.contentSize.height;
|
const contentHeight = event.nativeEvent.contentSize.height;
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
logWebStickyBottom("composer_content_size_change", {
|
logWebStickyBottom("composer_content_size_change", {
|
||||||
reportedHeight: contentHeight,
|
reportedHeight: contentHeight,
|
||||||
});
|
});
|
||||||
@@ -876,7 +878,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||||
const start = event.nativeEvent.selection?.start ?? 0;
|
const start = event.nativeEvent.selection?.start ?? 0;
|
||||||
const end = event.nativeEvent.selection?.end ?? start;
|
const end = event.nativeEvent.selection?.end ?? start;
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
const textarea = getWebTextArea();
|
const textarea = getWebTextArea();
|
||||||
logWebStickyBottom("composer_selection_changed", {
|
logWebStickyBottom("composer_selection_changed", {
|
||||||
now: getDebugNow(),
|
now: getDebugNow(),
|
||||||
@@ -890,12 +892,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onSelectionChangeCallback?.({ start, end });
|
onSelectionChangeCallback?.({ start, end });
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldHandleDesktopSubmit = IS_WEB;
|
const shouldHandleDesktopSubmit = isWeb;
|
||||||
|
|
||||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||||
markScrollInvestigationEvent(investigationComponentId, "keyPress");
|
markScrollInvestigationEvent(investigationComponentId, "keyPress");
|
||||||
if (!shouldHandleDesktopSubmit) return;
|
if (!shouldHandleDesktopSubmit) return;
|
||||||
|
|
||||||
|
// IME composition in progress (e.g. CJK input) — all key events belong to the
|
||||||
|
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
|
||||||
|
// cleared before the keydown fires.
|
||||||
|
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||||
|
|
||||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||||
if (onKeyPressCallback) {
|
if (onKeyPressCallback) {
|
||||||
const handled = onKeyPressCallback({
|
const handled = onKeyPressCallback({
|
||||||
@@ -930,7 +937,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const hasRealContent = value.trim().length > 0 || hasImages;
|
const hasRealContent = value.trim().length > 0 || hasImages;
|
||||||
const hasSendableContent = hasRealContent || allowEmptySubmit;
|
const hasSendableContent = hasRealContent || allowEmptySubmit;
|
||||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||||
const showEmptySubmitLabel = allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading;
|
const showEmptySubmitLabel =
|
||||||
|
allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading;
|
||||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
const isSendButtonDisabled =
|
const isSendButtonDisabled =
|
||||||
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||||
@@ -947,7 +955,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
(nextValue: string) => {
|
(nextValue: string) => {
|
||||||
markScrollInvestigationEvent(investigationComponentId, "inputChange");
|
markScrollInvestigationEvent(investigationComponentId, "inputChange");
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
logWebStickyBottom("composer_text_changed", {
|
logWebStickyBottom("composer_text_changed", {
|
||||||
valueLength: nextValue.length,
|
valueLength: nextValue.length,
|
||||||
lineCount: nextValue.split("\n").length,
|
lineCount: nextValue.split("\n").length,
|
||||||
@@ -960,7 +968,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
return (
|
return (
|
||||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||||
{/* Regular input */}
|
{/* Regular input */}
|
||||||
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}>
|
<Animated.View
|
||||||
|
ref={inputWrapperRef}
|
||||||
|
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
|
||||||
|
>
|
||||||
{/* Image preview pills */}
|
{/* Image preview pills */}
|
||||||
{hasImages && (
|
{hasImages && (
|
||||||
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
||||||
@@ -978,7 +989,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.removeImageButton,
|
styles.removeImageButton,
|
||||||
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
|
(hovered || !isWeb) && styles.removeImageButtonVisible,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<X size={theme.iconSize.md} color="white" />
|
<X size={theme.iconSize.md} color="white" />
|
||||||
@@ -1010,7 +1021,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
styles.textInput,
|
styles.textInput,
|
||||||
IS_WEB
|
isWeb
|
||||||
? {
|
? {
|
||||||
height: inputHeight,
|
height: inputHeight,
|
||||||
minHeight: MIN_INPUT_HEIGHT,
|
minHeight: MIN_INPUT_HEIGHT,
|
||||||
@@ -1022,12 +1033,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
multiline
|
multiline
|
||||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
autoFocus={IS_WEB && autoFocus}
|
autoFocus={isWeb && autoFocus}
|
||||||
/>
|
/>
|
||||||
{inputScrollbar}
|
{inputScrollbar}
|
||||||
</View>
|
</View>
|
||||||
@@ -1051,7 +1062,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Paperclip size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
<Paperclip
|
||||||
|
size={buttonIconSize}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -1091,9 +1105,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
isDictating ? (
|
isDictating ? (
|
||||||
<Square size={buttonIconSize} color="white" fill="white" />
|
<Square size={buttonIconSize} color="white" fill="white" />
|
||||||
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||||
<MicOff size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
<MicOff
|
||||||
|
size={buttonIconSize}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Mic size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
<Mic
|
||||||
|
size={buttonIconSize}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -1150,7 +1170,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
|
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
|
||||||
disabled={isSendButtonDisabled}
|
disabled={isSendButtonDisabled}
|
||||||
accessibilityLabel={showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel}
|
accessibilityLabel={
|
||||||
|
showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel
|
||||||
|
}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
style={[
|
style={[
|
||||||
showEmptySubmitLabel ? styles.emptySubmitButton : styles.sendButton,
|
showEmptySubmitLabel ? styles.emptySubmitButton : styles.sendButton,
|
||||||
@@ -1233,7 +1255,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
xs: theme.spacing[3],
|
xs: theme.spacing[3],
|
||||||
md: theme.spacing[4],
|
md: theme.spacing[4],
|
||||||
},
|
},
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
transitionProperty: "border-color",
|
transitionProperty: "border-color",
|
||||||
transitionDuration: "200ms",
|
transitionDuration: "200ms",
|
||||||
@@ -1252,7 +1274,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}
|
}
|
||||||
@@ -1277,7 +1299,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
transitionProperty: "opacity",
|
transitionProperty: "opacity",
|
||||||
transitionDuration: "150ms",
|
transitionDuration: "150ms",
|
||||||
@@ -1296,7 +1318,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
lineHeight: theme.fontSize.base * 1.4,
|
lineHeight: theme.fontSize.base * 1.4,
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
outlineStyle: "none" as const,
|
outlineStyle: "none" as const,
|
||||||
outlineWidth: 0,
|
outlineWidth: 0,
|
||||||
@@ -1318,7 +1340,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
rightButtonGroup: {
|
rightButtonGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
|
||||||
},
|
},
|
||||||
attachButton: {
|
attachButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet";
|
|||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
|
|||||||
type ScrollAxis = "x" | "y";
|
type ScrollAxis = "x" | "y";
|
||||||
|
|
||||||
function ensureWebToolCallShimmerKeyframes() {
|
function ensureWebToolCallShimmerKeyframes() {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
@@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [messageHovered, setMessageHovered] = useState(false);
|
const [messageHovered, setMessageHovered] = useState(false);
|
||||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const hasText = message.trim().length > 0;
|
const hasText = message.trim().length > 0;
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -361,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={userMessageStylesheet.content}
|
style={userMessageStylesheet.content}
|
||||||
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
|
onHoverIn={() => setMessageHovered(true)}
|
||||||
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
|
onHoverOut={() => setMessageHovered(false)}
|
||||||
>
|
>
|
||||||
<View style={userMessageStylesheet.bubble}>
|
<View style={userMessageStylesheet.bubble}>
|
||||||
{hasImages ? (
|
{hasImages ? (
|
||||||
@@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
userSelect: Platform.OS === "web" ? "text" : "auto",
|
userSelect: isWeb ? "text" : "auto",
|
||||||
},
|
},
|
||||||
imageFrame: {
|
imageFrame: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -657,7 +658,7 @@ function MarkdownLink({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return (
|
return (
|
||||||
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
|
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
|
||||||
{children}
|
{children}
|
||||||
@@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleCopy}
|
onPress={handleCopy}
|
||||||
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
|
onHoverIn={() => onHoverChange?.(true)}
|
||||||
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
|
onHoverOut={() => onHoverChange?.(false)}
|
||||||
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={
|
accessibilityLabel={
|
||||||
@@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
<Text
|
<Text
|
||||||
key={node.key}
|
key={node.key}
|
||||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||||
selectable={Platform.OS === "web" ? undefined : false}
|
selectable={isWeb ? undefined : false}
|
||||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
@@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
32,
|
32,
|
||||||
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
|
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
|
||||||
);
|
);
|
||||||
const isWebShimmer = isLoading && Platform.OS === "web";
|
const isWebShimmer = isLoading && isWeb;
|
||||||
const shouldMeasureWebShimmer = isWebShimmer;
|
const shouldMeasureWebShimmer = isWebShimmer;
|
||||||
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
|
const shouldMeasureNativeShimmer = isLoading && isNative;
|
||||||
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
|
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
|
||||||
const webShimmerSpanStartX = labelOffsetX;
|
const webShimmerSpanStartX = labelOffsetX;
|
||||||
const webShimmerSpanEndX = secondaryLabel
|
const webShimmerSpanEndX = secondaryLabel
|
||||||
@@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
if (isNative || !isExpanded || !hasDetailContent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 } from "react-native";
|
||||||
import { Folder } from "lucide-react-native";
|
import { Folder } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
|
|||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
export function ProjectPickerModal() {
|
export function ProjectPickerModal() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || Platform.OS !== "web") return;
|
if (!open || isNative) return;
|
||||||
|
|
||||||
function handler(event: KeyboardEvent) {
|
function handler(event: KeyboardEvent) {
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
|
||||||
|
|
||||||
interface ProviderDiagnosticSheetProps {
|
interface ProviderDiagnosticSheetProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({
|
|||||||
}: ProviderDiagnosticSheetProps) {
|
}: ProviderDiagnosticSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
||||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const providerLabel =
|
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||||
AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
|
|
||||||
|
|
||||||
const fetchDiagnostic = useCallback(async () => {
|
const fetchDiagnostic = useCallback(async () => {
|
||||||
if (!client || !provider) return;
|
if (!client || !provider) return;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
|
import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface QuestionOption {
|
interface QuestionOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
|
|||||||
isResponding: boolean;
|
isResponding: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
|||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||||
import { buildHostNewWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostNewWorkspaceRoute,
|
||||||
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
@@ -99,6 +102,7 @@ import {
|
|||||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
|
|
||||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
@@ -214,18 +218,12 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
hitSlop={4}
|
hitSlop={4}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onPointerEnter={() => setIsHovered(true)}
|
onHoverIn={() => setIsHovered(true)}
|
||||||
onPointerLeave={() => setIsHovered(false)}
|
onHoverOut={() => setIsHovered(false)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
|
||||||
prBadgeStyles.badge,
|
|
||||||
pressed && prBadgeStyles.badgePressed,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<GitPullRequest size={12} color={activeColor} />
|
<GitPullRequest size={12} color={activeColor} />
|
||||||
<Text
|
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
|
||||||
style={[prBadgeStyles.text, { color: activeColor }]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
#{hint.number}
|
#{hint.number}
|
||||||
</Text>
|
</Text>
|
||||||
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
|
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
|
||||||
@@ -281,7 +279,6 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
function WorkspaceStatusIndicator({
|
function WorkspaceStatusIndicator({
|
||||||
bucket,
|
bucket,
|
||||||
workspaceKind,
|
workspaceKind,
|
||||||
@@ -613,7 +610,7 @@ function useLongPressDragInteraction(input: {
|
|||||||
input.drag();
|
input.drag();
|
||||||
}, DRAG_ARM_DELAY_MS);
|
}, DRAG_ARM_DELAY_MS);
|
||||||
|
|
||||||
if (!input.menuController || Platform.OS === "web") {
|
if (!input.menuController || platformIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,7 +775,9 @@ function ProjectHeaderRow({
|
|||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.navigate(buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any);
|
router.navigate(
|
||||||
|
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any,
|
||||||
|
);
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
@@ -836,15 +835,18 @@ function ProjectHeaderRow({
|
|||||||
<NewWorktreeButton
|
<NewWorktreeButton
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
onPress={handleBeginWorkspaceSetup}
|
onPress={handleBeginWorkspaceSetup}
|
||||||
visible={isHovered || isMobileBreakpoint}
|
visible={isHovered || platformIsNative || isMobileBreakpoint}
|
||||||
showShortcutHint={isProjectActive}
|
showShortcutHint={isProjectActive}
|
||||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{onRemoveProject ? (
|
{onRemoveProject ? (
|
||||||
<View
|
<View
|
||||||
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
|
style={
|
||||||
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
|
!(isHovered || platformIsNative || isMobileBreakpoint) &&
|
||||||
|
styles.projectKebabButtonHidden
|
||||||
|
}
|
||||||
|
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -954,8 +956,9 @@ function WorkspaceRowInner({
|
|||||||
archiveShortcutKeys,
|
archiveShortcutKeys,
|
||||||
}: WorkspaceRowInnerProps) {
|
}: WorkspaceRowInnerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isTouchPlatform = Platform.OS !== "web";
|
const isTouchPlatform = platformIsNative;
|
||||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
});
|
});
|
||||||
@@ -1080,7 +1083,9 @@ function WorkspaceRowInner({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||||
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
trailing={
|
||||||
|
archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null
|
||||||
|
}
|
||||||
status={archiveStatus}
|
status={archiveStatus}
|
||||||
pendingLabel={archivePendingLabel}
|
pendingLabel={archivePendingLabel}
|
||||||
onSelect={onArchive}
|
onSelect={onArchive}
|
||||||
@@ -1870,7 +1875,6 @@ export function SidebarWorkspaceList({
|
|||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const isNative = Platform.OS !== "web";
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||||
@@ -2104,7 +2108,7 @@ export function SidebarWorkspaceList({
|
|||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
useNestable={isNative}
|
useNestable={platformIsNative}
|
||||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2121,7 +2125,7 @@ export function SidebarWorkspaceList({
|
|||||||
serverId,
|
serverId,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
isNative,
|
platformIsNative,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -2145,7 +2149,7 @@ export function SidebarWorkspaceList({
|
|||||||
onDragEnd={handleProjectDragEnd}
|
onDragEnd={handleProjectDragEnd}
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={isNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
containerStyle={styles.projectListContainer}
|
containerStyle={styles.projectListContainer}
|
||||||
/>
|
/>
|
||||||
@@ -2156,7 +2160,7 @@ export function SidebarWorkspaceList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{isNative ? (
|
{platformIsNative ? (
|
||||||
<NestableScrollContainer
|
<NestableScrollContainer
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||||
import { Platform, View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ResizeHandle } from "@/components/resize-handle";
|
import { ResizeHandle } from "@/components/resize-handle";
|
||||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||||
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
|
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
interface SplitContainerProps {
|
interface SplitContainerProps {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
@@ -828,7 +829,7 @@ function SplitPaneView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
@@ -234,8 +235,8 @@ export function ToastViewport({
|
|||||||
<View style={styles.container} pointerEvents="box-none">
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
testID={toast.testID ?? "app-toast"}
|
testID={toast.testID ?? "app-toast"}
|
||||||
onPointerEnter={pauseDismiss}
|
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||||
onPointerLeave={resumeDismiss}
|
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||||
style={[
|
style={[
|
||||||
styles.toast,
|
styles.toast,
|
||||||
toast.variant === "success" ? styles.toastSuccess : null,
|
toast.variant === "success" ? styles.toastSuccess : null,
|
||||||
@@ -265,7 +266,7 @@ export function ToastViewport({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
|
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
|
||||||
return createPortal(content, getOverlayRoot());
|
return createPortal(content, getOverlayRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, ReactNode } from "react";
|
import React, { useMemo, ReactNode } from "react";
|
||||||
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
|
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
@@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
|
|||||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||||
|
|
||||||
// ---- Content Component ----
|
// ---- Content Component ----
|
||||||
|
|
||||||
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
overflowWrap: "normal",
|
overflowWrap: "normal",
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ import {
|
|||||||
shouldShowCustomComboboxOption,
|
shouldShowCustomComboboxOption,
|
||||||
} from "./combobox-options";
|
} from "./combobox-options";
|
||||||
import type { ComboboxOptionModel } from "./combobox-options";
|
import type { ComboboxOptionModel } from "./combobox-options";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export type ComboboxOption = ComboboxOptionModel;
|
export type ComboboxOption = ComboboxOptionModel;
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export interface SearchInputProps {
|
|||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmitEditing?: () => void;
|
onSubmitEditing?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
useBottomSheetInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchInput({
|
export function SearchInput({
|
||||||
@@ -115,10 +117,11 @@ export function SearchInput({
|
|||||||
onChangeText,
|
onChangeText,
|
||||||
onSubmitEditing,
|
onSubmitEditing,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
useBottomSheetInput = false,
|
||||||
}: SearchInputProps): ReactElement {
|
}: SearchInputProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && IS_WEB && inputRef.current) {
|
if (autoFocus && IS_WEB && inputRef.current) {
|
||||||
@@ -264,9 +267,8 @@ export function Combobox({
|
|||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||||
const isDesktopAboveSearch =
|
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
||||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const hasPresentedBottomSheetRef = useRef(false);
|
const hasPresentedBottomSheetRef = useRef(false);
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
@@ -274,11 +276,13 @@ export function Combobox({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||||
|
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
|
||||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||||
|
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
const isControlled = typeof open === "boolean";
|
const isControlled = typeof open === "boolean";
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
@@ -322,8 +326,8 @@ export function Combobox({
|
|||||||
|
|
||||||
const middleware = useMemo(
|
const middleware = useMemo(
|
||||||
() => [
|
() => [
|
||||||
floatingOffset(Platform.OS === "web" ? 5 : 4),
|
floatingOffset(isWeb ? 5 : 4),
|
||||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
|
||||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||||
floatingSize({
|
floatingSize({
|
||||||
padding: collisionPadding,
|
padding: collisionPadding,
|
||||||
@@ -336,6 +340,9 @@ export function Combobox({
|
|||||||
});
|
});
|
||||||
setReferenceWidth((prev) => {
|
setReferenceWidth((prev) => {
|
||||||
const next = rects.reference.width;
|
const next = rects.reference.width;
|
||||||
|
if (!(next > 0)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
if (prev === next) return prev;
|
if (prev === next) return prev;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -346,7 +353,7 @@ export function Combobox({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { refs, floatingStyles, update } = useFloating({
|
const { refs, floatingStyles, update } = useFloating({
|
||||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
placement: isWeb ? desktopPlacement : "bottom-start",
|
||||||
middleware,
|
middleware,
|
||||||
sameScrollView: false,
|
sameScrollView: false,
|
||||||
elements: {
|
elements: {
|
||||||
@@ -357,15 +364,18 @@ export function Combobox({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || isMobile) {
|
if (!isOpen || isMobile) {
|
||||||
setAvailableSize(null);
|
setAvailableSize(null);
|
||||||
|
setDesktopContentWidth(null);
|
||||||
|
setReferenceLeft(null);
|
||||||
setReferenceWidth(null);
|
setReferenceWidth(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raf = requestAnimationFrame(() => void update());
|
const raf = requestAnimationFrame(() => void update());
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
}, [desktopPlacement, isMobile, isOpen, update]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || isMobile) {
|
if (!isOpen || isMobile) {
|
||||||
|
setReferenceLeft(null);
|
||||||
setReferenceAtOrigin(false);
|
setReferenceAtOrigin(false);
|
||||||
setReferenceTop(null);
|
setReferenceTop(null);
|
||||||
return;
|
return;
|
||||||
@@ -379,9 +389,16 @@ export function Combobox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
referenceEl.measureInWindow((x, y) => {
|
referenceEl.measureInWindow((x, y, width, height) => {
|
||||||
|
setReferenceLeft((prev) => (prev === x ? prev : x));
|
||||||
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
||||||
setReferenceTop((prev) => (prev === y ? prev : y));
|
setReferenceTop((prev) => (prev === y ? prev : y));
|
||||||
|
setReferenceWidth((prev) => {
|
||||||
|
if (!(width > 0)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return prev === width ? prev : width;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -396,32 +413,46 @@ export function Combobox({
|
|||||||
isDesktopAboveSearch && referenceTop !== null
|
isDesktopAboveSearch && referenceTop !== null
|
||||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||||
: null;
|
: null;
|
||||||
const hasResolvedDesktopPosition =
|
const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
|
||||||
referenceWidth !== null &&
|
const useMeasuredTopStartPosition =
|
||||||
floatingLeft !== null &&
|
|
||||||
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
|
||||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
|
||||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
|
||||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
|
||||||
// For top-placed popups: once position resolves, use bottom-based CSS positioning
|
|
||||||
// so height changes grow upward naturally without floating-ui needing to reposition.
|
|
||||||
const useStableBottom =
|
|
||||||
!isDesktopAboveSearch &&
|
!isDesktopAboveSearch &&
|
||||||
IS_WEB &&
|
IS_WEB &&
|
||||||
!isMobile &&
|
!isMobile &&
|
||||||
hasResolvedDesktopPosition &&
|
desktopPlacement === "top-start" &&
|
||||||
desktopPlacement.startsWith("top") &&
|
referenceTop !== null &&
|
||||||
referenceTop !== null;
|
referenceLeft !== null &&
|
||||||
|
desktopContentWidth !== null;
|
||||||
|
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
|
||||||
|
? Math.max(
|
||||||
|
collisionPadding,
|
||||||
|
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const measuredTopStartBottom = useMeasuredTopStartPosition
|
||||||
|
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
|
||||||
|
: null;
|
||||||
|
const hasResolvedDesktopPosition =
|
||||||
|
referenceWidth !== null &&
|
||||||
|
referenceWidth > 0 &&
|
||||||
|
(isDesktopAboveSearch
|
||||||
|
? floatingLeft !== null && desktopAboveSearchBottom !== null
|
||||||
|
: useMeasuredTopStartPosition
|
||||||
|
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
|
||||||
|
: floatingLeft !== null &&
|
||||||
|
floatingTop !== null &&
|
||||||
|
(hasNonZeroFloatingPosition || !referenceAtOrigin));
|
||||||
|
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||||
|
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||||
|
|
||||||
const desktopPositionStyle = isDesktopAboveSearch
|
const desktopPositionStyle = isDesktopAboveSearch
|
||||||
? {
|
? {
|
||||||
left: floatingLeft ?? 0,
|
left: floatingLeft ?? 0,
|
||||||
bottom: desktopAboveSearchBottom ?? 0,
|
bottom: desktopAboveSearchBottom ?? 0,
|
||||||
}
|
}
|
||||||
: useStableBottom
|
: useMeasuredTopStartPosition
|
||||||
? {
|
? {
|
||||||
left: floatingLeft ?? 0,
|
left: clampedMeasuredTopStartLeft ?? 0,
|
||||||
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
bottom: measuredTopStartBottom ?? 0,
|
||||||
}
|
}
|
||||||
: floatingStyles;
|
: floatingStyles;
|
||||||
|
|
||||||
@@ -626,6 +657,7 @@ export function Combobox({
|
|||||||
onChangeText={setSearchQueryWithCallback}
|
onChangeText={setSearchQueryWithCallback}
|
||||||
onSubmitEditing={handleSubmitSearch}
|
onSubmitEditing={handleSubmitSearch}
|
||||||
autoFocus={!isMobile}
|
autoFocus={!isMobile}
|
||||||
|
useBottomSheetInput={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -725,7 +757,13 @@ export function Combobox({
|
|||||||
]}
|
]}
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
onLayout={() => update()}
|
onLayout={(event) => {
|
||||||
|
const { width, height } = event.nativeEvent.layout;
|
||||||
|
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
||||||
|
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
||||||
|
void update();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children ? (
|
{children ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
// Keep parity with dropdown-menu action statuses.
|
// Keep parity with dropdown-menu action statuses.
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -254,8 +255,7 @@ export function ContextMenuTrigger({
|
|||||||
>): ReactElement {
|
>): ReactElement {
|
||||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||||
|
|
||||||
const shouldEnableOnThisPlatform =
|
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
|
||||||
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
|
|
||||||
|
|
||||||
const openAtEvent = useCallback(
|
const openAtEvent = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
@@ -294,7 +294,7 @@ export function ContextMenuTrigger({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
delayLongPress={longPressDelayMs}
|
delayLongPress={longPressDelayMs}
|
||||||
onLongPress={(event) => {
|
onLongPress={(event) => {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
props.onLongPress?.(event);
|
props.onLongPress?.(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ export function ContextMenuTrigger({
|
|||||||
}}
|
}}
|
||||||
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
||||||
onContextMenu={(event: unknown) => {
|
onContextMenu={(event: unknown) => {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const e: any = event;
|
const e: any = event;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
|
|||||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type Side = "top" | "bottom" | "left" | "right";
|
type Side = "top" | "bottom" | "left" | "right";
|
||||||
type Align = "start" | "center" | "end";
|
type Align = "start" | "center" | "end";
|
||||||
@@ -108,18 +110,6 @@ function measureElement(element: View): Promise<Rect> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMobileTooltipEnvironment(): boolean {
|
|
||||||
if (Platform.OS !== "web") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof navigator === "undefined") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePosition({
|
function computePosition({
|
||||||
triggerRect,
|
triggerRect,
|
||||||
contentSize,
|
contentSize,
|
||||||
@@ -227,8 +217,8 @@ export function Tooltip({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMobile = isMobileTooltipEnvironment();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
|
||||||
|
|
||||||
const value = useMemo<TooltipContextValue>(
|
const value = useMemo<TooltipContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -236,10 +226,10 @@ export function Tooltip({
|
|||||||
setOpen: setIsOpen,
|
setOpen: setIsOpen,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
enabled,
|
enabled,
|
||||||
openOnPress: isMobile,
|
openOnPress: isCompact,
|
||||||
delayDuration,
|
delayDuration,
|
||||||
}),
|
}),
|
||||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||||
@@ -257,10 +247,9 @@ export function TooltipTrigger({
|
|||||||
triggerRefProp = "ref",
|
triggerRefProp = "ref",
|
||||||
...props
|
...props
|
||||||
}: PressableProps & {
|
}: PressableProps & {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
triggerRefProp?: string;
|
triggerRefProp?: string;
|
||||||
}
|
}): ReactElement {
|
||||||
): ReactElement {
|
|
||||||
const ctx = useTooltipContext("TooltipTrigger");
|
const ctx = useTooltipContext("TooltipTrigger");
|
||||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -353,7 +342,7 @@ export function TooltipTrigger({
|
|||||||
onFocus: handleFocus,
|
onFocus: handleFocus,
|
||||||
onBlur: handleBlur,
|
onBlur: handleBlur,
|
||||||
onPress: handlePress,
|
onPress: handlePress,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? ({
|
? ({
|
||||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||||
onPointerEnter: handleHoverIn,
|
onPointerEnter: handleHoverIn,
|
||||||
@@ -472,7 +461,7 @@ export function TooltipContent({
|
|||||||
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
||||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
Platform,
|
|
||||||
type FlatList,
|
type FlatList,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
useWebDesktopScrollbarMetrics,
|
useWebDesktopScrollbarMetrics,
|
||||||
type ScrollbarMetrics,
|
type ScrollbarMetrics,
|
||||||
} from "./web-desktop-scrollbar";
|
} from "./web-desktop-scrollbar";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const METRICS_EPSILON = 0.5;
|
const METRICS_EPSILON = 0.5;
|
||||||
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
||||||
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
|
|||||||
contentRef?: RefObject<HTMLElement | null>;
|
contentRef?: RefObject<HTMLElement | null>;
|
||||||
},
|
},
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
const isWeb = Platform.OS === "web";
|
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||||
const enabled = (options?.enabled ?? true) && isWeb;
|
|
||||||
const contentRef = options?.contentRef;
|
const contentRef = options?.contentRef;
|
||||||
|
|
||||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||||
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
|
|||||||
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||||
options?: { enabled?: boolean },
|
options?: { enabled?: boolean },
|
||||||
): WebScrollViewScrollbar {
|
): WebScrollViewScrollbar {
|
||||||
const isWeb = Platform.OS === "web";
|
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||||
const enabled = (options?.enabled ?? true) && isWeb;
|
|
||||||
const metricsHook = useWebDesktopScrollbarMetrics();
|
const metricsHook = useWebDesktopScrollbarMetrics();
|
||||||
|
|
||||||
const onScrollToOffset = useCallback(
|
const onScrollToOffset = useCallback(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
PanResponder,
|
PanResponder,
|
||||||
Platform,
|
|
||||||
View,
|
View,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
computeScrollOffsetFromDragDelta,
|
computeScrollOffsetFromDragDelta,
|
||||||
computeVerticalScrollbarGeometry,
|
computeVerticalScrollbarGeometry,
|
||||||
} from "./web-desktop-scrollbar.math";
|
} from "./web-desktop-scrollbar.math";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const METRICS_EPSILON = 0.5;
|
const METRICS_EPSILON = 0.5;
|
||||||
const HANDLE_WIDTH_IDLE = 6;
|
const HANDLE_WIDTH_IDLE = 6;
|
||||||
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
maxScrollOffset: 0,
|
maxScrollOffset: 0,
|
||||||
});
|
});
|
||||||
const onScrollToOffsetRef = useRef(onScrollToOffset);
|
const onScrollToOffsetRef = useRef(onScrollToOffset);
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
|
|
||||||
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
||||||
const normalizedOffset = inverted
|
const normalizedOffset = inverted
|
||||||
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const panResponder = useMemo(() => {
|
const panResponder = useMemo(() => {
|
||||||
if (isWeb) {
|
if (platformIsWeb) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [applyDragDelta, isWeb]);
|
}, [applyDragDelta, platformIsWeb]);
|
||||||
|
|
||||||
const startWebDrag = useCallback(
|
const startWebDrag = useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (!isWeb) {
|
if (!platformIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientY = readClientY(event);
|
const clientY = readClientY(event);
|
||||||
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
dragStartClientYRef.current = clientY;
|
dragStartClientYRef.current = clientY;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
},
|
},
|
||||||
[isWeb],
|
[platformIsWeb],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGrabHoverIn = useCallback(() => {
|
const handleGrabHoverIn = useCallback(() => {
|
||||||
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !isDragging) {
|
if (!platformIsWeb || !isDragging) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
window.removeEventListener("pointerup", stopDragging);
|
window.removeEventListener("pointerup", stopDragging);
|
||||||
window.removeEventListener("pointercancel", stopDragging);
|
window.removeEventListener("pointercancel", stopDragging);
|
||||||
};
|
};
|
||||||
}, [applyDragDelta, isDragging, isWeb]);
|
}, [applyDragDelta, isDragging, platformIsWeb]);
|
||||||
|
|
||||||
if (!enabled || !geometry.isVisible) {
|
if (!enabled || !geometry.isVisible) {
|
||||||
return null;
|
return null;
|
||||||
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
height: thumbRegionHeight,
|
height: thumbRegionHeight,
|
||||||
transform: [{ translateY: thumbRegionOffset }],
|
transform: [{ translateY: thumbRegionOffset }],
|
||||||
},
|
},
|
||||||
isWeb &&
|
platformIsWeb &&
|
||||||
({
|
({
|
||||||
cursor: handleCursor,
|
cursor: handleCursor,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
]}
|
]}
|
||||||
pointerEvents={handleVisible ? "auto" : "none"}
|
pointerEvents={handleVisible ? "auto" : "none"}
|
||||||
{...(panResponder?.panHandlers ?? {})}
|
{...(panResponder?.panHandlers ?? {})}
|
||||||
{...(isWeb
|
{...(platformIsWeb
|
||||||
? ({
|
? ({
|
||||||
onPointerDown: startWebDrag,
|
onPointerDown: startWebDrag,
|
||||||
onPointerEnter: handleGrabHoverIn,
|
onPointerEnter: handleGrabHoverIn,
|
||||||
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
backgroundColor: handleColor,
|
backgroundColor: handleColor,
|
||||||
opacity: handleOpacity,
|
opacity: handleOpacity,
|
||||||
},
|
},
|
||||||
isWeb &&
|
platformIsWeb &&
|
||||||
({
|
({
|
||||||
transitionProperty: "opacity, width, background-color",
|
transitionProperty: "opacity, width, background-color",
|
||||||
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
|
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
||||||
@@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
|||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
type WelcomeAction = {
|
type WelcomeAction = {
|
||||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||||
@@ -242,8 +243,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPathname =
|
const currentPathname = typeof window === "undefined" ? null : window.location.pathname || null;
|
||||||
typeof window === "undefined" ? null : (window.location.pathname || null);
|
|
||||||
if (currentPathname && currentPathname !== "/welcome") {
|
if (currentPathname && currentPathname !== "/welcome") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -260,52 +260,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const actions: WelcomeAction[] =
|
const actions: WelcomeAction[] = isWeb
|
||||||
Platform.OS === "web"
|
? [
|
||||||
? [
|
{
|
||||||
{
|
key: "direct-connection",
|
||||||
key: "direct-connection",
|
label: "Direct connection",
|
||||||
label: "Direct connection",
|
testID: "welcome-direct-connection",
|
||||||
testID: "welcome-direct-connection",
|
primary: true,
|
||||||
primary: true,
|
icon: Link2,
|
||||||
icon: Link2,
|
onPress: () => setIsDirectOpen(true),
|
||||||
onPress: () => setIsDirectOpen(true),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "paste-pairing-link",
|
||||||
key: "paste-pairing-link",
|
label: "Paste pairing link",
|
||||||
label: "Paste pairing link",
|
testID: "welcome-paste-pairing-link",
|
||||||
testID: "welcome-paste-pairing-link",
|
primary: false,
|
||||||
primary: false,
|
icon: ClipboardPaste,
|
||||||
icon: ClipboardPaste,
|
onPress: () => setIsPasteLinkOpen(true),
|
||||||
onPress: () => setIsPasteLinkOpen(true),
|
},
|
||||||
},
|
]
|
||||||
]
|
: [
|
||||||
: [
|
{
|
||||||
{
|
key: "scan-qr",
|
||||||
key: "scan-qr",
|
label: "Scan QR code",
|
||||||
label: "Scan QR code",
|
testID: "welcome-scan-qr",
|
||||||
testID: "welcome-scan-qr",
|
primary: true,
|
||||||
primary: true,
|
icon: QrCode,
|
||||||
icon: QrCode,
|
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "direct-connection",
|
||||||
key: "direct-connection",
|
label: "Direct connection",
|
||||||
label: "Direct connection",
|
testID: "welcome-direct-connection",
|
||||||
testID: "welcome-direct-connection",
|
primary: false,
|
||||||
primary: false,
|
icon: Link2,
|
||||||
icon: Link2,
|
onPress: () => setIsDirectOpen(true),
|
||||||
onPress: () => setIsDirectOpen(true),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "paste-pairing-link",
|
||||||
key: "paste-pairing-link",
|
label: "Paste pairing link",
|
||||||
label: "Paste pairing link",
|
testID: "welcome-paste-pairing-link",
|
||||||
testID: "welcome-paste-pairing-link",
|
primary: false,
|
||||||
primary: false,
|
icon: ClipboardPaste,
|
||||||
icon: ClipboardPaste,
|
onPress: () => setIsPasteLinkOpen(true),
|
||||||
onPress: () => setIsPasteLinkOpen(true),
|
},
|
||||||
},
|
];
|
||||||
];
|
|
||||||
|
|
||||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||||
|
|
||||||
@@ -326,7 +325,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!showHostList && Platform.OS !== "web" && (
|
{!showHostList && isNative && (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.setupHint}>
|
<Text style={styles.setupHint}>
|
||||||
You need the Paseo desktop app or server running on your computer first.
|
You need the Paseo desktop app or server running on your computer first.
|
||||||
|
|||||||
@@ -289,10 +289,7 @@ function WorkspaceHoverCardContent({
|
|||||||
<>
|
<>
|
||||||
<View style={styles.separator} />
|
<View style={styles.separator} />
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ hovered }) => [
|
style={({ hovered }) => [styles.checksSummaryRow, hovered && styles.listRowHovered]}
|
||||||
styles.checksSummaryRow,
|
|
||||||
hovered && styles.listRowHovered,
|
|
||||||
]}
|
|
||||||
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
|
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
|
||||||
>
|
>
|
||||||
{({ hovered }) => {
|
{({ hovered }) => {
|
||||||
@@ -333,10 +330,7 @@ function WorkspaceHoverCardContent({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ExternalLink size={12} color={theme.colors.foreground} />
|
||||||
size={12}
|
|
||||||
color={theme.colors.foreground}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
|||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
import {
|
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||||
requireWorkspaceExecutionAuthority,
|
|
||||||
} from "@/utils/workspace-execution";
|
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import type { ImageAttachment, MessagePayload } from "./message-input";
|
import type { ImageAttachment, MessagePayload } from "./message-input";
|
||||||
|
|
||||||
@@ -174,7 +172,9 @@ export function WorkspaceSetupDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const encodedImages = await encodeImages(images);
|
const encodedImages = await encodeImages(images);
|
||||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
const workspaceDirectory = requireWorkspaceExecutionAuthority({
|
||||||
|
workspace,
|
||||||
|
}).workspaceDirectory;
|
||||||
const agent = await connectedClient.createAgent({
|
const agent = await connectedClient.createAgent({
|
||||||
provider: composerState.selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
@@ -222,7 +222,6 @@ export function WorkspaceSetupDialog() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const workspaceTitle =
|
const workspaceTitle =
|
||||||
workspace?.name ||
|
workspace?.name ||
|
||||||
workspace?.projectDisplayName ||
|
workspace?.projectDisplayName ||
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export const FOOTER_HEIGHT = 75;
|
export const FOOTER_HEIGHT = 75;
|
||||||
|
|
||||||
@@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
|||||||
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||||
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||||
|
|
||||||
// Check if running in the Electron desktop runtime (any OS)
|
export {
|
||||||
function isElectronDesktopRuntime(): boolean {
|
getIsElectron as getIsElectronRuntime,
|
||||||
if (Platform.OS !== "web") return false;
|
getIsElectronMac as getIsElectronRuntimeMac,
|
||||||
return isElectronRuntime();
|
} from "./platform";
|
||||||
}
|
|
||||||
|
|
||||||
// Check if running in the Electron desktop runtime on macOS
|
|
||||||
function isElectronDesktopRuntimeMac(): boolean {
|
|
||||||
if (Platform.OS !== "web") return false;
|
|
||||||
return isElectronRuntimeMac();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
|
|
||||||
let _isElectronRuntimeMacCached: boolean | null = null;
|
|
||||||
let _isElectronRuntimeCached: boolean | null = null;
|
|
||||||
|
|
||||||
export function getIsElectronRuntimeMac(): boolean {
|
|
||||||
if (_isElectronRuntimeMacCached === true) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const result = isElectronDesktopRuntimeMac();
|
|
||||||
if (result) {
|
|
||||||
_isElectronRuntimeMacCached = true;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getIsElectronRuntime(): boolean {
|
|
||||||
if (_isElectronRuntimeCached === true) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const result = isElectronDesktopRuntime();
|
|
||||||
if (result) {
|
|
||||||
_isElectronRuntimeCached = true;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive hook — re-renders the component when the breakpoint changes.
|
* Reactive hook — re-renders the component when the breakpoint changes.
|
||||||
@@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean {
|
|||||||
// Keep that capability distinct from desktop-width layout so touch tablets
|
// Keep that capability distinct from desktop-width layout so touch tablets
|
||||||
// can use the desktop shell without entering web-only code paths.
|
// can use the desktop shell without entering web-only code paths.
|
||||||
export function supportsDesktopPaneSplits(): boolean {
|
export function supportsDesktopPaneSplits(): boolean {
|
||||||
return Platform.OS === "web";
|
return isWeb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Runtime environment constants
|
||||||
|
//
|
||||||
|
// These are the ONLY platform gates in the app. See CLAUDE.md for the
|
||||||
|
// decision matrix on when to use each one.
|
||||||
|
//
|
||||||
|
// Default is cross-platform. Gate only when you must:
|
||||||
|
// isWeb → DOM APIs (document, window, <div>, addEventListener)
|
||||||
|
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
|
||||||
|
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
|
||||||
|
//
|
||||||
|
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
|
||||||
|
// For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Browser or Electron — the JS runtime has access to the DOM. */
|
||||||
|
export const isWeb = Platform.OS === "web";
|
||||||
|
|
||||||
|
/** iOS or Android — the JS runtime is React Native. */
|
||||||
|
export const isNative = Platform.OS !== "web";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Electron detection (cached — only caches `true`, keeps checking if false
|
||||||
|
// because the desktop bridge may load after initial module evaluation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _isElectronCached: boolean | null = null;
|
||||||
|
let _isElectronMacCached: boolean | null = null;
|
||||||
|
|
||||||
|
/** Running inside the Electron desktop wrapper (any OS). */
|
||||||
|
export function getIsElectron(): boolean {
|
||||||
|
if (_isElectronCached === true) return true;
|
||||||
|
if (!isWeb) return false;
|
||||||
|
const result = isElectronRuntime();
|
||||||
|
if (result) _isElectronCached = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Running inside the Electron desktop wrapper on macOS. */
|
||||||
|
export function getIsElectronMac(): boolean {
|
||||||
|
if (_isElectronMacCached === true) return true;
|
||||||
|
if (!isWeb) return false;
|
||||||
|
const result = isElectronRuntimeMac();
|
||||||
|
if (result) _isElectronMacCached = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||||
@@ -55,6 +55,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
|
|||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -301,12 +302,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||||
const sendAgentMessageRef = useRef<
|
const sendAgentMessageRef = useRef<
|
||||||
((
|
| ((
|
||||||
agentId: string,
|
agentId: string,
|
||||||
message: string,
|
message: string,
|
||||||
images?: AttachmentMetadata[],
|
images?: AttachmentMetadata[],
|
||||||
attachments?: AgentAttachment[],
|
attachments?: AgentAttachment[],
|
||||||
) => Promise<void>) | null
|
) => Promise<void>)
|
||||||
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||||
@@ -562,7 +564,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
(awayMs: number) => {
|
(awayMs: number) => {
|
||||||
scheduleAuthoritativeRevalidation();
|
scheduleAuthoritativeRevalidation();
|
||||||
|
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
const agentId = session?.focusedAgentId;
|
const agentId = session?.focusedAgentId;
|
||||||
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
||||||
|
|||||||
@@ -154,10 +154,7 @@ describe("processTimelineResponse", () => {
|
|||||||
direction: "before",
|
direction: "before",
|
||||||
startSeq: 1,
|
startSeq: 1,
|
||||||
endSeq: 2,
|
endSeq: 2,
|
||||||
entries: [
|
entries: [makeTimelineEntry(1, "hello", "user_message"), makeTimelineEntry(2, "older")],
|
||||||
makeTimelineEntry(1, "hello", "user_message"),
|
|
||||||
makeTimelineEntry(2, "older"),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
export type DesktopPermissionKind = "notifications" | "microphone";
|
export type DesktopPermissionKind = "notifications" | "microphone";
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ type NavigatorLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function shouldShowDesktopPermissionSection(): boolean {
|
export function shouldShowDesktopPermissionSection(): boolean {
|
||||||
return Platform.OS === "web" && getDesktopHost() !== null;
|
return isWeb && getDesktopHost() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
||||||
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
|
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
|
||||||
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNavigatorLike(): NavigatorLike | null {
|
function getNavigatorLike(): NavigatorLike | null {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
|
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||||
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop notification status is only available on web runtime.",
|
detail: "Desktop notification status is only available on web runtime.",
|
||||||
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop microphone status is only available on web runtime.",
|
detail: "Desktop microphone status is only available on web runtime.",
|
||||||
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop notification requests are only available on web runtime.",
|
detail: "Desktop notification requests are only available on web runtime.",
|
||||||
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop microphone requests are only available on web runtime.",
|
detail: "Desktop microphone requests are only available on web runtime.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export interface DesktopAppUpdateCheckResult {
|
export interface DesktopAppUpdateCheckResult {
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
@@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowDesktopUpdateSection(): boolean {
|
export function shouldShowDesktopUpdateSection(): boolean {
|
||||||
return Platform.OS === "web" && isElectronRuntime();
|
return isWeb && isElectronRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import {
|
import {
|
||||||
shouldClearAgentAttention,
|
shouldClearAgentAttention,
|
||||||
type AgentAttentionClearTrigger,
|
type AgentAttentionClearTrigger,
|
||||||
} from "@/utils/agent-attention";
|
} from "@/utils/agent-attention";
|
||||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
|
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ export function useAgentAttentionClear({
|
|||||||
|
|
||||||
const appStateSubscription = AppState.addEventListener("change", updateVisibility);
|
const appStateSubscription = AppState.addEventListener("change", updateVisibility);
|
||||||
|
|
||||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
document.addEventListener("visibilitychange", updateVisibility);
|
document.addEventListener("visibilitychange", updateVisibility);
|
||||||
window.addEventListener("focus", updateVisibility);
|
window.addEventListener("focus", updateVisibility);
|
||||||
window.addEventListener("blur", updateVisibility);
|
window.addEventListener("blur", updateVisibility);
|
||||||
|
|||||||
@@ -1,12 +1,117 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { __private__ } from "./use-agent-form-state";
|
import { __private__ } from "./use-agent-form-state";
|
||||||
import {
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
type AgentProviderDefinition,
|
import type {
|
||||||
} from "@server/server/agent/provider-manifest";
|
AgentModelDefinition,
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex test provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [
|
||||||
|
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Claude test provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
||||||
|
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
||||||
|
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeProviderMap(
|
||||||
|
...definitions: AgentProviderDefinition[]
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||||
|
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
||||||
|
|
||||||
describe("useAgentFormState", () => {
|
describe("useAgentFormState", () => {
|
||||||
|
describe("buildProviderDefinitions", () => {
|
||||||
|
it("returns empty array when snapshot data is unavailable", () => {
|
||||||
|
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
||||||
|
expect(buildProviderDefinitions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds custom provider definitions from snapshot metadata", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "zai",
|
||||||
|
status: "ready",
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Anthropic Claude",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const definitions = buildProviderDefinitions(entries);
|
||||||
|
|
||||||
|
expect(definitions).toEqual([
|
||||||
|
{
|
||||||
|
id: "zai",
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Anthropic Claude",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Always Ask",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("__private__.combineInitialValues", () => {
|
describe("__private__.combineInitialValues", () => {
|
||||||
it("returns undefined when no initial values and no initial server id", () => {
|
it("returns undefined when no initial values and no initial server id", () => {
|
||||||
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
||||||
@@ -78,6 +183,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
|
codexProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
expect(resolved.model).toBe("");
|
||||||
@@ -106,6 +212,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
|
codexProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
@@ -134,6 +241,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
|
codexProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
@@ -161,6 +269,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
|
codexProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
@@ -188,6 +297,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
|
codexProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
@@ -215,6 +325,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
|
codexProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
@@ -256,6 +367,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
|
claudeProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.model).toBe("default");
|
expect(resolved.model).toBe("default");
|
||||||
@@ -263,11 +375,6 @@ describe("useAgentFormState", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves provider only from allowed provider map", () => {
|
it("resolves provider only from allowed provider map", () => {
|
||||||
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map(
|
|
||||||
(definition) => [definition.id as AgentProvider, definition],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{ provider: "codex" },
|
{ provider: "codex" },
|
||||||
@@ -289,7 +396,7 @@ describe("useAgentFormState", () => {
|
|||||||
workingDir: "",
|
workingDir: "",
|
||||||
},
|
},
|
||||||
new Set<string>(),
|
new Set<string>(),
|
||||||
allowedProviderMap,
|
claudeProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.provider).toBe("claude");
|
expect(resolved.provider).toBe("claude");
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
|
||||||
type AgentProviderDefinition,
|
|
||||||
} from "@server/server/agent/provider-manifest";
|
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -10,6 +7,7 @@ import type {
|
|||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
@@ -99,13 +97,8 @@ export type UseAgentFormStateResult = {
|
|||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
const DEFAULT_PROVIDER: AgentProvider = "claude";
|
||||||
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
|
||||||
allProviderDefinitions.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
const fallbackDefinition = allProviderDefinitions[0];
|
|
||||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
|
||||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
|
|
||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
@@ -180,7 +173,7 @@ function resolveFormState(
|
|||||||
userModified: UserModifiedFields,
|
userModified: UserModifiedFields,
|
||||||
currentState: FormState,
|
currentState: FormState,
|
||||||
validServerIds: Set<string>,
|
validServerIds: Set<string>,
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap,
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
||||||
): FormState {
|
): FormState {
|
||||||
// Start with current state - we only update non-user-modified fields
|
// Start with current state - we only update non-user-modified fields
|
||||||
const result = { ...currentState };
|
const result = { ...currentState };
|
||||||
@@ -374,10 +367,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
} = useProvidersSnapshot(formState.serverId);
|
} = useProvidersSnapshot(formState.serverId);
|
||||||
|
|
||||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
const snapshotProviderDefinitions = useMemo(() => {
|
const snapshotProviderDefinitions = useMemo(
|
||||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
() => buildProviderDefinitions(snapshotEntries),
|
||||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
[snapshotEntries],
|
||||||
}, [snapshotEntries]);
|
);
|
||||||
const snapshotProviderDefinitionMap = useMemo(
|
const snapshotProviderDefinitionMap = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(
|
new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
@@ -386,17 +379,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
[snapshotProviderDefinitions],
|
[snapshotProviderDefinitions],
|
||||||
);
|
);
|
||||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||||
|
if (!snapshotEntries?.length) {
|
||||||
|
return snapshotProviderDefinitionMap;
|
||||||
|
}
|
||||||
const readyProviders = new Set(
|
const readyProviders = new Set(
|
||||||
(snapshotEntries ?? [])
|
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
|
||||||
.filter((entry) => entry.status === "ready")
|
|
||||||
.map((entry) => entry.provider),
|
|
||||||
);
|
);
|
||||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
snapshotProviderDefinitions
|
snapshotProviderDefinitions
|
||||||
.filter((definition) => readyProviders.has(definition.id))
|
.filter((definition) => readyProviders.has(definition.id))
|
||||||
.map((definition) => [definition.id, definition]),
|
.map((definition) => [definition.id, definition]),
|
||||||
);
|
);
|
||||||
}, [snapshotEntries, snapshotProviderDefinitions]);
|
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
|
||||||
const snapshotAllProviderModels = useMemo(() => {
|
const snapshotAllProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
for (const entry of snapshotEntries ?? []) {
|
for (const entry of snapshotEntries ?? []) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import {
|
import {
|
||||||
@@ -10,13 +9,14 @@ import {
|
|||||||
rejectInitDeferred,
|
rejectInitDeferred,
|
||||||
} from "@/utils/agent-initialization";
|
} from "@/utils/agent-initialization";
|
||||||
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
|
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||||
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
||||||
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
||||||
|
|
||||||
function resolveInitialTimelineLimit(): number {
|
function resolveInitialTimelineLimit(): number {
|
||||||
return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
|
return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ function buildDraftComposerCommandConfig(input: {
|
|||||||
return {
|
return {
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
cwd,
|
cwd,
|
||||||
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
|
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
||||||
|
? { modeId: input.selectedMode }
|
||||||
|
: {}),
|
||||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||||
...(input.effectiveThinkingOptionId
|
...(input.effectiveThinkingOptionId
|
||||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
let current = getIsAppActivelyVisible();
|
let current = getIsAppActivelyVisible();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
@@ -29,7 +30,7 @@ export function useAppVisible(): boolean {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appStateSubscription = AppState.addEventListener("change", notify);
|
const appStateSubscription = AppState.addEventListener("change", notify);
|
||||||
|
|
||||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
document.addEventListener("visibilitychange", notify);
|
document.addEventListener("visibilitychange", notify);
|
||||||
window.addEventListener("focus", notify);
|
window.addEventListener("focus", notify);
|
||||||
window.addEventListener("blur", notify);
|
window.addEventListener("blur", notify);
|
||||||
@@ -37,7 +38,7 @@ export function useAppVisible(): boolean {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
appStateSubscription.remove();
|
appStateSubscription.remove();
|
||||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
document.removeEventListener("visibilitychange", notify);
|
document.removeEventListener("visibilitychange", notify);
|
||||||
window.removeEventListener("focus", notify);
|
window.removeEventListener("focus", notify);
|
||||||
window.removeEventListener("blur", notify);
|
window.removeEventListener("blur", notify);
|
||||||
|
|||||||
@@ -135,18 +135,8 @@ describe("useArchiveAgent", () => {
|
|||||||
|
|
||||||
it("can apply archived agent close results without invalidating cached lists", () => {
|
it("can apply archived agent close results without invalidating cached lists", () => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
useSessionStore
|
useSessionStore.getState().initializeSession("server-a", {} as DaemonClient);
|
||||||
.getState()
|
useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]]));
|
||||||
.initializeSession("server-a", {} as DaemonClient);
|
|
||||||
useSessionStore.getState().setAgents(
|
|
||||||
"server-a",
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
"agent-1",
|
|
||||||
makeAgent(),
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
|
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
|
||||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,7 +181,11 @@ function restoreArchivedAgentListCacheSnapshot(
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
snapshot: ArchivedAgentListCacheSnapshot,
|
snapshot: ArchivedAgentListCacheSnapshot,
|
||||||
): void {
|
): void {
|
||||||
restoreCachedListSnapshot(queryClient, ["sidebarAgentsList", serverId], snapshot.sidebarAgentsList);
|
restoreCachedListSnapshot(
|
||||||
|
queryClient,
|
||||||
|
["sidebarAgentsList", serverId],
|
||||||
|
snapshot.sidebarAgentsList,
|
||||||
|
);
|
||||||
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||||
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
||||||
@@ -32,7 +33,7 @@ export function useClientActivity({
|
|||||||
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
||||||
const lastImmediateHeartbeatAtRef = useRef<number>(0);
|
const lastImmediateHeartbeatAtRef = useRef<number>(0);
|
||||||
|
|
||||||
const deviceType = Platform.OS === "web" ? "web" : "mobile";
|
const deviceType = isWeb ? "web" : "mobile";
|
||||||
|
|
||||||
const recordUserActivity = useCallback(() => {
|
const recordUserActivity = useCallback(() => {
|
||||||
lastActivityAtRef.current = new Date();
|
lastActivityAtRef.current = new Date();
|
||||||
@@ -96,7 +97,7 @@ export function useClientActivity({
|
|||||||
|
|
||||||
// Track user activity on web for accurate staleness.
|
// Track user activity on web for accurate staleness.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
const handleUserActivity = () => {
|
const handleUserActivity = () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||||
type DesktopBadgeWorkspaceStatus,
|
type DesktopBadgeWorkspaceStatus,
|
||||||
} from "@/utils/desktop-badge-state";
|
} from "@/utils/desktop-badge-state";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
type FaviconStatus = "none" | "running" | "attention";
|
type FaviconStatus = "none" | "running" | "attention";
|
||||||
type ColorScheme = "dark" | "light";
|
type ColorScheme = "dark" | "light";
|
||||||
@@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSystemColorScheme(): ColorScheme {
|
function getSystemColorScheme(): ColorScheme {
|
||||||
if (
|
if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||||
Platform.OS !== "web" ||
|
|
||||||
typeof window === "undefined" ||
|
|
||||||
typeof window.matchMedia !== "function"
|
|
||||||
) {
|
|
||||||
return "dark";
|
return "dark";
|
||||||
}
|
}
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateMacDockBadge(count?: number) {
|
async function updateMacDockBadge(count?: number) {
|
||||||
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
|
if (isNative || !getIsElectronRuntimeMac()) return;
|
||||||
|
|
||||||
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
|
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
|
||||||
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
|
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
|
||||||
@@ -119,7 +115,7 @@ export function useFaviconStatus() {
|
|||||||
|
|
||||||
// Listen for system color scheme changes
|
// Listen for system color scheme changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || typeof window === "undefined") return;
|
if (isNative || typeof window === "undefined") return;
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
@@ -132,7 +128,7 @@ export function useFaviconStatus() {
|
|||||||
|
|
||||||
// Update favicon when agents or color scheme changes
|
// Update favicon when agents or color scheme changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
|
|
||||||
const status = deriveFaviconStatus(agents);
|
const status = deriveFaviconStatus(agents);
|
||||||
updateFavicon(status, colorScheme);
|
updateFavicon(status, colorScheme);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface UseFileDropZoneOptions {
|
interface UseFileDropZoneOptions {
|
||||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||||
@@ -14,7 +14,7 @@ interface UseFileDropZoneReturn {
|
|||||||
containerRef: React.RefObject<HTMLElement | null>;
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = isWeb;
|
||||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
openImagePathsWithDesktopDialog,
|
openImagePathsWithDesktopDialog,
|
||||||
type PickedImageAttachmentInput,
|
type PickedImageAttachmentInput,
|
||||||
} from "@/hooks/image-attachment-picker";
|
} from "@/hooks/image-attachment-picker";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface UseImageAttachmentPickerResult {
|
interface UseImageAttachmentPickerResult {
|
||||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||||
@@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
|||||||
isPickingRef.current = true;
|
isPickingRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Platform.OS === "web" && isElectronRuntime()) {
|
if (isWeb && isElectronRuntime()) {
|
||||||
const selectedPaths = await openImagePathsWithDesktopDialog();
|
const selectedPaths = await openImagePathsWithDesktopDialog();
|
||||||
if (selectedPaths.length === 0) {
|
if (selectedPaths.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { usePathname } from "expo-router";
|
|
||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import {
|
||||||
|
buildHostSettingsRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
@@ -26,6 +26,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
|||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
export function useKeyboardShortcuts({
|
export function useKeyboardShortcuts({
|
||||||
enabled,
|
enabled,
|
||||||
@@ -47,6 +48,7 @@ export function useKeyboardShortcuts({
|
|||||||
cycleTheme?: () => void;
|
cycleTheme?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
@@ -65,7 +67,7 @@ export function useKeyboardShortcuts({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
|
|
||||||
const isDesktopApp = getIsElectronRuntime();
|
const isDesktopApp = getIsElectronRuntime();
|
||||||
@@ -244,6 +246,16 @@ export function useKeyboardShortcuts({
|
|||||||
case "sidebar.toggle.left":
|
case "sidebar.toggle.left":
|
||||||
toggleAgentList();
|
toggleAgentList();
|
||||||
return true;
|
return true;
|
||||||
|
case "settings.toggle":
|
||||||
|
if (pathname.endsWith("/settings")) {
|
||||||
|
router.back();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!activeServerId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
router.push(buildHostSettingsRoute(activeServerId));
|
||||||
|
return true;
|
||||||
case "sidebar.toggle.both":
|
case "sidebar.toggle.both":
|
||||||
if (toggleBothSidebars) {
|
if (toggleBothSidebars) {
|
||||||
toggleBothSidebars();
|
toggleBothSidebars();
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ describe("openProjectDirectly", () => {
|
|||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
|
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
|
||||||
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
|
expect(
|
||||||
|
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []),
|
||||||
|
).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "1",
|
id: "1",
|
||||||
projectId: "1",
|
projectId: "1",
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useCallback } from "react";
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import {
|
||||||
|
normalizeWorkspaceDescriptor,
|
||||||
|
type WorkspaceDescriptor,
|
||||||
|
useSessionStore,
|
||||||
|
} from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import Constants from "expo-constants";
|
import Constants from "expo-constants";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const STORAGE_PREFIX = "@paseo:expo-push-token:";
|
const STORAGE_PREFIX = "@paseo:expo-push-token:";
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
|
|||||||
const lastSentTokenRef = useRef<string | null>(null);
|
const lastSentTokenRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const registerIfPossible = useCallback(async () => {
|
const registerIfPossible = useCallback(async () => {
|
||||||
if (Platform.OS === "web") return;
|
if (isWeb) return;
|
||||||
if (!client.isConnected) return;
|
if (!client.isConnected) return;
|
||||||
const token = tokenRef.current;
|
const token = tokenRef.current;
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
|
|||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") return;
|
if (isWeb) return;
|
||||||
|
|
||||||
const storageKey = `${STORAGE_PREFIX}${serverId}`;
|
const storageKey = `${STORAGE_PREFIX}${serverId}`;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
|
|||||||
|
|
||||||
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
|
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
|
||||||
return normalizeWorkspaceDescriptor(payload);
|
return normalizeWorkspaceDescriptor(payload);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarWorkspacesList(options?: {
|
export function useSidebarWorkspacesList(options?: {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type KeyboardActionId =
|
|||||||
| "sidebar.toggle.left"
|
| "sidebar.toggle.left"
|
||||||
| "sidebar.toggle.right"
|
| "sidebar.toggle.right"
|
||||||
| "sidebar.toggle.both"
|
| "sidebar.toggle.both"
|
||||||
|
| "settings.toggle"
|
||||||
| "command-center.toggle"
|
| "command-center.toggle"
|
||||||
| "shortcuts.dialog.toggle"
|
| "shortcuts.dialog.toggle"
|
||||||
| "workspace.terminal.new"
|
| "workspace.terminal.new"
|
||||||
|
|||||||
@@ -712,6 +712,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Settings toggle ---
|
||||||
|
{
|
||||||
|
id: "settings-toggle-cmd-comma-mac",
|
||||||
|
action: "settings.toggle",
|
||||||
|
combo: "Cmd+,",
|
||||||
|
when: { mac: true, commandCenter: false },
|
||||||
|
help: {
|
||||||
|
id: "toggle-settings",
|
||||||
|
section: "panels",
|
||||||
|
label: "Toggle settings",
|
||||||
|
keys: ["mod", ","],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings-toggle-ctrl-comma-non-mac",
|
||||||
|
action: "settings.toggle",
|
||||||
|
combo: "Ctrl+,",
|
||||||
|
when: { mac: false, commandCenter: false, terminal: false },
|
||||||
|
help: {
|
||||||
|
id: "toggle-settings",
|
||||||
|
section: "panels",
|
||||||
|
label: "Toggle settings",
|
||||||
|
keys: ["mod", ","],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// --- Focus mode ---
|
// --- Focus mode ---
|
||||||
{
|
{
|
||||||
id: "view-toggle-focus-cmd-shift-f-mac",
|
id: "view-toggle-focus-cmd-shift-f-mac",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ KEY_MAP["Digit"] = { code: "Digit" };
|
|||||||
KEY_MAP["\\"] = { code: "Backslash" };
|
KEY_MAP["\\"] = { code: "Backslash" };
|
||||||
KEY_MAP["["] = { code: "BracketLeft", key: "[" };
|
KEY_MAP["["] = { code: "BracketLeft", key: "[" };
|
||||||
KEY_MAP["]"] = { code: "BracketRight", key: "]" };
|
KEY_MAP["]"] = { code: "BracketRight", key: "]" };
|
||||||
|
KEY_MAP[","] = { code: "Comma", key: "," };
|
||||||
KEY_MAP["."] = { code: "Period", key: "." };
|
KEY_MAP["."] = { code: "Period", key: "." };
|
||||||
KEY_MAP["`"] = { code: "Backquote", key: "`" };
|
KEY_MAP["`"] = { code: "Backquote", key: "`" };
|
||||||
KEY_MAP["/"] = { code: "Slash" };
|
KEY_MAP["/"] = { code: "Slash" };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, Platform, Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
deriveRouteBottomAnchorIntent,
|
deriveRouteBottomAnchorIntent,
|
||||||
deriveRouteBottomAnchorRequest,
|
deriveRouteBottomAnchorRequest,
|
||||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -242,7 +243,7 @@ function AgentPanelBody({
|
|||||||
);
|
);
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
|
const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null;
|
||||||
return {
|
return {
|
||||||
serverId: agent?.serverId ?? null,
|
serverId: agent?.serverId ?? null,
|
||||||
id: agent?.id ?? null,
|
id: agent?.id ?? null,
|
||||||
@@ -690,7 +691,14 @@ function ChatAgentContent({
|
|||||||
projectPlacement,
|
projectPlacement,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
[agentState.serverId, agentState.id, agentState.status, agentState.cwd, agentState.lastError, projectPlacement],
|
[
|
||||||
|
agentState.serverId,
|
||||||
|
agentState.id,
|
||||||
|
agentState.status,
|
||||||
|
agentState.cwd,
|
||||||
|
agentState.lastError,
|
||||||
|
projectPlacement,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
|
const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
|
||||||
@@ -791,7 +799,7 @@ function ChatAgentContent({
|
|||||||
if (!isConnected || !hasSession) {
|
if (!isConnected || !hasSession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web";
|
const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
|
||||||
if (!shouldSyncOnEntry) {
|
if (!shouldSyncOnEntry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function useSetupPanelDescriptor(
|
|||||||
serverId: context.serverId,
|
serverId: context.serverId,
|
||||||
workspaceId: target.workspaceId,
|
workspaceId: target.workspaceId,
|
||||||
});
|
});
|
||||||
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
|
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
|
||||||
|
|
||||||
if (snapshot?.status === "completed") {
|
if (snapshot?.status === "completed") {
|
||||||
return {
|
return {
|
||||||
@@ -98,7 +98,7 @@ function SetupPanel() {
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId: target.workspaceId,
|
workspaceId: target.workspaceId,
|
||||||
});
|
});
|
||||||
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
|
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
|
||||||
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||||
|
|
||||||
// On mount, if no snapshot in the store, request cached status from server
|
// On mount, if no snapshot in the store, request cached status from server
|
||||||
@@ -160,13 +160,14 @@ function SetupPanel() {
|
|||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const statusLabel = snapshot?.status === "running"
|
const statusLabel =
|
||||||
? "Running"
|
snapshot?.status === "running"
|
||||||
: snapshot?.status === "completed"
|
? "Running"
|
||||||
? "Completed"
|
: snapshot?.status === "completed"
|
||||||
: snapshot?.status === "failed"
|
? "Completed"
|
||||||
? "Failed"
|
: snapshot?.status === "failed"
|
||||||
: "Waiting for setup output";
|
? "Failed"
|
||||||
|
: "Waiting for setup output";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -175,10 +176,9 @@ function SetupPanel() {
|
|||||||
testID="workspace-setup-panel"
|
testID="workspace-setup-panel"
|
||||||
>
|
>
|
||||||
{/* Hidden element for status — preserves testID for E2E */}
|
{/* Hidden element for status — preserves testID for E2E */}
|
||||||
<Text
|
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
|
||||||
style={styles.hiddenStatus}
|
{statusLabel}
|
||||||
testID="workspace-setup-status"
|
</Text>
|
||||||
>{statusLabel}</Text>
|
|
||||||
|
|
||||||
{isWaiting ? (
|
{isWaiting ? (
|
||||||
<View style={styles.waitingContainer}>
|
<View style={styles.waitingContainer}>
|
||||||
@@ -241,17 +241,12 @@ function SetupPanel() {
|
|||||||
{command.command}
|
{command.command}
|
||||||
</Text>
|
</Text>
|
||||||
{command.durationMs != null ? (
|
{command.durationMs != null ? (
|
||||||
<Text style={styles.commandDuration}>
|
<Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
|
||||||
{formatDuration(command.durationMs)}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
size={14}
|
size={14}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundMuted}
|
||||||
style={[
|
style={[styles.chevron, showDetail && styles.chevronExpanded]}
|
||||||
styles.chevron,
|
|
||||||
showDetail && styles.chevronExpanded,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{showDetail ? (
|
{showDetail ? (
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ function useTerminalPanelDescriptor(
|
|||||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||||
if (!client || !workspaceDirectory) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
workspaceAuthority.ok ? "Workspace execution directory not found" : workspaceAuthority.message,
|
workspaceAuthority.ok
|
||||||
|
? "Workspace execution directory not found"
|
||||||
|
: workspaceAuthority.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return client.listTerminals(workspaceDirectory);
|
return client.listTerminals(workspaceDirectory);
|
||||||
@@ -76,7 +78,9 @@ function TerminalPanel() {
|
|||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
||||||
<Text>
|
<Text>
|
||||||
{workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message}
|
{workspaceAuthority.ok
|
||||||
|
? "Workspace execution directory not found."
|
||||||
|
: workspaceAuthority.message}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native";
|
import { View, Text, Pressable, ScrollView, Keyboard } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -57,6 +57,8 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||||
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
@@ -67,10 +69,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
};
|
};
|
||||||
const PROVIDER_DEFINITION_MAP = new Map(
|
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined) {
|
function getParamValue(value: string | string[] | undefined) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -91,16 +89,14 @@ function getValidProvider(value: string | undefined) {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined;
|
return value as AgentProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
|
function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
|
||||||
if (!provider || !value) {
|
if (!provider || !value) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const definition = PROVIDER_DEFINITION_MAP.get(provider);
|
return value;
|
||||||
const modes = definition?.modes ?? [];
|
|
||||||
return modes.some((mode) => mode.id === value) ? value : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DraftAgentParams = {
|
type DraftAgentParams = {
|
||||||
@@ -205,22 +201,20 @@ function DraftAgentScreenContent({
|
|||||||
|
|
||||||
const draftIdRef = useRef(generateDraftId());
|
const draftIdRef = useRef(generateDraftId());
|
||||||
const draftAgentIdRef = useRef(generateDraftId());
|
const draftAgentIdRef = useRef(generateDraftId());
|
||||||
const draftInput = useAgentInputDraft(
|
const draftInput = useAgentInputDraft({
|
||||||
{
|
draftKey: ({ selectedServerId }) =>
|
||||||
draftKey: ({ selectedServerId }) =>
|
buildDraftStoreKey({
|
||||||
buildDraftStoreKey({
|
serverId: selectedServerId ?? "",
|
||||||
serverId: selectedServerId ?? "",
|
agentId: draftAgentIdRef.current,
|
||||||
agentId: draftAgentIdRef.current,
|
draftId: draftIdRef.current,
|
||||||
draftId: draftIdRef.current,
|
}),
|
||||||
}),
|
composer: {
|
||||||
composer: {
|
initialServerId: resolvedServerId ?? null,
|
||||||
initialServerId: resolvedServerId ?? null,
|
initialValues,
|
||||||
initialValues,
|
isVisible,
|
||||||
isVisible,
|
onlineServerIds,
|
||||||
onlineServerIds,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
const composerState = draftInput.composerState;
|
const composerState = draftInput.composerState;
|
||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Draft agent composer state is required");
|
throw new Error("Draft agent composer state is required");
|
||||||
@@ -636,7 +630,9 @@ function DraftAgentScreenContent({
|
|||||||
useCallback(
|
useCallback(
|
||||||
(state) =>
|
(state) =>
|
||||||
resolveWorkspaceIdByExecutionDirectory({
|
resolveWorkspaceIdByExecutionDirectory({
|
||||||
workspaces: selectedServerId ? state.sessions[selectedServerId]?.workspaces?.values() : null,
|
workspaces: selectedServerId
|
||||||
|
? state.sessions[selectedServerId]?.workspaces?.values()
|
||||||
|
: null,
|
||||||
workspaceDirectory: explorerCwd,
|
workspaceDirectory: explorerCwd,
|
||||||
}),
|
}),
|
||||||
[explorerCwd, selectedServerId],
|
[explorerCwd, selectedServerId],
|
||||||
@@ -802,7 +798,7 @@ function DraftAgentScreenContent({
|
|||||||
},
|
},
|
||||||
onBeforeSubmit: () => {
|
onBeforeSubmit: () => {
|
||||||
void persistFormPreferences();
|
void persistFormPreferences();
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
}
|
}
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
@@ -868,9 +864,7 @@ function DraftAgentScreenContent({
|
|||||||
cwd: resolvedWorkingDir,
|
cwd: resolvedWorkingDir,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
||||||
...(effectiveThinkingOptionId
|
...(effectiveThinkingOptionId ? { thinkingOptionId: effectiveThinkingOptionId } : {}),
|
||||||
? { thinkingOptionId: effectiveThinkingOptionId }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectiveBaseBranch = baseBranch.trim();
|
const effectiveBaseBranch = baseBranch.trim();
|
||||||
@@ -932,9 +926,7 @@ function DraftAgentScreenContent({
|
|||||||
},
|
},
|
||||||
onCreateSuccess: ({ result }) => {
|
onCreateSuccess: ({ result }) => {
|
||||||
if (!result.workspaceId) {
|
if (!result.workspaceId) {
|
||||||
router.replace(
|
router.replace(buildHostAgentDetailRoute(selectedServerId as string, result.id) as any);
|
||||||
buildHostAgentDetailRoute(selectedServerId as string, result.id) as any,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { Pressable, Text, View } from "react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CircleDot, ChevronDown, ExternalLink, GitBranch, GitPullRequest } from "lucide-react-native";
|
import {
|
||||||
|
CircleDot,
|
||||||
|
ChevronDown,
|
||||||
|
ExternalLink,
|
||||||
|
GitBranch,
|
||||||
|
GitPullRequest,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||||
@@ -26,9 +32,7 @@ import { encodeImages } from "@/utils/encode-images";
|
|||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||||
import {
|
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||||
requireWorkspaceExecutionAuthority,
|
|
||||||
} from "@/utils/workspace-execution";
|
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
@@ -123,14 +127,13 @@ export function NewWorkspaceScreen({
|
|||||||
|
|
||||||
const githubSearchOptions: ComboboxOptionType[] = useMemo(() => {
|
const githubSearchOptions: ComboboxOptionType[] = useMemo(() => {
|
||||||
const items = githubSearchResultsQuery.data?.items ?? [];
|
const items = githubSearchResultsQuery.data?.items ?? [];
|
||||||
return items
|
return items.map((item) => ({
|
||||||
.map((item) => ({
|
id: `${item.kind}:${item.number}`,
|
||||||
id: `${item.kind}:${item.number}`,
|
label: `#${item.number} ${item.title}`,
|
||||||
label: `#${item.number} ${item.title}`,
|
// Include search query so the Combobox's client-side filter doesn't
|
||||||
// Include search query so the Combobox's client-side filter doesn't
|
// discard server-searched results that match on body but not title.
|
||||||
// discard server-searched results that match on body but not title.
|
description: githubSearchQuery_trimmed,
|
||||||
description: githubSearchQuery_trimmed,
|
}));
|
||||||
}));
|
|
||||||
}, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]);
|
}, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]);
|
||||||
|
|
||||||
const handleSelectGithubItem = useCallback(
|
const handleSelectGithubItem = useCallback(
|
||||||
@@ -198,7 +201,9 @@ export function NewWorkspaceScreen({
|
|||||||
const initialPrompt = text.trim();
|
const initialPrompt = text.trim();
|
||||||
const encodedImages = await encodeImages(images);
|
const encodedImages = await encodeImages(images);
|
||||||
const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem);
|
const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem);
|
||||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
const workspaceDirectory = requireWorkspaceExecutionAuthority({
|
||||||
|
workspace,
|
||||||
|
}).workspaceDirectory;
|
||||||
const agent = await connectedClient.createAgent({
|
const agent = await connectedClient.createAgent({
|
||||||
provider: composerState.selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
@@ -234,7 +239,15 @@ export function NewWorkspaceScreen({
|
|||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[composerState, ensureWorkspace, selectedGithubItem, serverId, setAgents, toast, withConnectedClient],
|
[
|
||||||
|
composerState,
|
||||||
|
ensureWorkspace,
|
||||||
|
selectedGithubItem,
|
||||||
|
serverId,
|
||||||
|
setAgents,
|
||||||
|
toast,
|
||||||
|
withConnectedClient,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceTitle =
|
const workspaceTitle =
|
||||||
@@ -337,10 +350,7 @@ export function NewWorkspaceScreen({
|
|||||||
active={active}
|
active={active}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
leadingSlot={
|
leadingSlot={
|
||||||
<GitBranch
|
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
size={theme.iconSize.sm}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -366,7 +376,10 @@ export function NewWorkspaceScreen({
|
|||||||
<>
|
<>
|
||||||
<View style={styles.badgeIcon}>
|
<View style={styles.badgeIcon}>
|
||||||
{selectedGithubItem.kind === "pr" ? (
|
{selectedGithubItem.kind === "pr" ? (
|
||||||
<GitPullRequest size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<GitPullRequest
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CircleDot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<CircleDot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
)}
|
)}
|
||||||
@@ -400,7 +413,11 @@ export function NewWorkspaceScreen({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={githubSearchOptions}
|
options={githubSearchOptions}
|
||||||
value={selectedGithubItem ? `${selectedGithubItem.kind}:${selectedGithubItem.number}` : ""}
|
value={
|
||||||
|
selectedGithubItem
|
||||||
|
? `${selectedGithubItem.kind}:${selectedGithubItem.number}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
onSelect={handleSelectGithubItem}
|
onSelect={handleSelectGithubItem}
|
||||||
searchable
|
searchable
|
||||||
searchPlaceholder="Search issues and PRs..."
|
searchPlaceholder="Search issues and PRs..."
|
||||||
@@ -414,9 +431,7 @@ export function NewWorkspaceScreen({
|
|||||||
desktopPlacement="bottom-start"
|
desktopPlacement="bottom-start"
|
||||||
anchorRef={githubAnchorRef}
|
anchorRef={githubAnchorRef}
|
||||||
emptyText={
|
emptyText={
|
||||||
githubSearchResultsQuery.isFetching
|
githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."
|
||||||
? "Searching..."
|
|
||||||
: "No results found."
|
|
||||||
}
|
}
|
||||||
renderOption={({ option, selected, active, onPress }) => {
|
renderOption={({ option, selected, active, onPress }) => {
|
||||||
const item = (githubSearchResultsQuery.data?.items ?? []).find(
|
const item = (githubSearchResultsQuery.data?.items ?? []).find(
|
||||||
@@ -452,7 +467,9 @@ export function NewWorkspaceScreen({
|
|||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<ExternalLink
|
<ExternalLink
|
||||||
size={theme.iconSize.sm}
|
size={theme.iconSize.sm}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={
|
||||||
|
hovered ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { MutableRefObject, ComponentType } from "react";
|
import type { MutableRefObject, ComponentType } from "react";
|
||||||
import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native";
|
import { View, Text, ScrollView, Alert, Pressable } from "react-native";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -68,10 +68,11 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
|||||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Section definitions
|
// Section definitions
|
||||||
@@ -522,6 +523,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||||
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
|
||||||
const hasServer = routeServerId.length > 0;
|
const hasServer = routeServerId.length > 0;
|
||||||
|
|
||||||
@@ -557,7 +559,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
{providerDefinitions.map((def) => {
|
||||||
const entry = entries?.find((e) => e.provider === def.id);
|
const entry = entries?.find((e) => e.provider === def.id);
|
||||||
const status = entry?.status ?? "unavailable";
|
const status = entry?.status ?? "unavailable";
|
||||||
const ProviderIcon = getProviderIcon(def.id);
|
const ProviderIcon = getProviderIcon(def.id);
|
||||||
@@ -1672,22 +1674,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
|
|||||||
<View style={styles.hostHeaderRight}>
|
<View style={styles.hostHeaderRight}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile,
|
isWeb ? styles.statusPill : styles.statusPillMobile,
|
||||||
{ backgroundColor: statusPillBg },
|
{ backgroundColor: statusPillBg },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||||
{Platform.OS === "web" ? (
|
{isWeb ? (
|
||||||
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{connectionBadge ? (
|
{connectionBadge ? (
|
||||||
<View
|
<View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
|
||||||
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
|
|
||||||
>
|
|
||||||
{connectionBadge.icon}
|
{connectionBadge.icon}
|
||||||
{Platform.OS === "web" ? (
|
{isWeb ? (
|
||||||
<Text style={styles.connectionText} numberOfLines={1}>
|
<Text style={styles.connectionText} numberOfLines={1}>
|
||||||
{connectionBadge.text}
|
{connectionBadge.text}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { View, Text, Platform } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
function ShortcutSequence({
|
function ShortcutSequence({
|
||||||
chord,
|
chord,
|
||||||
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
if (capturingBindingId === null) return;
|
if (capturingBindingId === null) return;
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
|
|||||||
};
|
};
|
||||||
}, [setCapturingShortcut]);
|
}, [setCapturingShortcut]);
|
||||||
|
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<View style={settingsStyles.section}>
|
||||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
|
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
||||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type StartupSplashScreenProps = {
|
type StartupSplashScreenProps = {
|
||||||
bootstrapState?: {
|
bootstrapState?: {
|
||||||
@@ -42,7 +43,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
errorScrollView: {
|
errorScrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
@@ -145,7 +146,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
overflowWrap: "normal",
|
overflowWrap: "normal",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||||
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -110,6 +110,7 @@ function useMiddleClickClose(onClose: () => void) {
|
|||||||
const ref = useRef<View>(null);
|
const ref = useRef<View>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isNative) return;
|
||||||
const node = ref.current as unknown as HTMLElement | null;
|
const node = ref.current as unknown as HTMLElement | null;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
@@ -171,17 +172,16 @@ function TabChip({
|
|||||||
);
|
);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const isHighlighted = isActive || hovered || isCloseHovered;
|
const isHighlighted = isActive || hovered || isCloseHovered;
|
||||||
const closeButtonDragBlockers =
|
const closeButtonDragBlockers = isWeb
|
||||||
Platform.OS === "web"
|
? ({
|
||||||
? ({
|
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
event.stopPropagation?.();
|
||||||
event.stopPropagation?.();
|
},
|
||||||
},
|
onMouseDown: (event: { stopPropagation?: () => void }) => {
|
||||||
onMouseDown: (event: { stopPropagation?: () => void }) => {
|
event.stopPropagation?.();
|
||||||
event.stopPropagation?.();
|
},
|
||||||
},
|
} as const)
|
||||||
} as const)
|
: undefined;
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View ref={middleClickRef}>
|
<View ref={middleClickRef}>
|
||||||
@@ -196,7 +196,7 @@ function TabChip({
|
|||||||
enabledOnMobile={false}
|
enabledOnMobile={false}
|
||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
styles.tab,
|
styles.tab,
|
||||||
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
|
isWeb && isDragging && ({ cursor: "grabbing" } as const),
|
||||||
{
|
{
|
||||||
minWidth: resolvedTabWidth,
|
minWidth: resolvedTabWidth,
|
||||||
width: resolvedTabWidth,
|
width: resolvedTabWidth,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
import { Keyboard, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
@@ -17,6 +17,7 @@ import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
|||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
@@ -63,18 +64,16 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}),
|
}),
|
||||||
[draftId, serverId, tabId],
|
[draftId, serverId, tabId],
|
||||||
);
|
);
|
||||||
const draftInput = useAgentInputDraft(
|
const draftInput = useAgentInputDraft({
|
||||||
{
|
draftKey: draftStoreKey,
|
||||||
draftKey: draftStoreKey,
|
composer: {
|
||||||
composer: {
|
initialServerId: serverId,
|
||||||
initialServerId: serverId,
|
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
||||||
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
isVisible: true,
|
||||||
isVisible: true,
|
onlineServerIds: isConnected ? [serverId] : [],
|
||||||
onlineServerIds: isConnected ? [serverId] : [],
|
lockedWorkingDir: workspaceDirectory ?? undefined,
|
||||||
lockedWorkingDir: workspaceDirectory ?? undefined,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
const composerState = draftInput.composerState;
|
const composerState = draftInput.composerState;
|
||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Workspace draft composer state is required");
|
throw new Error("Workspace draft composer state is required");
|
||||||
@@ -112,7 +111,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
},
|
},
|
||||||
onBeforeSubmit: () => {
|
onBeforeSubmit: () => {
|
||||||
void composerState.persistFormPreferences();
|
void composerState.persistFormPreferences();
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
}
|
}
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Check, ChevronDown } from "lucide-react-native";
|
import { Check, ChevronDown } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -15,6 +15,7 @@ import { useToast } from "@/contexts/toast-context";
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface WorkspaceOpenInEditorButtonProps {
|
interface WorkspaceOpenInEditorButtonProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
|
|||||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||||
|
|
||||||
const shouldLoadEditors =
|
const shouldLoadEditors =
|
||||||
Platform.OS === "web" &&
|
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||||
Boolean(client && isConnected) &&
|
|
||||||
cwd.trim().length > 0 &&
|
|
||||||
isAbsolutePath(cwd);
|
|
||||||
|
|
||||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||||
queryKey: ["available-editors", serverId],
|
queryKey: ["available-editors", serverId],
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import {
|
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
BackHandler,
|
|
||||||
Keyboard,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
@@ -125,6 +117,7 @@ import {
|
|||||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||||
@@ -263,7 +256,7 @@ function WorkspaceDocumentTitleEffect({
|
|||||||
titleState: "ready" | "loading";
|
titleState: "ready" | "loading";
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || typeof document === "undefined") {
|
if (isNative || typeof document === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolvedLabel = label.trim();
|
const resolvedLabel = label.trim();
|
||||||
@@ -659,9 +652,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: terminalsQueryKey,
|
queryKey: terminalsQueryKey,
|
||||||
enabled:
|
enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory),
|
||||||
Boolean(client && isConnected) &&
|
|
||||||
Boolean(workspaceDirectory),
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client || !workspaceDirectory) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
@@ -763,9 +754,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
||||||
),
|
),
|
||||||
enabled:
|
enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory),
|
||||||
Boolean(client && isConnected) &&
|
|
||||||
Boolean(workspaceDirectory),
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client || !workspaceDirectory) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
@@ -840,7 +829,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web" || !isExplorerOpen) {
|
if (isWeb || !isExplorerOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +857,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
||||||
);
|
);
|
||||||
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
|
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
|
||||||
persistenceKey ? state.snapshots[persistenceKey] ?? null : null,
|
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
|
||||||
);
|
);
|
||||||
const uiTabs = useMemo(
|
const uiTabs = useMemo(
|
||||||
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
||||||
@@ -880,7 +869,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||||
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
|
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
|
||||||
const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent);
|
const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore(
|
||||||
|
(state) => state.convertDraftToAgent,
|
||||||
|
);
|
||||||
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
|
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
|
||||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||||
@@ -1079,8 +1070,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const hasSetupTab = useMemo(
|
const hasSetupTab = useMemo(
|
||||||
() =>
|
() =>
|
||||||
uiTabs.some(
|
uiTabs.some(
|
||||||
(tab) =>
|
(tab) => tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId,
|
||||||
tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId,
|
|
||||||
),
|
),
|
||||||
[normalizedWorkspaceId, uiTabs],
|
[normalizedWorkspaceId, uiTabs],
|
||||||
);
|
);
|
||||||
@@ -1350,7 +1340,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: "Archive running agent?",
|
title: "Archive running agent?",
|
||||||
message: "This agent is still running. Archiving it will stop the agent and close the tab.",
|
message:
|
||||||
|
"This agent is still running. Archiving it will stop the agent and close the tab.",
|
||||||
confirmLabel: "Archive",
|
confirmLabel: "Archive",
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: "Cancel",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
@@ -1369,7 +1360,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void archiveAgent({ serverId: normalizedServerId, agentId });
|
// Errors (e.g. timeout) are handled by the mutation's onSettled callback
|
||||||
|
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
||||||
@@ -1803,7 +1795,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||||
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
|
if (isNative || typeof document === "undefined" || activeTabDescriptor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.title = "Workspace";
|
document.title = "Workspace";
|
||||||
@@ -2038,7 +2030,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
|
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
|
||||||
{Platform.OS === "web" && activeTabDescriptor ? (
|
{isWeb && activeTabDescriptor ? (
|
||||||
<WorkspaceTabPresentationResolver
|
<WorkspaceTabPresentationResolver
|
||||||
tab={activeTabDescriptor}
|
tab={activeTabDescriptor}
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
@@ -2377,8 +2369,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(!isFocusModeEnabled || isMobile) && (
|
{(!isFocusModeEnabled || isMobile) &&
|
||||||
workspaceDirectory ? (
|
(workspaceDirectory ? (
|
||||||
<ExplorerSidebar
|
<ExplorerSidebar
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
workspaceId={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
@@ -2386,8 +2378,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
isGit={isGitCheckout}
|
isGit={isGitCheckout}
|
||||||
onOpenFile={handleOpenFileFromExplorer}
|
onOpenFile={handleOpenFileFromExplorer}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null)}
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { type ReactElement } from "react";
|
import { type ReactElement } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { ChevronDown, ExternalLink, Globe, LoaderCircle, Play, SquareTerminal } from "lucide-react-native";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ExternalLink,
|
||||||
|
Globe,
|
||||||
|
LoaderCircle,
|
||||||
|
Play,
|
||||||
|
SquareTerminal,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -58,10 +65,9 @@ export function WorkspaceScriptsButton({
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
onError: (error, scriptName) => {
|
onError: (error, scriptName) => {
|
||||||
toast.show(
|
toast.show(error instanceof Error ? error.message : `Failed to start ${scriptName}`, {
|
||||||
error instanceof Error ? error.message : `Failed to start ${scriptName}`,
|
variant: "error",
|
||||||
{ variant: "error" },
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,9 +94,7 @@ export function WorkspaceScriptsButton({
|
|||||||
<Play
|
<Play
|
||||||
size={14}
|
size={14}
|
||||||
color={
|
color={
|
||||||
hasAnyRunning
|
hasAnyRunning ? theme.colors.palette.blue[500] : theme.colors.foregroundMuted
|
||||||
? theme.colors.palette.blue[500]
|
|
||||||
: theme.colors.foregroundMuted
|
|
||||||
}
|
}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { File as FSFile, Paths } from "expo-file-system";
|
import { File as FSFile, Paths } from "expo-file-system";
|
||||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
percent: number;
|
percent: number;
|
||||||
@@ -97,10 +97,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
|||||||
const downloadUrl = buildDownloadUrl(
|
const downloadUrl = buildDownloadUrl(
|
||||||
downloadTarget.baseUrl,
|
downloadTarget.baseUrl,
|
||||||
tokenResponse.token,
|
tokenResponse.token,
|
||||||
Platform.OS === "web" ? downloadTarget.authCredentials : null,
|
isWeb ? downloadTarget.authCredentials : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
triggerBrowserDownload(downloadUrl, resolvedFileName);
|
triggerBrowserDownload(downloadUrl, resolvedFileName);
|
||||||
get().completeDownload(id);
|
get().completeDownload(id);
|
||||||
return;
|
return;
|
||||||
@@ -153,7 +153,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to download file.";
|
const message = error instanceof Error ? error.message : "Failed to download file.";
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
console.warn("[DownloadStore] Download failed:", message);
|
console.warn("[DownloadStore] Download failed:", message);
|
||||||
get().failDownload(id, message);
|
get().failDownload(id, message);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user