mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a298b8f70 | ||
|
|
bf1d5d2ab2 | ||
|
|
8d1def4615 | ||
|
|
7ad4482096 | ||
|
|
7ef47e9193 | ||
|
|
bdb300b919 | ||
|
|
0d0a8a791a | ||
|
|
4806cd9a51 | ||
|
|
61201d68a6 | ||
|
|
6dfcb4daa9 | ||
|
|
95e112c185 | ||
|
|
6f28027687 | ||
|
|
8122d501b0 | ||
|
|
8023e1f0c3 | ||
|
|
a2d652d8d8 | ||
|
|
bf76915884 | ||
|
|
f0c59ec534 | ||
|
|
8aa1833e2b | ||
|
|
b3d5401295 | ||
|
|
8008ea69dd | ||
|
|
42aaa4e418 | ||
|
|
0f7fa55a0f | ||
|
|
964bf3e13f | ||
|
|
e6ff15a1e0 |
@@ -76,6 +76,31 @@ jobs:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
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).
|
||||
- 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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-H+hYTcnYBby1aL7kaxA9Y8fX/aW4iAPc5zeMDV8RCGk=";
|
||||
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
||||
|
||||
# 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).
|
||||
|
||||
Generated
+47
-19
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.54",
|
||||
"@getpaseo/highlight": "0.1.54",
|
||||
"@getpaseo/server": "0.1.54",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35056,11 +35056,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.54",
|
||||
"@getpaseo/server": "0.1.54",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35101,11 +35101,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.54",
|
||||
"@getpaseo/server": "0.1.54",
|
||||
"@getpaseo/cli": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35139,7 +35139,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35340,7 +35340,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35366,7 +35366,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35382,14 +35382,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.54",
|
||||
"@getpaseo/relay": "0.1.54",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35406,6 +35406,7 @@
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"pi-acp": "^0.0.24",
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
@@ -35681,6 +35682,21 @@
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
@@ -35778,6 +35794,18 @@
|
||||
"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": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
@@ -35789,7 +35817,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.54",
|
||||
"@getpaseo/highlight": "0.1.54",
|
||||
"@getpaseo/server": "0.1.54",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -90,6 +91,22 @@ export type HostRuntimeBootstrapState = {
|
||||
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>({
|
||||
phase: "starting-daemon",
|
||||
error: null,
|
||||
@@ -101,7 +118,7 @@ function PushNotificationRouter() {
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -390,10 +407,10 @@ function AppContainer({
|
||||
const screenW = UnistylesRuntime.screen.width;
|
||||
const screenH = UnistylesRuntime.screen.height;
|
||||
const isElectron = getIsElectronRuntime();
|
||||
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
|
||||
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
|
||||
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
|
||||
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
|
||||
const windowW = isWeb ? window.innerWidth : undefined;
|
||||
const windowH = isWeb ? window.innerHeight : undefined;
|
||||
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
||||
const ua = isWeb ? navigator.userAgent : undefined;
|
||||
|
||||
console.log(
|
||||
"[layout-debug]",
|
||||
@@ -577,7 +594,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading || Platform.OS !== "web") {
|
||||
if (settingsLoading || isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -787,7 +804,14 @@ function RootStack() {
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</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]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
@@ -88,7 +88,7 @@ function HostWorkspaceLayoutContent() {
|
||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||
// skips search params). Strip ?open from the browser URL directly so the
|
||||
// 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);
|
||||
if (url.searchParams.has("open")) {
|
||||
url.searchParams.delete("open");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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 { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
|
||||
}, [router, source, sourceServerId, targetServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") return;
|
||||
if (isWeb) return;
|
||||
if (permission && permission.granted) return;
|
||||
void requestPermission().catch(() => undefined);
|
||||
}, [permission, requestPermission]);
|
||||
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
|
||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<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 type { AttachmentStore } from "@/attachments/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
if (isElectronRuntime()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopOverlay: {
|
||||
@@ -216,7 +217,7 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
|
||||
// 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;
|
||||
return createPortal(desktopContent, getOverlayRoot());
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
{isNative ? (
|
||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
|
||||
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
|
||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
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 { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
import { baseColors } from "@/styles/theme";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||
ShieldCheck,
|
||||
@@ -168,7 +169,7 @@ export function SelectField({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: unknown) => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
const key = getWebKey(event);
|
||||
if (key === "Enter" || key === " ") {
|
||||
preventWebDefault(event);
|
||||
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: unknown) => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
const key = getWebKey(event);
|
||||
if (key === "Enter" || key === " ") {
|
||||
preventWebDefault(event);
|
||||
@@ -556,7 +557,11 @@ export function AgentConfigRow({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||
|
||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
||||
const selectedModeVisuals = getModeVisuals(
|
||||
selectedProvider,
|
||||
effectiveSelectedMode,
|
||||
providerDefinitions,
|
||||
);
|
||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||
|
||||
|
||||
@@ -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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
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 { submitAgentInput } from "@/components/agent-input-submit";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -118,7 +119,7 @@ export function AgentInputArea({
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
||||
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 client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -156,7 +157,7 @@ export function AgentInputArea({
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
|
||||
const isDesktopWebBreakpoint = isWeb && !isMobile;
|
||||
const messagePlaceholder = isDesktopWebBreakpoint
|
||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||
@@ -217,7 +218,7 @@ export function AgentInputArea({
|
||||
}, [addImages, onAddImages]);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
focusWithRetries({
|
||||
focus: () => messageInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
@@ -430,7 +431,7 @@ export function AgentInputArea({
|
||||
case "message-input.dictation-confirm":
|
||||
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
||||
case "message-input.focus":
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
messageInputRef.current?.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -258,7 +258,8 @@ export function AgentList({
|
||||
if (!actionAgent || !actionClient) {
|
||||
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);
|
||||
}, [actionAgent, actionClient]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
@@ -40,7 +41,6 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -59,10 +60,6 @@ type StatusOption = {
|
||||
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string;
|
||||
providerOptions?: StatusOption[];
|
||||
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions?: AgentProviderDefinition[];
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
@@ -222,7 +219,6 @@ function ControlledStatusBar({
|
||||
onModelSelectorOpen,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
||||
|
||||
@@ -252,7 +248,9 @@ function ControlledStatusBar({
|
||||
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 modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
@@ -300,9 +298,7 @@ function ControlledStatusBar({
|
||||
);
|
||||
return map;
|
||||
}, [modelOptions, provider]);
|
||||
const effectiveProviderDefinitions =
|
||||
providerDefinitions ??
|
||||
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
||||
const effectiveProviderDefinitions = providerDefinitions;
|
||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
@@ -322,7 +318,7 @@ function ControlledStatusBar({
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const visuals = getModeVisuals(provider, option.id);
|
||||
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
|
||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||
return (
|
||||
<ComboboxItem
|
||||
@@ -334,7 +330,7 @@ function ControlledStatusBar({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[provider, theme.colors.foreground],
|
||||
[provider, providerDefinitions, theme.colors.foreground],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
@@ -356,7 +352,7 @@ function ControlledStatusBar({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
{platformIsWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<>
|
||||
@@ -745,7 +741,7 @@ function ControlledStatusBar({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{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;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -896,9 +892,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
const models = snapshotModels;
|
||||
|
||||
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] : [];
|
||||
}, [agent?.provider]);
|
||||
}, [agent?.provider, snapshotEntries]);
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
@@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({
|
||||
onModelSelectorOpen,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
@@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||
|
||||
if (isWeb) {
|
||||
if (platformIsWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
@@ -1129,6 +1126,7 @@ export function DraftAgentStatusBar({
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
WORKING_INDICATOR_CYCLE_MS,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "@/utils/working-indicator";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return buildAgentStreamRenderModel({
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
platform: Platform.OS === "web" ? "web" : "native",
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
|
||||
@@ -4,17 +4,17 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
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 { getProviderIcon } from "@/components/provider-icons";
|
||||
@@ -349,7 +349,7 @@ function ProviderSearchInput({
|
||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
||||
if (autoFocus && platformIsWeb && inputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
@@ -363,7 +363,7 @@ function ProviderSearchInput({
|
||||
<InputComponent
|
||||
ref={inputRef as any}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
|
||||
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
|
||||
placeholder="Search models..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
||||
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -591,7 +590,7 @@ export function CombinedModelSelector({
|
||||
}, [selectedModelLabel, selectedProviderLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) {
|
||||
if (platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -605,7 +604,7 @@ export function CombinedModelSelector({
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, isWeb]);
|
||||
}, [isOpen, platformIsWeb]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -668,7 +667,7 @@ export function CombinedModelSelector({
|
||||
<ProviderSearchInput
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
autoFocus={platformIsWeb}
|
||||
/>
|
||||
</View>
|
||||
) : undefined
|
||||
|
||||
@@ -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 { Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||
return `${agent.serverId}:${agent.id}`;
|
||||
@@ -90,7 +91,7 @@ export function CommandCenter() {
|
||||
}
|
||||
}, [activeIndex, open]);
|
||||
|
||||
if (Platform.OS !== "web" || !open) return null;
|
||||
if (isNative || !open) return null;
|
||||
|
||||
const actionItems = items.filter((item) => item.kind === "action");
|
||||
const agentItems = items.filter((item) => item.kind === "agent");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function TitlebarDragRegion() {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||
if (isNative || !getIsElectronRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
|
||||
interface DiffViewerProps {
|
||||
diffLines: DiffLine[];
|
||||
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
|
||||
|
||||
// Mobile: full-screen overlay with gesture.
|
||||
// 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
|
||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[
|
||||
styles.resizeHandle,
|
||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
||||
]}
|
||||
/>
|
||||
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||
</GestureDetector>
|
||||
|
||||
<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 Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "./message-input";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: React.ReactNode;
|
||||
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
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 { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
@@ -91,7 +91,7 @@ export function FileExplorerPane({
|
||||
}: FileExplorerPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
type HighlightStyle,
|
||||
} from "@getpaseo/highlight";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -243,7 +243,7 @@ export function FilePane({
|
||||
filePath: string;
|
||||
}) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
FlatList,
|
||||
Platform,
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
@@ -33,11 +32,15 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
useCheckoutDiffQuery,
|
||||
type ParsedDiffFile,
|
||||
type DiffLine,
|
||||
type HighlightToken,
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import {
|
||||
useCheckoutDiffMetadataQuery,
|
||||
type DiffFileMetadata,
|
||||
} from "@/hooks/use-checkout-diff-metadata-query";
|
||||
import { useFileHunks } from "@/hooks/use-file-hunks";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
@@ -79,6 +82,7 @@ import {
|
||||
formatDiffGutterText,
|
||||
hasVisibleDiffTokens,
|
||||
} from "@/utils/diff-rendering";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
@@ -99,7 +103,7 @@ type WrappedWebTextStyle = TextStyle & {
|
||||
};
|
||||
|
||||
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return undefined;
|
||||
}
|
||||
return wrapLines
|
||||
@@ -130,8 +134,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffFileHeaderFields {
|
||||
path: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeaderFields;
|
||||
isExpanded: boolean;
|
||||
onToggle: (path: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
@@ -453,7 +465,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
}}
|
||||
onPressOut={(event) => {
|
||||
if (
|
||||
Platform.OS !== "web" &&
|
||||
isNative &&
|
||||
!pressHandledRef.current &&
|
||||
layoutYRef.current === 0 &&
|
||||
pressInRef.current
|
||||
@@ -617,6 +629,109 @@ function DiffFileBody({
|
||||
);
|
||||
}
|
||||
|
||||
interface LazyDiffFileBodyProps {
|
||||
file: DiffFileMetadata;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
onBodyHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function LazyDiffFileBody({
|
||||
file,
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
layout,
|
||||
wrapLines,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
}: LazyDiffFileBodyProps) {
|
||||
const {
|
||||
file: resolvedFile,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useFileHunks({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (file.status === "binary" || file.status === "too_large") {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !resolvedFile) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{error instanceof Error ? error.message : "Failed to load diff"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DiffFileBody
|
||||
file={resolvedFile}
|
||||
layout={layout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={onBodyHeightChange}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GitDiffPaneProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
@@ -625,15 +740,15 @@ interface GitDiffPaneProps {
|
||||
}
|
||||
|
||||
type DiffFlatItem =
|
||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
||||
| { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
const canUseSplitLayout = isWeb && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
@@ -687,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
} = useCheckoutDiffQuery({
|
||||
} = useCheckoutDiffMetadataQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
@@ -788,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
stickyIndices.push(items.length - 1);
|
||||
}
|
||||
if (isExpanded) {
|
||||
items.push({ type: "body", file, fileIndex: i });
|
||||
items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
|
||||
}
|
||||
}
|
||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||
@@ -1068,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiffFileBody
|
||||
<LazyDiffFileBody
|
||||
file={item.file}
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
mode={diffMode}
|
||||
baseRef={baseRef}
|
||||
ignoreWhitespace={changesPreferences.hideWhitespace}
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
@@ -1078,10 +1198,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
},
|
||||
[
|
||||
baseRef,
|
||||
changesPreferences.hideWhitespace,
|
||||
cwd,
|
||||
diffMode,
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
handleToggleExpanded,
|
||||
serverId,
|
||||
wrapLines,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Platform,
|
||||
Text,
|
||||
View,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface HeaderToggleButtonState {
|
||||
hovered: boolean;
|
||||
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
|
||||
: undefined;
|
||||
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
||||
const ariaExpandedProps =
|
||||
Platform.OS === "web" && typeof expandedState === "boolean"
|
||||
isWeb && typeof expandedState === "boolean"
|
||||
? ({ "aria-expanded": expandedState } as any)
|
||||
: null;
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
View,
|
||||
Pressable,
|
||||
Text,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
@@ -527,7 +527,7 @@ function MobileSidebar({
|
||||
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 (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -833,12 +833,7 @@ function DesktopSidebar({
|
||||
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[
|
||||
styles.resizeHandle,
|
||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
||||
]}
|
||||
/>
|
||||
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
TextInputKeyPressEventData,
|
||||
TextInputSelectionChangeEventData,
|
||||
Image,
|
||||
Platform,
|
||||
BackHandler,
|
||||
} from "react-native";
|
||||
import {
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
markScrollInvestigationEvent,
|
||||
markScrollInvestigationRender,
|
||||
} from "@/utils/scroll-jank-investigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export type ImageAttachment = AttachmentMetadata;
|
||||
|
||||
@@ -115,13 +115,15 @@ export interface MessageInputRef {
|
||||
|
||||
const MIN_INPUT_HEIGHT = 30;
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
TextInputKeyPressEventData & {
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -222,7 +224,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
ref,
|
||||
) {
|
||||
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"}`;
|
||||
markScrollInvestigationRender(investigationComponentId);
|
||||
const toast = useToast();
|
||||
@@ -293,7 +295,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
return false;
|
||||
},
|
||||
getNativeElement: () => {
|
||||
if (!IS_WEB) return null;
|
||||
if (!isWeb) return null;
|
||||
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
|
||||
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
|
||||
return native instanceof HTMLElement ? native : null;
|
||||
@@ -328,7 +330,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !autoFocus) return;
|
||||
if (!isWeb || !autoFocus) return;
|
||||
return focusWithRetries({
|
||||
focus: () => textInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
@@ -371,7 +373,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onChangeText(nextValue);
|
||||
}
|
||||
|
||||
if (IS_WEB && typeof requestAnimationFrame === "function") {
|
||||
if (isWeb && typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
measureWebInputHeight("dictation");
|
||||
});
|
||||
@@ -627,13 +629,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||
}
|
||||
}, [getWebTextArea]);
|
||||
|
||||
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 => {
|
||||
@@ -647,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !onAddImages) {
|
||||
if (!isWeb || !onAddImages) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -700,7 +702,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || typeof ResizeObserver === "undefined") {
|
||||
if (!isWeb || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -754,7 +756,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}, [getWebElement, getWebTextArea]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
}
|
||||
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
|
||||
@@ -792,7 +794,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}, [getWebTextArea]);
|
||||
|
||||
function measureWebInputHeight(source: string): boolean {
|
||||
if (!IS_WEB) return false;
|
||||
if (!isWeb) return false;
|
||||
const textarea = getWebTextArea();
|
||||
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
||||
const scrollHeight = textarea.scrollHeight ?? 0;
|
||||
@@ -846,7 +848,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||
) {
|
||||
const contentHeight = event.nativeEvent.contentSize.height;
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
logWebStickyBottom("composer_content_size_change", {
|
||||
reportedHeight: contentHeight,
|
||||
});
|
||||
@@ -866,7 +868,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||
const start = event.nativeEvent.selection?.start ?? 0;
|
||||
const end = event.nativeEvent.selection?.end ?? start;
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
const textarea = getWebTextArea();
|
||||
logWebStickyBottom("composer_selection_changed", {
|
||||
now: getDebugNow(),
|
||||
@@ -880,12 +882,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onSelectionChangeCallback?.({ start, end });
|
||||
}
|
||||
|
||||
const shouldHandleDesktopSubmit = IS_WEB;
|
||||
const shouldHandleDesktopSubmit = isWeb;
|
||||
|
||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
markScrollInvestigationEvent(investigationComponentId, "keyPress");
|
||||
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)
|
||||
if (onKeyPressCallback) {
|
||||
const handled = onKeyPressCallback({
|
||||
@@ -935,7 +942,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
(nextValue: string) => {
|
||||
markScrollInvestigationEvent(investigationComponentId, "inputChange");
|
||||
onChangeText(nextValue);
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
logWebStickyBottom("composer_text_changed", {
|
||||
valueLength: nextValue.length,
|
||||
lineCount: nextValue.split("\n").length,
|
||||
@@ -966,7 +973,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<View
|
||||
style={[
|
||||
styles.removeImageButton,
|
||||
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
|
||||
(hovered || !isWeb) && styles.removeImageButtonVisible,
|
||||
]}
|
||||
>
|
||||
<X size={theme.iconSize.md} color="white" />
|
||||
@@ -998,7 +1005,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}}
|
||||
style={[
|
||||
styles.textInput,
|
||||
IS_WEB
|
||||
isWeb
|
||||
? {
|
||||
height: inputHeight,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
@@ -1010,12 +1017,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
},
|
||||
]}
|
||||
multiline
|
||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={IS_WEB && autoFocus}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
{inputScrollbar}
|
||||
</View>
|
||||
@@ -1206,7 +1213,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "border-color",
|
||||
transitionDuration: "200ms",
|
||||
@@ -1225,7 +1232,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
cursor: "pointer",
|
||||
}
|
||||
@@ -1250,7 +1257,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
opacity: 0,
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "opacity",
|
||||
transitionDuration: "150ms",
|
||||
@@ -1269,7 +1276,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
outlineStyle: "none" as const,
|
||||
outlineWidth: 0,
|
||||
@@ -1290,7 +1297,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
rightButtonGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
||||
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
|
||||
},
|
||||
attachButton: {
|
||||
width: 28,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import * as React from "react";
|
||||
import {
|
||||
@@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
interface UserMessageProps {
|
||||
message: string;
|
||||
@@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
|
||||
type ScrollAxis = "x" | "y";
|
||||
|
||||
function ensureWebToolCallShimmerKeyframes() {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
if (typeof document === "undefined") {
|
||||
@@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [messageHovered, setMessageHovered] = useState(false);
|
||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
||||
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -361,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
|
||||
>
|
||||
<Pressable
|
||||
style={userMessageStylesheet.content}
|
||||
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
|
||||
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
|
||||
onHoverIn={() => setMessageHovered(true)}
|
||||
onHoverOut={() => setMessageHovered(false)}
|
||||
>
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
{hasImages ? (
|
||||
@@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
userSelect: Platform.OS === "web" ? "text" : "auto",
|
||||
userSelect: isWeb ? "text" : "auto",
|
||||
},
|
||||
imageFrame: {
|
||||
width: "100%",
|
||||
@@ -657,7 +658,7 @@ function MarkdownLink({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return (
|
||||
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
|
||||
{children}
|
||||
@@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleCopy}
|
||||
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
|
||||
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
|
||||
onHoverIn={() => onHoverChange?.(true)}
|
||||
onHoverOut={() => onHoverChange?.(false)}
|
||||
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
@@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||
selectable={Platform.OS === "web" ? undefined : false}
|
||||
selectable={isWeb ? undefined : false}
|
||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||
>
|
||||
{content}
|
||||
@@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
32,
|
||||
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
|
||||
);
|
||||
const isWebShimmer = isLoading && Platform.OS === "web";
|
||||
const isWebShimmer = isLoading && isWeb;
|
||||
const shouldMeasureWebShimmer = isWebShimmer;
|
||||
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
|
||||
const shouldMeasureNativeShimmer = isLoading && isNative;
|
||||
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
|
||||
const webShimmerSpanStartX = labelOffsetX;
|
||||
const webShimmerSpanEndX = secondaryLabel
|
||||
@@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
||||
if (isNative || !isExpanded || !hasDetailContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open || Platform.OS !== "web") return;
|
||||
if (!open || isNative) return;
|
||||
|
||||
function handler(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
@@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const providerLabel =
|
||||
AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
|
||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||
|
||||
const fetchDiagnostic = useCallback(async () => {
|
||||
if (!client || !provider) return;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface QuestionOption {
|
||||
label: string;
|
||||
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
|
||||
isResponding: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -199,8 +200,8 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
|
||||
>
|
||||
<GitPullRequest size={12} color={iconColor} />
|
||||
@@ -543,7 +544,7 @@ function useLongPressDragInteraction(input: {
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!input.menuController || Platform.OS === "web") {
|
||||
if (!input.menuController || platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -793,7 +794,7 @@ function ProjectHeaderRow({
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
visible={isHovered || platformIsNative || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
@@ -801,8 +802,11 @@ function ProjectHeaderRow({
|
||||
) : null}
|
||||
{onRemoveProject ? (
|
||||
<View
|
||||
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
|
||||
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
|
||||
style={
|
||||
!(isHovered || platformIsNative || isMobileBreakpoint) &&
|
||||
styles.projectKebabButtonHidden
|
||||
}
|
||||
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -912,8 +916,8 @@ function WorkspaceRowInner({
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isTouchPlatform = Platform.OS !== "web";
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
@@ -978,7 +982,7 @@ function WorkspaceRowInner({
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isTouchPlatform) ? (
|
||||
{onArchive && (isHovered || platformIsNative || isCompact) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
@@ -1597,7 +1601,6 @@ export function SidebarWorkspaceList({
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isNative = Platform.OS !== "web";
|
||||
const pathname = usePathname();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
@@ -1831,7 +1834,7 @@ export function SidebarWorkspaceList({
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
useNestable={isNative}
|
||||
useNestable={platformIsNative}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
/>
|
||||
);
|
||||
@@ -1848,7 +1851,7 @@ export function SidebarWorkspaceList({
|
||||
serverId,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
isNative,
|
||||
platformIsNative,
|
||||
creatingWorkspaceIds,
|
||||
],
|
||||
);
|
||||
@@ -1872,7 +1875,7 @@ export function SidebarWorkspaceList({
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={isNative}
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
@@ -1883,7 +1886,7 @@ export function SidebarWorkspaceList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isNative ? (
|
||||
{platformIsNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
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 { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
@@ -838,7 +839,7 @@ function SplitPaneView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
import {
|
||||
@@ -234,8 +235,8 @@ export function ToastViewport({
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
onPointerEnter={pauseDismiss}
|
||||
onPointerLeave={resumeDismiss}
|
||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "success" ? styles.toastSuccess : null,
|
||||
@@ -265,7 +266,7 @@ export function ToastViewport({
|
||||
</View>
|
||||
);
|
||||
|
||||
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
|
||||
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { StyleSheet } from "react-native-unistyles";
|
||||
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 { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
|
||||
// ---- Content Component ----
|
||||
|
||||
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
|
||||
@@ -37,8 +37,9 @@ import {
|
||||
shouldShowCustomComboboxOption,
|
||||
} 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;
|
||||
|
||||
@@ -107,6 +108,7 @@ export interface SearchInputProps {
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
autoFocus?: boolean;
|
||||
useBottomSheetInput?: boolean;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
@@ -115,10 +117,11 @@ export function SearchInput({
|
||||
onChangeText,
|
||||
onSubmitEditing,
|
||||
autoFocus = false,
|
||||
useBottomSheetInput = false,
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
@@ -264,9 +267,8 @@ export function Combobox({
|
||||
}: ComboboxProps): ReactElement {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||
const isDesktopAboveSearch =
|
||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const hasPresentedBottomSheetRef = useRef(false);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
@@ -274,11 +276,13 @@ export function Combobox({
|
||||
null,
|
||||
);
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
|
||||
|
||||
const isControlled = typeof open === "boolean";
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
@@ -322,8 +326,8 @@ export function Combobox({
|
||||
|
||||
const middleware = useMemo(
|
||||
() => [
|
||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
||||
floatingOffset(isWeb ? 5 : 4),
|
||||
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
|
||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||
floatingSize({
|
||||
padding: collisionPadding,
|
||||
@@ -336,6 +340,9 @@ export function Combobox({
|
||||
});
|
||||
setReferenceWidth((prev) => {
|
||||
const next = rects.reference.width;
|
||||
if (!(next > 0)) {
|
||||
return prev;
|
||||
}
|
||||
if (prev === next) return prev;
|
||||
return next;
|
||||
});
|
||||
@@ -346,7 +353,7 @@ export function Combobox({
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
||||
placement: isWeb ? desktopPlacement : "bottom-start",
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
@@ -357,15 +364,18 @@ export function Combobox({
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setDesktopContentWidth(null);
|
||||
setReferenceLeft(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => void update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||
}, [desktopPlacement, isMobile, isOpen, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setReferenceLeft(null);
|
||||
setReferenceAtOrigin(false);
|
||||
setReferenceTop(null);
|
||||
return;
|
||||
@@ -379,9 +389,16 @@ export function Combobox({
|
||||
}
|
||||
|
||||
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);
|
||||
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
|
||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||
: null;
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
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 =
|
||||
const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
|
||||
const useMeasuredTopStartPosition =
|
||||
!isDesktopAboveSearch &&
|
||||
IS_WEB &&
|
||||
!isMobile &&
|
||||
hasResolvedDesktopPosition &&
|
||||
desktopPlacement.startsWith("top") &&
|
||||
referenceTop !== null;
|
||||
desktopPlacement === "top-start" &&
|
||||
referenceTop !== null &&
|
||||
referenceLeft !== null &&
|
||||
desktopContentWidth !== null;
|
||||
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
|
||||
? Math.max(
|
||||
collisionPadding,
|
||||
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
|
||||
)
|
||||
: null;
|
||||
const measuredTopStartBottom = useMeasuredTopStartPosition
|
||||
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
|
||||
: null;
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
referenceWidth > 0 &&
|
||||
(isDesktopAboveSearch
|
||||
? floatingLeft !== null && desktopAboveSearchBottom !== null
|
||||
: useMeasuredTopStartPosition
|
||||
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
|
||||
: floatingLeft !== null &&
|
||||
floatingTop !== null &&
|
||||
(hasNonZeroFloatingPosition || !referenceAtOrigin));
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||
|
||||
const desktopPositionStyle = isDesktopAboveSearch
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: desktopAboveSearchBottom ?? 0,
|
||||
}
|
||||
: useStableBottom
|
||||
: useMeasuredTopStartPosition
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
||||
left: clampedMeasuredTopStartLeft ?? 0,
|
||||
bottom: measuredTopStartBottom ?? 0,
|
||||
}
|
||||
: floatingStyles;
|
||||
|
||||
@@ -626,6 +657,7 @@ export function Combobox({
|
||||
onChangeText={setSearchQueryWithCallback}
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
autoFocus={!isMobile}
|
||||
useBottomSheetInput={isMobile}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -725,7 +757,13 @@ export function Combobox({
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
onLayout={() => update()}
|
||||
onLayout={(event) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
||||
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
||||
void update();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children ? (
|
||||
<>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -254,8 +255,7 @@ export function ContextMenuTrigger({
|
||||
>): ReactElement {
|
||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||
|
||||
const shouldEnableOnThisPlatform =
|
||||
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
|
||||
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
|
||||
|
||||
const openAtEvent = useCallback(
|
||||
(event: unknown) => {
|
||||
@@ -294,7 +294,7 @@ export function ContextMenuTrigger({
|
||||
disabled={disabled}
|
||||
delayLongPress={longPressDelayMs}
|
||||
onLongPress={(event) => {
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
props.onLongPress?.(event);
|
||||
return;
|
||||
}
|
||||
@@ -303,7 +303,7 @@ export function ContextMenuTrigger({
|
||||
}}
|
||||
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
||||
onContextMenu={(event: unknown) => {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
const e: any = event;
|
||||
|
||||
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
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({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
@@ -227,8 +217,8 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isMobile = isMobileTooltipEnvironment();
|
||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -236,10 +226,10 @@ export function Tooltip({
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
openOnPress: isMobile,
|
||||
openOnPress: isCompact,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
@@ -354,7 +344,7 @@ export function TooltipTrigger({
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onPress: handlePress,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
@@ -473,7 +463,7 @@ export function TooltipContent({
|
||||
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
Platform,
|
||||
type FlatList,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
useWebDesktopScrollbarMetrics,
|
||||
type ScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
||||
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
|
||||
contentRef?: RefObject<HTMLElement | null>;
|
||||
},
|
||||
): ReactNode {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const enabled = (options?.enabled ?? true) && isWeb;
|
||||
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||
const contentRef = options?.contentRef;
|
||||
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
|
||||
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||
options?: { enabled?: boolean },
|
||||
): WebScrollViewScrollbar {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const enabled = (options?.enabled ?? true) && isWeb;
|
||||
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||
const metricsHook = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const onScrollToOffset = useCallback(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
PanResponder,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HANDLE_WIDTH_IDLE = 6;
|
||||
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
|
||||
maxScrollOffset: 0,
|
||||
});
|
||||
const onScrollToOffsetRef = useRef(onScrollToOffset);
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
||||
const normalizedOffset = inverted
|
||||
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
);
|
||||
|
||||
const panResponder = useMemo(() => {
|
||||
if (isWeb) {
|
||||
if (platformIsWeb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
|
||||
setIsDragging(false);
|
||||
},
|
||||
});
|
||||
}, [applyDragDelta, isWeb]);
|
||||
}, [applyDragDelta, platformIsWeb]);
|
||||
|
||||
const startWebDrag = useCallback(
|
||||
(event: any) => {
|
||||
if (!isWeb) {
|
||||
if (!platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
const clientY = readClientY(event);
|
||||
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
dragStartClientYRef.current = clientY;
|
||||
setIsDragging(true);
|
||||
},
|
||||
[isWeb],
|
||||
[platformIsWeb],
|
||||
);
|
||||
|
||||
const handleGrabHoverIn = useCallback(() => {
|
||||
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !isDragging) {
|
||||
if (!platformIsWeb || !isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [applyDragDelta, isDragging, isWeb]);
|
||||
}, [applyDragDelta, isDragging, platformIsWeb]);
|
||||
|
||||
if (!enabled || !geometry.isVisible) {
|
||||
return null;
|
||||
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
height: thumbRegionHeight,
|
||||
transform: [{ translateY: thumbRegionOffset }],
|
||||
},
|
||||
isWeb &&
|
||||
platformIsWeb &&
|
||||
({
|
||||
cursor: handleCursor,
|
||||
touchAction: "none",
|
||||
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
]}
|
||||
pointerEvents={handleVisible ? "auto" : "none"}
|
||||
{...(panResponder?.panHandlers ?? {})}
|
||||
{...(isWeb
|
||||
{...(platformIsWeb
|
||||
? ({
|
||||
onPointerDown: startWebDrag,
|
||||
onPointerEnter: handleGrabHoverIn,
|
||||
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
backgroundColor: handleColor,
|
||||
opacity: handleOpacity,
|
||||
},
|
||||
isWeb &&
|
||||
platformIsWeb &&
|
||||
({
|
||||
transitionProperty: "opacity, width, background-color",
|
||||
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 { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
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 { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
type WelcomeAction = {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
@@ -255,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
[router],
|
||||
);
|
||||
|
||||
const actions: WelcomeAction[] =
|
||||
Platform.OS === "web"
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: "Scan QR code",
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
];
|
||||
const actions: WelcomeAction[] = isWeb
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: "Scan QR code",
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
@@ -321,7 +321,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
</Text>
|
||||
|
||||
{!showHostList && Platform.OS !== "web" && (
|
||||
{!showHostList && isNative && (
|
||||
<>
|
||||
<Text style={styles.setupHint}>
|
||||
You need the Paseo desktop app or server running on your computer first.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
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_HEIGHT = 48;
|
||||
|
||||
// Check if running in the Electron desktop runtime (any OS)
|
||||
function isElectronDesktopRuntime(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return isElectronRuntime();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
export {
|
||||
getIsElectron as getIsElectronRuntime,
|
||||
getIsElectronMac as getIsElectronRuntimeMac,
|
||||
} from "./platform";
|
||||
|
||||
/**
|
||||
* 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
|
||||
// can use the desktop shell without entering web-only code paths.
|
||||
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 { Buffer } from "buffer";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { AppState } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
@@ -52,6 +52,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
(awayMs: number) => {
|
||||
scheduleAuthoritativeRevalidation();
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agentId = session?.focusedAgentId;
|
||||
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
export type DesktopPermissionKind = "notifications" | "microphone";
|
||||
|
||||
@@ -45,7 +45,7 @@ type NavigatorLike = {
|
||||
};
|
||||
|
||||
export function shouldShowDesktopPermissionSection(): boolean {
|
||||
return Platform.OS === "web" && getDesktopHost() !== null;
|
||||
return isWeb && getDesktopHost() !== null;
|
||||
}
|
||||
|
||||
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
||||
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return null;
|
||||
}
|
||||
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
|
||||
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
||||
}
|
||||
|
||||
function getNavigatorLike(): NavigatorLike | null {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return null;
|
||||
}
|
||||
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
||||
}
|
||||
|
||||
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification status is only available on web runtime.",
|
||||
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
|
||||
}
|
||||
|
||||
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop microphone status is only available on web runtime.",
|
||||
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
|
||||
}
|
||||
|
||||
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification requests are only available on web runtime.",
|
||||
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
||||
}
|
||||
|
||||
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
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 { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface DesktopAppUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
@@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
||||
}
|
||||
|
||||
export function shouldShowDesktopUpdateSection(): boolean {
|
||||
return Platform.OS === "web" && isElectronRuntime();
|
||||
return isWeb && isElectronRuntime();
|
||||
}
|
||||
|
||||
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
|
||||
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
|
||||
interface DiffFileWithPath {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
if (left === right) {
|
||||
@@ -9,7 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
|
||||
export function orderCheckoutDiffFiles<T extends DiffFileWithPath>(files: T[]): T[] {
|
||||
if (files.length < 2) {
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 {
|
||||
shouldClearAgentAttention,
|
||||
type AgentAttentionClearTrigger,
|
||||
} from "@/utils/agent-attention";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
@@ -70,7 +71,7 @@ export function useAgentAttentionClear({
|
||||
|
||||
const appStateSubscription = AppState.addEventListener("change", updateVisibility);
|
||||
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
window.addEventListener("focus", updateVisibility);
|
||||
window.addEventListener("blur", updateVisibility);
|
||||
|
||||
@@ -1,12 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-form-state";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
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("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", () => {
|
||||
it("returns undefined when no initial values and no initial server id", () => {
|
||||
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
||||
@@ -78,6 +183,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -106,6 +212,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -134,6 +241,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
@@ -161,6 +269,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -188,6 +297,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -215,6 +325,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -256,6 +367,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("default");
|
||||
@@ -263,11 +375,6 @@ describe("useAgentFormState", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
@@ -289,7 +396,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
allowedProviderMap,
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("claude");
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -10,6 +7,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
@@ -99,13 +97,8 @@ type UseAgentFormStateResult = {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
};
|
||||
|
||||
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
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 ?? "";
|
||||
const DEFAULT_PROVIDER: AgentProvider = "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
|
||||
|
||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
@@ -180,7 +173,7 @@ function resolveFormState(
|
||||
userModified: UserModifiedFields,
|
||||
currentState: FormState,
|
||||
validServerIds: Set<string>,
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap,
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
@@ -376,10 +369,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(() => {
|
||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||
}, [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(
|
||||
() => buildProviderDefinitions(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
@@ -388,17 +381,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
[snapshotProviderDefinitions],
|
||||
);
|
||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||
if (!snapshotEntries?.length) {
|
||||
return snapshotProviderDefinitionMap;
|
||||
}
|
||||
const readyProviders = new Set(
|
||||
(snapshotEntries ?? [])
|
||||
.filter((entry) => entry.status === "ready")
|
||||
.map((entry) => entry.provider),
|
||||
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
|
||||
);
|
||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions
|
||||
.filter((definition) => readyProviders.has(definition.id))
|
||||
.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
|
||||
const snapshotAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import {
|
||||
@@ -10,13 +9,14 @@ import {
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
||||
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
||||
|
||||
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__ = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { AppState } from "react-native";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
let current = getIsAppActivelyVisible();
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -29,7 +30,7 @@ export function useAppVisible(): boolean {
|
||||
useEffect(() => {
|
||||
const appStateSubscription = AppState.addEventListener("change", notify);
|
||||
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", notify);
|
||||
window.addEventListener("focus", notify);
|
||||
window.addEventListener("blur", notify);
|
||||
@@ -37,7 +38,7 @@ export function useAppVisible(): boolean {
|
||||
|
||||
return () => {
|
||||
appStateSubscription.remove();
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", notify);
|
||||
window.removeEventListener("focus", notify);
|
||||
window.removeEventListener("blur", notify);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
|
||||
return {
|
||||
path,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
|
||||
it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
|
||||
const files: DiffFileMetadata[] = [
|
||||
createMetadata("zeta.ts"),
|
||||
createMetadata("alpha.ts"),
|
||||
createMetadata("beta.ts"),
|
||||
];
|
||||
const ordered = orderCheckoutDiffFiles(files);
|
||||
expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
|
||||
});
|
||||
|
||||
it("replaces full list on snapshot — does not merge with previous", () => {
|
||||
const initial: DiffFileMetadata[] = [
|
||||
createMetadata("a.ts", "fp1"),
|
||||
createMetadata("b.ts", "fp2"),
|
||||
createMetadata("c.ts", "fp3"),
|
||||
];
|
||||
// Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
|
||||
const updated: DiffFileMetadata[] = [
|
||||
createMetadata("a.ts", "fp1"),
|
||||
createMetadata("c.ts", "fp4"),
|
||||
];
|
||||
|
||||
// Full replacement: the result is exactly the updated list, not a merge
|
||||
const result = orderCheckoutDiffFiles(updated);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
|
||||
expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
|
||||
// b.ts is gone — no remnant from initial
|
||||
expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
|
||||
|
||||
// Initial is unchanged (no mutation)
|
||||
expect(initial).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("preserves fingerprint and status on metadata items", () => {
|
||||
const file: DiffFileMetadata = {
|
||||
path: "large.bin",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
status: "binary",
|
||||
fingerprint: "abc123",
|
||||
};
|
||||
const ordered = orderCheckoutDiffFiles([file]);
|
||||
expect(ordered[0].fingerprint).toBe("abc123");
|
||||
expect(ordered[0].status).toBe("binary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useId, useMemo } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutDiffSnapshot } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
|
||||
|
||||
export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
|
||||
|
||||
type CheckoutDiffMetadataPayload = Omit<CheckoutDiffSnapshot["payload"], "subscriptionId">;
|
||||
|
||||
function checkoutDiffMetadataQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return [
|
||||
"checkoutDiffMetadata",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffMetadataQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffMetadataQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffMetadataQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const payload = await client.getCheckoutDiffMetadata(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd || !enabled) {
|
||||
return;
|
||||
}
|
||||
if (!isOpen || explorerTab !== "changes") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = [
|
||||
"checkoutDiffMetadata",
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
|
||||
if (message.type !== "checkout/diff_snapshot") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||
cwd: message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(message.payload.files),
|
||||
error: message.payload.error,
|
||||
requestId: message.payload.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
void client
|
||||
.subscribeCheckoutDiffLazy(
|
||||
cwd,
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
|
||||
serverId,
|
||||
cwd,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeSnapshot();
|
||||
try {
|
||||
client.unsubscribeCheckoutDiffLazy(subscriptionId);
|
||||
} catch {
|
||||
// Ignore disconnect race during effect cleanup.
|
||||
}
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
cwd,
|
||||
enabled,
|
||||
isOpen,
|
||||
explorerTab,
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
const payload = query.data ?? null;
|
||||
const payloadError = payload?.error ?? null;
|
||||
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError || Boolean(payloadError),
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
||||
@@ -32,7 +33,7 @@ export function useClientActivity({
|
||||
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
||||
const lastImmediateHeartbeatAtRef = useRef<number>(0);
|
||||
|
||||
const deviceType = Platform.OS === "web" ? "web" : "mobile";
|
||||
const deviceType = isWeb ? "web" : "mobile";
|
||||
|
||||
const recordUserActivity = useCallback(() => {
|
||||
lastActivityAtRef.current = new Date();
|
||||
@@ -96,7 +97,7 @@ export function useClientActivity({
|
||||
|
||||
// Track user activity on web for accurate staleness.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const handleUserActivity = () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||
type DesktopBadgeWorkspaceStatus,
|
||||
} from "@/utils/desktop-badge-state";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
type ColorScheme = "dark" | "light";
|
||||
@@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
|
||||
}
|
||||
|
||||
function getSystemColorScheme(): ColorScheme {
|
||||
if (
|
||||
Platform.OS !== "web" ||
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return "dark";
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
async function updateMacDockBadge(count?: number) {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
|
||||
if (isNative || !getIsElectronRuntimeMac()) return;
|
||||
|
||||
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
|
||||
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
|
||||
@@ -119,7 +115,7 @@ export function useFaviconStatus() {
|
||||
|
||||
// Listen for system color scheme changes
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || typeof window === "undefined") return;
|
||||
if (isNative || typeof window === "undefined") return;
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
@@ -132,7 +128,7 @@ export function useFaviconStatus() {
|
||||
|
||||
// Update favicon when agents or color scheme changes
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
|
||||
const status = deriveFaviconStatus(agents);
|
||||
updateFavicon(status, colorScheme);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
@@ -14,7 +14,7 @@ interface UseFileDropZoneReturn {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = isWeb;
|
||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||
|
||||
/**
|
||||
* Tests for use-file-hunks hook logic.
|
||||
*
|
||||
* The hook itself is a thin React Query wrapper, so we test the key properties:
|
||||
* 1. Query key includes fingerprint — fingerprint change = automatic refetch
|
||||
* 2. Metadata fields are passed through to the merged result
|
||||
*/
|
||||
|
||||
function fileHunksQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef: string | undefined,
|
||||
ignoreWhitespace: boolean | undefined,
|
||||
path: string,
|
||||
fingerprint: string,
|
||||
) {
|
||||
return [
|
||||
"checkoutFileHunks",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
path,
|
||||
fingerprint,
|
||||
] as const;
|
||||
}
|
||||
|
||||
describe("use-file-hunks query key", () => {
|
||||
it("includes fingerprint in query key", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
|
||||
expect(key).toContain("fp-abc");
|
||||
expect(key).toContain("foo.ts");
|
||||
});
|
||||
|
||||
it("different fingerprints produce different query keys", () => {
|
||||
const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
|
||||
const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
|
||||
expect(key1).not.toEqual(key2);
|
||||
// Only the fingerprint element differs
|
||||
expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
|
||||
});
|
||||
|
||||
it("same fingerprint + same params produces identical key", () => {
|
||||
const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||
const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||
expect(key1).toEqual(key2);
|
||||
});
|
||||
|
||||
it("normalizes missing baseRef to empty string", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
|
||||
expect(key[4]).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes ignoreWhitespace to boolean", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
|
||||
expect(key[5]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("use-file-hunks metadata merge", () => {
|
||||
it("metadata fields carry through to merged ParsedDiffFile shape", () => {
|
||||
const meta: DiffFileMetadata = {
|
||||
path: "src/index.ts",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 10,
|
||||
deletions: 2,
|
||||
status: "ok",
|
||||
fingerprint: "abc",
|
||||
};
|
||||
|
||||
// Simulates merging metadata with fetched hunks
|
||||
const merged = {
|
||||
path: meta.path,
|
||||
isNew: meta.isNew,
|
||||
isDeleted: meta.isDeleted,
|
||||
additions: meta.additions,
|
||||
deletions: meta.deletions,
|
||||
status: meta.status,
|
||||
hunks: [],
|
||||
};
|
||||
|
||||
expect(merged.path).toBe("src/index.ts");
|
||||
expect(merged.isNew).toBe(true);
|
||||
expect(merged.additions).toBe(10);
|
||||
expect(merged.hunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
|
||||
|
||||
function fileHunksQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef: string | undefined,
|
||||
ignoreWhitespace: boolean | undefined,
|
||||
path: string,
|
||||
fingerprint: string,
|
||||
) {
|
||||
return [
|
||||
"checkoutFileHunks",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
path,
|
||||
fingerprint,
|
||||
] as const;
|
||||
}
|
||||
|
||||
interface UseFileHunksOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
file: DiffFileMetadata;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useFileHunks({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file,
|
||||
enabled = true,
|
||||
}: UseFileHunksOptions) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() =>
|
||||
fileHunksQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file.path,
|
||||
file.fingerprint,
|
||||
),
|
||||
[serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async (): Promise<ParsedDiffFile> => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const payload = await client.getCheckoutFileHunks(
|
||||
cwd,
|
||||
{ mode, baseRef, ignoreWhitespace },
|
||||
file.path,
|
||||
);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
isNew: file.isNew,
|
||||
isDeleted: file.isDeleted,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status,
|
||||
hunks: payload.hunks,
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
file: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
openImagePathsWithDesktopDialog,
|
||||
type PickedImageAttachmentInput,
|
||||
} from "@/hooks/image-attachment-picker";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface UseImageAttachmentPickerResult {
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||
@@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
isPickingRef.current = true;
|
||||
|
||||
try {
|
||||
if (Platform.OS === "web" && isElectronRuntime()) {
|
||||
if (isWeb && isElectronRuntime()) {
|
||||
const selectedPaths = await openImagePathsWithDesktopDialog();
|
||||
if (selectedPaths.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -26,6 +26,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
@@ -47,6 +48,7 @@ export function useKeyboardShortcuts({
|
||||
cycleTheme?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const hosts = useHosts();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
@@ -65,7 +67,7 @@ export function useKeyboardShortcuts({
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
if (isMobile) return;
|
||||
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
@@ -244,6 +246,16 @@ export function useKeyboardShortcuts({
|
||||
case "sidebar.toggle.left":
|
||||
toggleAgentList();
|
||||
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":
|
||||
if (toggleBothSidebars) {
|
||||
toggleBothSidebars();
|
||||
|
||||
@@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import Constants from "expo-constants";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
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 registerIfPossible = useCallback(async () => {
|
||||
if (Platform.OS === "web") return;
|
||||
if (isWeb) return;
|
||||
if (!client.isConnected) return;
|
||||
const token = tokenRef.current;
|
||||
if (!token) return;
|
||||
@@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") return;
|
||||
if (isWeb) return;
|
||||
|
||||
const storageKey = `${STORAGE_PREFIX}${serverId}`;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -37,6 +37,7 @@ export type KeyboardActionId =
|
||||
| "sidebar.toggle.left"
|
||||
| "sidebar.toggle.right"
|
||||
| "sidebar.toggle.both"
|
||||
| "settings.toggle"
|
||||
| "command-center.toggle"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "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 ---
|
||||
{
|
||||
id: "view-toggle-focus-cmd-shift-f-mac",
|
||||
|
||||
@@ -31,6 +31,7 @@ KEY_MAP["Digit"] = { code: "Digit" };
|
||||
KEY_MAP["\\"] = { code: "Backslash" };
|
||||
KEY_MAP["["] = { code: "BracketLeft", key: "[" };
|
||||
KEY_MAP["]"] = { code: "BracketRight", key: "]" };
|
||||
KEY_MAP[","] = { code: "Comma", key: "," };
|
||||
KEY_MAP["."] = { code: "Period", key: "." };
|
||||
KEY_MAP["`"] = { code: "Backquote", key: "`" };
|
||||
KEY_MAP["/"] = { code: "Slash" };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (!provider) {
|
||||
@@ -595,7 +596,7 @@ function AgentPanelBody({
|
||||
if (!isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web";
|
||||
const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
|
||||
if (!shouldSyncOnEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
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 { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -49,7 +49,6 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -57,6 +56,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
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 DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -67,10 +67,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
function getParamValue(value: string | string[] | undefined) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
@@ -91,16 +87,14 @@ function getValidProvider(value: string | undefined) {
|
||||
if (!value) {
|
||||
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) {
|
||||
if (!provider || !value) {
|
||||
return undefined;
|
||||
}
|
||||
const definition = PROVIDER_DEFINITION_MAP.get(provider);
|
||||
const modes = definition?.modes ?? [];
|
||||
return modes.some((mode) => mode.id === value) ? value : undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
type DraftAgentParams = {
|
||||
@@ -850,7 +844,7 @@ function DraftAgentScreenContent({
|
||||
},
|
||||
onBeforeSubmit: () => {
|
||||
void persistFormPreferences();
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } 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 { useFocusEffect } from "@react-navigation/native";
|
||||
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 { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -522,6 +523,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
@@ -557,7 +559,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
</View>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||
{providerDefinitions.map((def) => {
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
@@ -1672,22 +1674,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
|
||||
<View style={styles.hostHeaderRight}>
|
||||
<View
|
||||
style={[
|
||||
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile,
|
||||
isWeb ? styles.statusPill : styles.statusPillMobile,
|
||||
{ backgroundColor: statusPillBg },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
{Platform.OS === "web" ? (
|
||||
{isWeb ? (
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{connectionBadge ? (
|
||||
<View
|
||||
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
|
||||
>
|
||||
<View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
|
||||
{connectionBadge.icon}
|
||||
{Platform.OS === "web" ? (
|
||||
{isWeb ? (
|
||||
<Text style={styles.connectionText} numberOfLines={1}>
|
||||
{connectionBadge.text}
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
function ShortcutSequence({
|
||||
chord,
|
||||
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
if (capturingBindingId === null) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
|
||||
};
|
||||
}, [setCapturingShortcut]);
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { openExternalUrl } from "@/utils/open-external-url";
|
||||
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 { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type StartupSplashScreenProps = {
|
||||
bootstrapState?: {
|
||||
@@ -42,7 +43,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
errorScrollView: {
|
||||
flex: 1,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
overflowX: "auto",
|
||||
overflowY: "auto",
|
||||
@@ -144,7 +145,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -113,6 +113,7 @@ function useMiddleClickClose(onClose: () => void) {
|
||||
const ref = useRef<View>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
const node = ref.current as unknown as HTMLElement | null;
|
||||
if (!node) return;
|
||||
|
||||
@@ -174,17 +175,16 @@ function TabChip({
|
||||
);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isHighlighted = isActive || hovered || isCloseHovered;
|
||||
const closeButtonDragBlockers =
|
||||
Platform.OS === "web"
|
||||
? ({
|
||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
onMouseDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
} as const)
|
||||
: undefined;
|
||||
const closeButtonDragBlockers = isWeb
|
||||
? ({
|
||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
onMouseDown: (event: { stopPropagation?: () => void }) => {
|
||||
event.stopPropagation?.();
|
||||
},
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<View ref={middleClickRef}>
|
||||
@@ -199,7 +199,7 @@ function TabChip({
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
|
||||
isWeb && isDragging && ({ cursor: "grabbing" } as const),
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, 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 { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -155,7 +156,7 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
onBeforeSubmit: () => {
|
||||
void persistFormPreferences();
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Check, ChevronDown } from "lucide-react-native";
|
||||
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 { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadEditors =
|
||||
Platform.OS === "web" &&
|
||||
Boolean(client && isConnected) &&
|
||||
cwd.trim().length > 0 &&
|
||||
isAbsolutePath(cwd);
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -118,6 +110,7 @@ import {
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
@@ -252,7 +245,7 @@ function WorkspaceDocumentTitleEffect({
|
||||
titleState: "ready" | "loading";
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || typeof document === "undefined") {
|
||||
if (isNative || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const resolvedLabel = label.trim();
|
||||
@@ -810,7 +803,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web" || !isExplorerOpen) {
|
||||
if (isWeb || !isExplorerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1291,7 +1284,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],
|
||||
@@ -1708,7 +1702,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
|
||||
if (isNative || typeof document === "undefined" || activeTabDescriptor) {
|
||||
return;
|
||||
}
|
||||
document.title = "Workspace";
|
||||
@@ -1932,7 +1926,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
|
||||
{Platform.OS === "web" && activeTabDescriptor ? (
|
||||
{isWeb && activeTabDescriptor ? (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={activeTabDescriptor}
|
||||
serverId={normalizedServerId}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
import { Platform } from "react-native";
|
||||
import { File as FSFile, Paths } from "expo-file-system";
|
||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface DownloadProgress {
|
||||
percent: number;
|
||||
@@ -97,10 +97,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
const downloadUrl = buildDownloadUrl(
|
||||
downloadTarget.baseUrl,
|
||||
tokenResponse.token,
|
||||
Platform.OS === "web" ? downloadTarget.authCredentials : null,
|
||||
isWeb ? downloadTarget.authCredentials : null,
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
triggerBrowserDownload(downloadUrl, resolvedFileName);
|
||||
get().completeDownload(id);
|
||||
return;
|
||||
@@ -153,7 +153,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to download file.";
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
console.warn("[DownloadStore] Download failed:", message);
|
||||
get().failDownload(id, message);
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
buildExplorerCheckoutKey,
|
||||
coerceExplorerTabForCheckout,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
resolveExplorerTabForCheckout,
|
||||
type ExplorerTab,
|
||||
} from "./explorer-tab-memory";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
export type { ExplorerTab } from "./explorer-tab-memory";
|
||||
|
||||
/**
|
||||
@@ -128,7 +128,7 @@ function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab |
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
||||
const DEFAULT_DESKTOP_OPEN = isWeb;
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
@@ -318,11 +318,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
|
||||
if (version < 2) {
|
||||
if (
|
||||
Platform.OS === "web" &&
|
||||
typeof state.explorerWidth === "number" &&
|
||||
state.explorerWidth === 400
|
||||
) {
|
||||
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
|
||||
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
||||
}
|
||||
|
||||
@@ -337,7 +333,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
|
||||
if (version < 3) {
|
||||
if (
|
||||
Platform.OS === "web" &&
|
||||
isWeb &&
|
||||
typeof state.explorerWidth === "number" &&
|
||||
(state.explorerWidth === 400 || state.explorerWidth === 520)
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Platform } from "react-native";
|
||||
import type { Theme } from "./theme";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {};
|
||||
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
|
||||
|
||||
/**
|
||||
* Creates comprehensive markdown styles for react-native-markdown-display.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { AppState } from "react-native";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
|
||||
if (appState !== "active") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { Alert } from "react-native";
|
||||
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
export interface ConfirmDialogInput {
|
||||
title: string;
|
||||
@@ -49,7 +50,7 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
|
||||
}
|
||||
|
||||
function getDesktopApi() {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return null;
|
||||
}
|
||||
return getDesktopHost();
|
||||
@@ -67,7 +68,7 @@ function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOpti
|
||||
}
|
||||
|
||||
function blurActiveWebElement(): void {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
const activeElement = (globalThis as { document?: Document }).document?.activeElement;
|
||||
@@ -103,7 +104,7 @@ function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
|
||||
}
|
||||
|
||||
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return showNativeConfirmDialog(input);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
getIsElectronRuntimeMac,
|
||||
getIsElectronRuntime,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
type RawWindowControlsPadding = {
|
||||
left: number;
|
||||
@@ -23,7 +23,7 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) return;
|
||||
if (isNative || !getIsElectronRuntime()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
const opener = getDesktopHost()?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { Platform } from "react-native";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -80,7 +80,7 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return false;
|
||||
}
|
||||
return await ensureNotificationPermission();
|
||||
@@ -154,7 +154,7 @@ function attachWebClickHandler(
|
||||
|
||||
export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> {
|
||||
// Mobile/native notifications should be remote push only.
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ProviderSnapshotEntry, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
type AgentProviderDefinition,
|
||||
type AgentProviderModeDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
|
||||
function buildProviderModes(entry: ProviderSnapshotEntry): AgentProviderModeDefinition[] {
|
||||
const entryModes = entry.modes ?? [];
|
||||
|
||||
return entryModes.map((mode) => ({
|
||||
...mode,
|
||||
icon: (mode.icon ?? "ShieldCheck") as AgentModeIcon,
|
||||
colorTier: (mode.colorTier ?? "moderate") as AgentModeColorTier,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildProviderDefinitions(
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
||||
): AgentProviderDefinition[] {
|
||||
if (!snapshotEntries?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return snapshotEntries.map((entry) => ({
|
||||
id: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
description: entry.description ?? "",
|
||||
defaultModeId: entry.defaultModeId ?? null,
|
||||
modes: buildProviderModes(entry),
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveProviderLabel(
|
||||
provider: string,
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
||||
): string {
|
||||
return snapshotEntries?.find((entry) => entry.provider === provider)?.label ?? provider;
|
||||
}
|
||||
|
||||
export function resolveProviderDefinition(
|
||||
provider: string,
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
||||
): AgentProviderDefinition | undefined {
|
||||
return buildProviderDefinitions(snapshotEntries).find((definition) => definition.id === provider);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type ListenerStats = {
|
||||
adds: number;
|
||||
@@ -92,7 +92,7 @@ const SOURCE_LABEL = "[ScrollJankInvestigation]";
|
||||
function shouldInstall(): boolean {
|
||||
const runtime = globalThis as ScrollInvestigationGlobal;
|
||||
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
return Platform.OS === "web" && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
|
||||
return isWeb && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
|
||||
}
|
||||
|
||||
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
export function getShortcutOs(): ShortcutOs {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||
}
|
||||
if (getIsElectronRuntimeMac()) return "mac";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { File } from "expo-file-system";
|
||||
import { Platform } from "react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
export { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav";
|
||||
|
||||
export const THINKING_TONE_REPEAT_GAP_MS = 350;
|
||||
@@ -11,7 +11,7 @@ async function readThinkingToneArrayBuffer(): Promise<ArrayBuffer> {
|
||||
const toneModule = require("../../assets/audio/thinking-tone.wav");
|
||||
const asset = Asset.fromModule(toneModule);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
const response = await fetch(asset.uri);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch thinking tone asset: ${response.status}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.54",
|
||||
"@getpaseo/server": "0.1.54",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { execCommand } from "@getpaseo/server";
|
||||
|
||||
export interface NodePathFromPidResult {
|
||||
nodePath: string | null;
|
||||
@@ -13,63 +13,37 @@ function normalizeError(error: unknown): string {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
|
||||
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
|
||||
async function resolveNodePathFromPidUnix(pid: number): Promise<NodePathFromPidResult> {
|
||||
try {
|
||||
const { stdout } = await execCommand("ps", ["-o", "comm=", "-p", String(pid)]);
|
||||
const resolved = stdout.trim();
|
||||
return resolved
|
||||
? { nodePath: resolved }
|
||||
: { nodePath: null, error: "ps returned an empty command path" };
|
||||
} catch (error) {
|
||||
return { nodePath: null, error: `ps failed: ${normalizeError(error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim();
|
||||
return {
|
||||
nodePath: null,
|
||||
error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = result.stdout.trim();
|
||||
return resolved
|
||||
? { nodePath: resolved }
|
||||
: { nodePath: null, error: "ps returned an empty command path" };
|
||||
}
|
||||
|
||||
function runProcessProbe(
|
||||
async function runProcessProbe(
|
||||
command: string,
|
||||
args: string[],
|
||||
): {
|
||||
): Promise<{
|
||||
resolved: string | null;
|
||||
error?: string;
|
||||
} {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
|
||||
}> {
|
||||
try {
|
||||
const { stdout } = await execCommand(command, args);
|
||||
const resolved = stdout.trim();
|
||||
return resolved
|
||||
? { resolved }
|
||||
: { resolved: null, error: `${command} returned no executable path` };
|
||||
} catch (error) {
|
||||
return { resolved: null, error: `${command} failed: ${normalizeError(error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim();
|
||||
return {
|
||||
resolved: null,
|
||||
error: details
|
||||
? `${command} failed: ${details}`
|
||||
: `${command} exited with code ${result.status ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = result.stdout.trim();
|
||||
return resolved
|
||||
? { resolved }
|
||||
: { resolved: null, error: `${command} returned no executable path` };
|
||||
}
|
||||
|
||||
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||
async function resolveNodePathFromPidWindows(pid: number): Promise<NodePathFromPidResult> {
|
||||
const probes: Array<{
|
||||
label: string;
|
||||
command: string;
|
||||
@@ -103,7 +77,7 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const probe of probes) {
|
||||
const result = runProcessProbe(probe.command, probe.args);
|
||||
const result = await runProcessProbe(probe.command, probe.args);
|
||||
if (result.resolved) {
|
||||
const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved;
|
||||
if (resolved) {
|
||||
@@ -123,8 +97,8 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
export async function resolveNodePathFromPid(pid: number): Promise<NodePathFromPidResult> {
|
||||
return platform() === "win32"
|
||||
? resolveNodePathFromPidWindows(pid)
|
||||
: resolveNodePathFromPidUnix(pid);
|
||||
? await resolveNodePathFromPidWindows(pid)
|
||||
: await resolveNodePathFromPidUnix(pid);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Command } from "commander";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { promisify } from "node:util";
|
||||
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import {
|
||||
getOrCreateServerId,
|
||||
findExecutable,
|
||||
applyProviderEnv,
|
||||
execCommand,
|
||||
} from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
@@ -178,11 +179,9 @@ async function checkProviderBinary(
|
||||
}
|
||||
const env = applyProviderEnv(process.env);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
|
||||
encoding: "utf8",
|
||||
const { stdout } = await execCommand(binaryPath, ["--version"], {
|
||||
timeout: 5000,
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { path: binaryPath, version: stdout.trim() || null };
|
||||
} catch {
|
||||
@@ -224,7 +223,7 @@ export async function runStatusCommand(
|
||||
if (!state.running) {
|
||||
daemonNode = "-";
|
||||
} else if (state.pidInfo?.pid) {
|
||||
const fromPid = resolveNodePathFromPid(state.pidInfo.pid);
|
||||
const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
|
||||
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
||||
} else {
|
||||
daemonNode = "unknown (no PID available)";
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
|
||||
/** Provider list item for display */
|
||||
export interface ProviderListItem {
|
||||
provider: string;
|
||||
label: string;
|
||||
status: string;
|
||||
defaultMode: string;
|
||||
modes: string;
|
||||
@@ -13,16 +15,22 @@ export interface ProviderListItem {
|
||||
/** Derive provider list from the manifest — single source of truth */
|
||||
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
label: def.label,
|
||||
status: "available",
|
||||
defaultMode: def.defaultModeId ?? "default",
|
||||
modes: def.modes.map((m) => m.label).join(", "),
|
||||
}));
|
||||
|
||||
function getStaticProviders(): ProviderListItem[] {
|
||||
return PROVIDERS;
|
||||
}
|
||||
|
||||
/** Schema for provider ls output */
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
idField: "provider",
|
||||
columns: [
|
||||
{ header: "PROVIDER", field: "provider", width: 12 },
|
||||
{ header: "LABEL", field: "label", width: 16 },
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
@@ -45,13 +53,39 @@ export interface ProviderLsOptions extends CommandOptions {
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
_options: ProviderLsOptions,
|
||||
options: ProviderLsOptions,
|
||||
_command: Command,
|
||||
): Promise<ProviderLsResult> {
|
||||
// Provider data is static - no daemon connection needed
|
||||
return {
|
||||
type: "list",
|
||||
data: PROVIDERS,
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
const client = await tryConnectToDaemon({ host: options.host });
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
return {
|
||||
type: "list",
|
||||
data: snapshot.entries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
status: entry.status === "ready" ? "available" : entry.status,
|
||||
defaultMode: entry.defaultModeId ?? "default",
|
||||
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
|
||||
})),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,12 @@ try {
|
||||
assert(result.stdout.includes("claude"), "output should include claude");
|
||||
assert(result.stdout.includes("codex"), "output should include codex");
|
||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
||||
assert(result.stdout.includes("available"), "output should show available status");
|
||||
assert(
|
||||
result.stdout.includes("available") ||
|
||||
result.stdout.includes("loading") ||
|
||||
result.stdout.includes("unavailable"),
|
||||
"output should show a provider status",
|
||||
);
|
||||
console.log("✓ provider ls lists all providers\n");
|
||||
}
|
||||
|
||||
@@ -140,7 +145,7 @@ try {
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert.strictEqual(data.length, 5, "should have 5 providers");
|
||||
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
||||
"should include claude",
|
||||
@@ -153,14 +158,6 @@ try {
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "copilot"),
|
||||
"should include copilot",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "pi"),
|
||||
"should include pi",
|
||||
);
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
@@ -170,12 +167,10 @@ try {
|
||||
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
assert.strictEqual(lines.length, 5, "should have 5 lines");
|
||||
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
|
||||
assert(lines.includes("claude"), "should include claude");
|
||||
assert(lines.includes("codex"), "should include codex");
|
||||
assert(lines.includes("opencode"), "should include opencode");
|
||||
assert(lines.includes("copilot"), "should include copilot");
|
||||
assert(lines.includes("pi"), "should include pi");
|
||||
console.log("✓ provider ls --quiet outputs provider names only\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.54",
|
||||
"@getpaseo/server": "0.1.54",
|
||||
"@getpaseo/cli": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55-rc.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user