Merge origin/main into dev (second round)

Resolve 21 conflicts from latest main (provider profiles, Windows fixes, 0.1.55-rc.1).
Fix all type errors and syntax issues from merge artifacts.
This commit is contained in:
Mohamed Boudra
2026-04-14 04:07:36 +07:00
249 changed files with 9118 additions and 4973 deletions
+25
View File
@@ -76,6 +76,31 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
app-tests: app-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+40
View File
@@ -55,6 +55,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null). - Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Platform gating
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
### The four gates
| Gate | Type | When to use |
|---|---|---|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
### Decision matrix
| I need to... | Use |
|---|---|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
### Rules
- **Default is cross-platform.** Don't gate unless you have a specific reason.
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
```
hooks/
use-audio-recorder.web.ts ← uses Web Audio API
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
## Debugging ## Debugging
+159
View File
@@ -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
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU="; npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+47 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.54", "version": "0.1.55-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.54", "version": "0.1.55-rc.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -36169,16 +36169,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.54", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.54", "@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
"@getpaseo/highlight": "0.1.54", "@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/server": "0.1.54", "@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -36319,11 +36319,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.54", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.54", "@getpaseo/relay": "0.1.55-rc.1",
"@getpaseo/server": "0.1.54", "@getpaseo/server": "0.1.55-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -36364,11 +36364,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.54", "version": "0.1.55-rc.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.54", "@getpaseo/cli": "0.1.55-rc.1",
"@getpaseo/server": "0.1.54", "@getpaseo/server": "0.1.55-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -36403,7 +36403,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.54", "version": "0.1.55-rc.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -36604,7 +36604,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.54", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -36630,7 +36630,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.54", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -36646,14 +36646,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.54", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.54", "@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/relay": "0.1.54", "@getpaseo/relay": "0.1.55-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -36672,6 +36672,7 @@
"node-pty": "1.2.0-beta.11", "node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0", "onnxruntime-node": "^1.23.0",
"openai": "^4.20.0", "openai": "^4.20.0",
"p-limit": "^7.3.0",
"pi-acp": "^0.0.24", "pi-acp": "^0.0.24",
"pino": "^10.2.0", "pino": "^10.2.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
@@ -36949,6 +36950,21 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"packages/server/node_modules/p-limit": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/raw-body": { "packages/server/node_modules/raw-body": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
@@ -37046,6 +37062,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/zod": { "packages/server/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -37057,7 +37085,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.54", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.54", "version": "0.1.55-rc.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
+3 -2
View File
@@ -117,7 +117,6 @@ export async function clickTerminal(page: Page): Promise<void> {
await button.click(); await button.click();
} }
// ─── Tab title assertions ────────────────────────────────────────────────── // ─── Tab title assertions ──────────────────────────────────────────────────
/** Wait for any tab in the bar to display the given title text. */ /** Wait for any tab in the bar to display the given title text. */
@@ -188,6 +187,8 @@ export async function sampleTabsDuringTransition(
// ─── Workspace setup ─────────────────────────────────────────────────────── // ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */ /** Create a temp git repo and return its path with a cleanup function. */
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> { export async function createWorkspace(
prefix = "launcher-e2e-",
): ReturnType<typeof createTempGitRepo> {
return createTempGitRepo(prefix); return createTempGitRepo(prefix);
} }
+231
View File
@@ -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 };
}
+1 -3
View File
@@ -8,9 +8,7 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = { export type TerminalPerfDaemonClient = {
connect(): Promise<void>; connect(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
openProject( openProject(cwd: string): Promise<{
cwd: string,
): Promise<{
workspace: { id: number; name: string; projectRootPath: string } | null; workspace: { id: number; name: string; projectRootPath: string } | null;
error: string | null; error: string | null;
}>; }>;
@@ -1,8 +1,5 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { import { clickNewChat, clickTerminal } from "./launcher";
clickNewChat,
clickTerminal,
} from "./launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf"; import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) { function terminalSurface(page: Page) {
+21 -17
View File
@@ -10,9 +10,7 @@ import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = { type WorkspaceSetupDaemonClient = {
connect(): Promise<void>; connect(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
openProject( openProject(cwd: string): Promise<{
cwd: string,
): Promise<{
workspace: { workspace: {
id: number; id: number;
name: string; name: string;
@@ -21,9 +19,7 @@ type WorkspaceSetupDaemonClient = {
} | null; } | null;
error: string | null; error: string | null;
}>; }>;
createPaseoWorktree( createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
input: { cwd: string; worktreeSlug?: string },
): Promise<{
workspace: { workspace: {
id: number; id: number;
name: string; name: string;
@@ -45,15 +41,11 @@ type WorkspaceSetupDaemonClient = {
agent: { id: string; cwd: string; workspaceId?: string | null }; agent: { id: string; cwd: string; workspaceId?: string | null };
}>; }>;
}>; }>;
fetchAgent( fetchAgent(agentId: string): Promise<{
agentId: string,
): Promise<{
agent: { id: string; cwd: string } | null; agent: { id: string; cwd: string } | null;
project: unknown | null; project: unknown | null;
} | null>; } | null>;
listTerminals( listTerminals(cwd: string): Promise<{
cwd: string,
): Promise<{
cwd?: string; cwd?: string;
terminals: Array<{ id: string; cwd: string; name: string }>; terminals: Array<{ id: string; cwd: string; name: string }>;
error?: string | null; error?: string | null;
@@ -77,7 +69,11 @@ function getDaemonWsUrl(): string {
} }
async function loadDaemonClientConstructor(): Promise< async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => WorkspaceSetupDaemonClient
> { > {
const repoRoot = path.resolve(process.cwd(), "../.."); const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL( const moduleUrl = pathToFileURL(
@@ -149,7 +145,9 @@ export async function createWorkspaceFromSidebar(page: Page, repoPath: string):
await expect(button).toBeEnabled({ timeout: 30_000 }); await expect(button).toBeEnabled({ timeout: 30_000 });
await button.click(); await button.click();
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 }); await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
} }
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> { export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
@@ -193,7 +191,10 @@ export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Pr
.click(); .click();
} }
export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise<void> { export async function waitForWorkspaceSetupDialogToClose(
page: Page,
timeoutMs = 45_000,
): Promise<void> {
const dialog = workspaceSetupDialog(page); const dialog = workspaceSetupDialog(page);
try { try {
@@ -243,7 +244,9 @@ export async function expectSetupLogContains(page: Page, text: string): Promise<
} }
export async function expectNoSetupMessage(page: Page): Promise<void> { export async function expectNoSetupMessage(page: Page): Promise<void> {
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({ await expect(
page.getByText("No setup commands ran for this workspace.", { exact: true }),
).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
} }
@@ -276,7 +279,8 @@ export async function findWorktreeWorkspaceForProject(
const workspace = const workspace =
payload.entries.find( payload.entries.find(
(entry) => (entry) =>
entry.projectRootPath === normalizedRepoPath && entry.workspaceDirectory !== normalizedRepoPath, entry.projectRootPath === normalizedRepoPath &&
entry.workspaceDirectory !== normalizedRepoPath,
) ?? null; ) ?? null;
if (!workspace) { if (!workspace) {
throw new Error(`Failed to find created worktree workspace for ${repoPath}`); throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
+3 -15
View File
@@ -63,9 +63,7 @@ test.describe("Tab creation", () => {
const countAfterFirst = await countTabsOfKind(page, "draft"); const countAfterFirst = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page); await pressNewTabShortcut(page);
await expect await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countAfterFirst + 1);
.poll(() => countTabsOfKind(page, "draft"))
.toBe(countAfterFirst + 1);
}); });
test("clicking new agent tab creates a draft tab", async ({ page }) => { test("clicking new agent tab creates a draft tab", async ({ page }) => {
@@ -201,12 +199,7 @@ test.describe("Tab transitions (no flash)", () => {
await gotoWorkspace(page, workspaceId); await gotoWorkspace(page, workspaceId);
// Sample tabs at high frequency across the transition // Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition( const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
page,
() => clickNewChat(page),
2_000,
30,
);
// Every snapshot should have at least one tab — no blank/zero-tab frames // Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) { for (const snapshot of snapshots) {
@@ -246,12 +239,7 @@ test.describe("Tab transitions (no flash)", () => {
const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const elapsed = await measureTileTransition( const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
page,
() => clickNewChat(page),
composer,
10_000,
);
// Draft creation is fully in-memory — should be fast // Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion // We use a generous budget here because CI can be slow, but the key assertion
+153
View File
@@ -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();
}
});
});
+5 -5
View File
@@ -57,7 +57,10 @@ async function openProjectViaDaemon(
}; };
} }
async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) { async function openWorkspaceFromSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
) {
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId)); const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 }); await expect(row).toBeVisible({ timeout: 30_000 });
await row.click(); await row.click();
@@ -65,10 +68,7 @@ async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, w
return row; return row;
} }
async function waitForSidebarProject( async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
page: import("@playwright/test").Page,
projectName: string,
) {
const row = page const row = page
.getByRole("button", { .getByRole("button", {
name: new RegExp(escapeRegex(projectName), "i"), name: new RegExp(escapeRegex(projectName), "i"),
+9 -13
View File
@@ -2,14 +2,8 @@ import { execSync } from "node:child_process";
import { realpathSync } from "node:fs"; import { realpathSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { expect, test } from "./fixtures"; import { expect, test } from "./fixtures";
import { import { clickTerminal, waitForTabBar } from "./helpers/launcher";
clickTerminal, import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
waitForTabBar,
} from "./helpers/launcher";
import {
setupDeterministicPrompt,
waitForTerminalContent,
} from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo } from "./helpers/workspace";
import { import {
connectWorkspaceSetupClient, connectWorkspaceSetupClient,
@@ -88,10 +82,13 @@ test.describe("Workspace cwd correctness", () => {
try { try {
await seedProjectForWorkspaceSetup(client, repo.path); await seedProjectForWorkspaceSetup(client, repo.path);
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { execSync(
cwd: repo.path, `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
stdio: "ignore", {
}); cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true; worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath); const workspaceResult = await client.openProject(worktreePath);
@@ -132,5 +129,4 @@ test.describe("Workspace cwd correctness", () => {
await repo.cleanup(); await repo.cleanup();
} }
}); });
}); });
+15 -8
View File
@@ -45,17 +45,21 @@ async function expectScriptInCard(page: Page, scriptName: string): Promise<void>
/** Asserts the script status dot indicates "running". */ /** Asserts the script status dot indicates "running". */
async function expectScriptRunning(page: Page, scriptName: string): Promise<void> { async function expectScriptRunning(page: Page, scriptName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
await expect( await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute(
card.getByTestId(`hover-card-script-status-${scriptName}`), "aria-label",
).toHaveAttribute("aria-label", "Running", { timeout: 10_000 }); "Running",
{ timeout: 10_000 },
);
} }
/** Asserts the script lifecycle is stopped. */ /** Asserts the script lifecycle is stopped. */
async function expectScriptStopped(page: Page, scriptName: string): Promise<void> { async function expectScriptStopped(page: Page, scriptName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card"); const card = page.getByTestId("workspace-hover-card");
await expect( await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute(
card.getByTestId(`hover-card-script-status-${scriptName}`), "aria-label",
).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 }); "Stopped",
{ timeout: 10_000 },
);
} }
/** Asserts the script health label shown in the hover card. */ /** Asserts the script health label shown in the hover card. */
@@ -117,7 +121,8 @@ test.describe("Workspace hover card", () => {
// Wait for setup completion via daemon (setup snapshots are per-session) // Wait for setup completion via daemon (setup snapshots are per-session)
const completed = waitForWorkspaceSetupProgress( const completed = waitForWorkspaceSetupProgress(
client, client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), (payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
); );
const workspace = await createWorkspaceThroughDaemon(client, { const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path, cwd: repo.path,
@@ -182,7 +187,9 @@ test.describe("Workspace hover card", () => {
} }
await openHomeWithProject(page, repo.path); await openHomeWithProject(page, repo.path);
const wsRow = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspace.id}`); const wsRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${workspace.workspace.id}`,
);
await expect(wsRow).toBeVisible({ timeout: 30_000 }); await expect(wsRow).toBeVisible({ timeout: 30_000 });
await expectHoverCard(page, workspace.workspace.name); await expectHoverCard(page, workspace.workspace.name);
+14 -8
View File
@@ -107,10 +107,13 @@ test.describe("Workspace lifecycle", () => {
try { try {
await seedProjectForWorkspaceSetup(client, repo.path); await seedProjectForWorkspaceSetup(client, repo.path);
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { execSync(
cwd: repo.path, `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
stdio: "ignore", {
}); cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true; worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath); const workspaceResult = await client.openProject(worktreePath);
@@ -154,10 +157,13 @@ test.describe("Workspace lifecycle", () => {
try { try {
await seedProjectForWorkspaceSetup(client, repo.path); await seedProjectForWorkspaceSetup(client, repo.path);
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { execSync(
cwd: repo.path, `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
stdio: "ignore", {
}); cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true; worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath); const workspaceResult = await client.openProject(worktreePath);
@@ -1,10 +1,7 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { expect, test } from "./fixtures"; import { expect, test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo } from "./helpers/workspace";
import { import { clickTerminal, waitForTabBar } from "./helpers/launcher";
clickTerminal,
waitForTabBar,
} from "./helpers/launcher";
import { import {
connectWorkspaceSetupClient, connectWorkspaceSetupClient,
createWorkspaceThroughDaemon, createWorkspaceThroughDaemon,
@@ -82,15 +79,11 @@ test.describe("Workspace setup runtime authority", () => {
// Verify terminal is listed under the worktree directory, not the original repo // Verify terminal is listed under the worktree directory, not the original repo
await expect await expect
.poll( .poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, {
async () => timeout: 30_000,
(await client.listTerminals(workspaceDir)).terminals.length > 0, })
{ timeout: 30_000 },
)
.toBe(true); .toBe(true);
expect( expect((await client.listTerminals(repo.path)).terminals.length).toBe(0);
(await client.listTerminals(repo.path)).terminals.length,
).toBe(0);
} finally { } finally {
await client.close(); await client.close();
await repo.cleanup(); await repo.cleanup();
@@ -36,7 +36,9 @@ test.describe("Workspace setup streaming", () => {
const repo = await createTempGitRepo("setup-open-", { const repo = await createTempGitRepo("setup-open-", {
paseoConfig: { paseoConfig: {
worktree: { worktree: {
setup: ["sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'"], setup: [
"sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'",
],
}, },
}, },
}); });
@@ -77,7 +79,8 @@ test.describe("Workspace setup streaming", () => {
// so the browser session won't receive progress events). // so the browser session won't receive progress events).
const completed = waitForWorkspaceSetupProgress( const completed = waitForWorkspaceSetupProgress(
client, client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), (payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
); );
const workspace = await createWorkspaceThroughDaemon(client, { const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path, cwd: repo.path,
@@ -138,7 +141,8 @@ test.describe("Workspace setup streaming", () => {
); );
const completed = waitForWorkspaceSetupProgress( const completed = waitForWorkspaceSetupProgress(
client, client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), (payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
); );
await createWorkspaceThroughDaemon(client, { await createWorkspaceThroughDaemon(client, {
@@ -243,7 +247,8 @@ test.describe("Workspace setup streaming", () => {
// Wait for setup completion via daemon (setup snapshots are per-session) // Wait for setup completion via daemon (setup snapshots are per-session)
const completed = waitForWorkspaceSetupProgress( const completed = waitForWorkspaceSetupProgress(
client, client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), (payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
); );
const workspace = await createWorkspaceThroughDaemon(client, { const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path, cwd: repo.path,
@@ -296,7 +301,8 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path); await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress( const completed = waitForWorkspaceSetupProgress(
client, client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), (payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
); );
const result = await client.createPaseoWorktree({ const result = await client.createPaseoWorktree({
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.54", "version": "0.1.55-rc.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.54", "@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
"@getpaseo/highlight": "0.1.54", "@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/server": "0.1.54", "@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
+31 -7
View File
@@ -83,6 +83,7 @@ import {
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto(); polyfillCrypto();
@@ -92,6 +93,22 @@ export type HostRuntimeBootstrapState = {
retry: () => void; retry: () => void;
}; };
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const firstValue = value[0];
if (typeof firstValue !== "string") {
return undefined;
}
const trimmed = firstValue.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
return undefined;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon", phase: "starting-daemon",
error: null, error: null,
@@ -103,7 +120,7 @@ function PushNotificationRouter() {
const lastHandledIdRef = useRef<string | null>(null); const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") { if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null; let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false; let cancelled = false;
@@ -392,10 +409,10 @@ function AppContainer({
const screenW = UnistylesRuntime.screen.width; const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height; const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime(); const isElectron = getIsElectronRuntime();
const windowW = Platform.OS === "web" ? window.innerWidth : undefined; const windowW = isWeb ? window.innerWidth : undefined;
const windowH = Platform.OS === "web" ? window.innerHeight : undefined; const windowH = isWeb ? window.innerHeight : undefined;
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined; const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = Platform.OS === "web" ? navigator.userAgent : undefined; const ua = isWeb ? navigator.userAgent : undefined;
console.log( console.log(
"[layout-debug]", "[layout-debug]",
@@ -580,7 +597,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [settingsLoading, settings.theme]); }, [settingsLoading, settings.theme]);
useEffect(() => { useEffect(() => {
if (settingsLoading || Platform.OS !== "web") { if (settingsLoading || isNative) {
return; return;
} }
@@ -789,7 +806,14 @@ function RootStack() {
<Stack.Screen name="settings" /> <Stack.Screen name="settings" />
<Stack.Screen name="pair-scan" /> <Stack.Screen name="pair-scan" />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" /> <Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) => {
const serverId = getRouteParamValue(params?.serverId);
const workspaceId = getRouteParamValue(params?.workspaceId);
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
}}
/>
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} /> <Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" /> <Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" /> <Stack.Screen name="h/[serverId]/sessions" />
@@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, usePathname, useRootNavigationState } from "expo-router"; import {
import { Platform } from "react-native"; useGlobalSearchParams,
useLocalSearchParams,
usePathname,
useRootNavigationState,
} from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
@@ -11,6 +15,7 @@ import {
type WorkspaceOpenIntent, type WorkspaceOpenIntent,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string { function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") { if (typeof value === "string") {
@@ -91,7 +96,7 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState // Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the // skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route. // address bar reflects the clean workspace route.
if (Platform.OS === "web" && typeof window !== "undefined") { if (isWeb && typeof window !== "undefined") {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.searchParams.has("open")) { if (url.searchParams.has("open")) {
url.searchParams.delete("open"); url.searchParams.delete("open");
+4 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes"; import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
}, [router, source, sourceServerId, targetServerId]); }, [router, source, sourceServerId, targetServerId]);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") return; if (isWeb) return;
if (permission && permission.granted) return; if (permission && permission.granted) return;
void requestPermission().catch(() => undefined); void requestPermission().catch(() => undefined);
}, [permission, requestPermission]); }, [permission, requestPermission]);
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], [daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
); );
if (Platform.OS === "web") { if (isWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}> <View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
+2 -2
View File
@@ -1,11 +1,11 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types"; import type { AttachmentStore } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
let attachmentStorePromise: Promise<AttachmentStore> | null = null; let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> { async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") { if (isWeb) {
if (isElectronRuntime()) { if (isElectronRuntime()) {
const { createDesktopAttachmentStore } = await import( const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store" "../desktop/attachments/desktop-attachment-store"
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native"; import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -16,6 +16,7 @@ import {
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
desktopOverlay: { desktopOverlay: {
@@ -143,7 +144,7 @@ export function AdaptiveModalSheet({
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
useEffect(() => { useEffect(() => {
if (isMobile || !visible || Platform.OS !== "web" || typeof window === "undefined") return; if (isMobile || !visible || !isWeb || typeof window === "undefined") return;
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -253,16 +254,16 @@ export function AdaptiveModalSheet({
/> />
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}> <View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
{onFilesDropped ? ( {onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}> <FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
{cardInner} ) : (
</FileDropZone> cardInner
) : cardInner} )}
</View> </View>
</View> </View>
); );
// On web, use portal to overlay root for consistent stacking with toasts // On web, use portal to overlay root for consistent stacking with toasts
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
if (!visible) return null; if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot()); return createPortal(desktopContent, getOverlayRoot());
} }
@@ -1,8 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Pressable, Text, View, Platform } from "react-native"; import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
option: { option: {
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
</View> </View>
</Pressable> </Pressable>
{Platform.OS !== "web" ? ( {isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code"> <Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} /> <QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native"; import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme"; import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = { const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck, ShieldCheck,
@@ -168,7 +169,7 @@ export function SelectField({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: unknown) => { (event: unknown) => {
if (Platform.OS !== "web") return; if (isNative) return;
const key = getWebKey(event); const key = getWebKey(event);
if (key === "Enter" || key === " ") { if (key === "Enter" || key === " ") {
preventWebDefault(event); preventWebDefault(event);
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: unknown) => { (event: unknown) => {
if (Platform.OS !== "web") return; if (isNative) return;
const key = getWebKey(event); const key = getWebKey(event);
if (key === "Enter" || key === " ") { if (key === "Enter" || key === " ") {
preventWebDefault(event); preventWebDefault(event);
@@ -556,7 +557,11 @@ export function AgentConfigRow({
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || ""; selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode); const selectedModeVisuals = getModeVisuals(
selectedProvider,
effectiveSelectedMode,
providerDefinitions,
);
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"]; const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"]; const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
+15 -17
View File
@@ -262,23 +262,20 @@ export function AgentList({
[isActionSheetVisible, onAgentSelect], [isActionSheetVisible, onAgentSelect],
); );
const handleAgentLongPress = useCallback( const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
(agent: AggregatedAgent) => { const isRunning = agent.status === "running" || agent.status === "initializing";
const isRunning = agent.status === "running" || agent.status === "initializing"; if (isRunning) {
if (isRunning) { setActionAgent(agent);
setActionAgent(agent); return;
return; }
}
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null; const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) { if (!client) {
setActionAgent(agent); setActionAgent(agent);
return; return;
} }
void client.archiveAgent(agent.id); void client.archiveAgent(agent.id);
}, }, []);
[],
);
const handleCloseActionSheet = useCallback(() => { const handleCloseActionSheet = useCallback(() => {
setActionAgent(null); setActionAgent(null);
@@ -288,7 +285,8 @@ export function AgentList({
if (!actionAgent || !actionClient) { if (!actionAgent || !actionClient) {
return; return;
} }
void actionClient.archiveAgent(actionAgent.id); // Timeout errors are swallowed — the daemon will still process the archive
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
setActionAgent(null); setActionAgent(null);
}, [actionAgent, actionClient]); }, [actionAgent, actionClient]);
@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useRef, useState } from "react"; import { memo, useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native"; import { View, Text, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { import {
buildFavoriteModelKey, buildFavoriteModelKey,
mergeProviderPreferences, mergeProviderPreferences,
@@ -40,7 +41,6 @@ import type {
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { import {
AGENT_PROVIDER_DEFINITIONS,
getModeVisuals, getModeVisuals,
type AgentModeColorTier, type AgentModeColorTier,
type AgentModeIcon, type AgentModeIcon,
@@ -51,6 +51,7 @@ import {
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
type StatusOption = { type StatusOption = {
id: string; id: string;
@@ -59,10 +60,6 @@ type StatusOption = {
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
type ControlledAgentStatusBarProps = { type ControlledAgentStatusBarProps = {
provider: string; provider: string;
providerOptions?: StatusOption[]; providerOptions?: StatusOption[];
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void; onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean; disabled?: boolean;
isModelLoading?: boolean; isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[]; providerDefinitions: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>; allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean; canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>; favoriteKeys?: Set<string>;
@@ -222,7 +219,6 @@ function ControlledStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false); const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null); const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
@@ -252,7 +248,9 @@ function ControlledStatusBar({
thinkingOptions?.[0]?.label ?? "Unknown", thinkingOptions?.[0]?.label ?? "Unknown",
); );
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined; const modeVisuals = selectedModeId
? getModeVisuals(provider, selectedModeId, providerDefinitions)
: undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette); const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider); const ProviderIcon = getProviderIcon(provider);
@@ -300,9 +298,7 @@ function ControlledStatusBar({
); );
return map; return map;
}, [modelOptions, provider]); }, [modelOptions, provider]);
const effectiveProviderDefinitions = const effectiveProviderDefinitions = providerDefinitions;
providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels; const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true); const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>( const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
@@ -322,7 +318,7 @@ function ControlledStatusBar({
active: boolean; active: boolean;
onPress: () => void; onPress: () => void;
}) => { }) => {
const visuals = getModeVisuals(provider, option.id); const visuals = getModeVisuals(provider, option.id, providerDefinitions);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return ( return (
<ComboboxItem <ComboboxItem
@@ -334,7 +330,7 @@ function ControlledStatusBar({
/> />
); );
}, },
[provider, theme.colors.foreground], [provider, providerDefinitions, theme.colors.foreground],
); );
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
@@ -356,7 +352,7 @@ function ControlledStatusBar({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isWeb ? ( {platformIsWeb ? (
<> <>
{providerOptions && providerOptions.length > 0 ? ( {providerOptions && providerOptions.length > 0 ? (
<> <>
@@ -745,7 +741,7 @@ function ControlledStatusBar({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => { {modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id); const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return ( return (
<DropdownMenuItem <DropdownMenuItem
@@ -896,9 +892,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const models = snapshotModels; const models = snapshotModels;
const agentProviderDefinitions = useMemo(() => { const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); const definition = agent?.provider
? resolveProviderDefinition(agent.provider, snapshotEntries)
: undefined;
return definition ? [definition] : []; return definition ? [definition] : [];
}, [agent?.provider]); }, [agent?.provider, snapshotEntries]);
const agentProviderModels = useMemo(() => { const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
@@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => { const mappedModeOptions = useMemo<StatusOption[]>(() => {
@@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) { if (platformIsWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<CombinedModelSelector <CombinedModelSelector
@@ -1129,6 +1126,7 @@ export function DraftAgentStatusBar({
/> />
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={mappedModeOptions} modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode} selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode} onSelectMode={onSelectMode}
@@ -74,6 +74,7 @@ import {
WORKING_INDICATOR_CYCLE_MS, WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS, WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator"; } from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
@@ -222,7 +223,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
tail: streamItems, tail: streamItems,
head: streamHead ?? [], head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native", platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [isMobile, streamHead, streamItems]); }, [isMobile, streamHead, streamItems]);
@@ -4,17 +4,17 @@ import {
Text, Text,
TextInput, TextInput,
Pressable, Pressable,
Platform,
ActivityIndicator, ActivityIndicator,
type GestureResponderEvent, type GestureResponderEvent,
} from "react-native"; } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = Platform.OS === "web"; const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
@@ -349,7 +349,7 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput; const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) { if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, 50); }, 50);
@@ -363,7 +363,7 @@ function ProviderSearchInput({
<InputComponent <InputComponent
ref={inputRef as any} ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]} style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
placeholder="Search models..." placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={value} value={value}
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
disabled = false, disabled = false,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb); const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" }); const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -601,7 +600,7 @@ export function CombinedModelSelector({
}, [selectedModelLabel]); }, [selectedModelLabel]);
useEffect(() => { useEffect(() => {
if (isWeb) { if (platformIsWeb) {
return; return;
} }
@@ -615,7 +614,7 @@ export function CombinedModelSelector({
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]); }, [isOpen, platformIsWeb]);
return ( return (
<> <>
@@ -678,7 +677,7 @@ export function CombinedModelSelector({
<ProviderSearchInput <ProviderSearchInput
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"} autoFocus={platformIsWeb}
/> />
</View> </View>
) : undefined ) : undefined
@@ -793,8 +792,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
level2Header: { level2Header: {},
},
backButton: { backButton: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -1,4 +1,4 @@
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react"; import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native"; import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; return `${agent.serverId}:${agent.id}`;
@@ -90,7 +91,7 @@ export function CommandCenter() {
} }
}, [activeIndex, open]); }, [activeIndex, open]);
if (Platform.OS !== "web" || !open) return null; if (isNative || !open) return null;
const actionItems = items.filter((item) => item.kind === "action"); const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent"); const agentItems = items.filter((item) => item.kind === "agent");
+13 -7
View File
@@ -1,4 +1,4 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit"; import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -126,7 +127,7 @@ export function Composer({
}: ComposerProps) { }: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -164,7 +165,7 @@ export function Composer({
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; const isDesktopWebBreakpoint = isWeb && !isMobile;
const messagePlaceholder = isDesktopWebBreakpoint const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER ? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER; : MOBILE_MESSAGE_PLACEHOLDER;
@@ -225,7 +226,7 @@ export function Composer({
}, [addImages, onAddImages]); }, [addImages, onAddImages]);
const focusInput = useCallback(() => { const focusInput = useCallback(() => {
if (Platform.OS !== "web") return; if (isNative) return;
focusWithRetries({ focusWithRetries({
focus: () => messageInputRef.current?.focus(), focus: () => messageInputRef.current?.focus(),
isFocused: () => { isFocused: () => {
@@ -438,7 +439,7 @@ export function Composer({
case "message-input.dictation-confirm": case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus": case "message-input.focus":
if (Platform.OS !== "web") { if (isNative) {
messageInputRef.current?.focus(); messageInputRef.current?.focus();
return true; return true;
} }
@@ -632,14 +633,19 @@ export function Composer({
style={({ hovered }) => [ style={({ hovered }) => [
styles.realtimeVoiceButton as any, styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any, (hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any, (!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]} ]}
> >
{({ hovered }) => {({ hovered }) =>
voice?.isVoiceSwitching ? ( voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<AudioLines size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> <AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) )
} }
</TooltipTrigger> </TooltipTrigger>
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
/** /**
* VS Code-style titlebar drag region for Electron. * VS Code-style titlebar drag region for Electron.
@@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
* Place as FIRST child of any positioned container that should be draggable. * Place as FIRST child of any positioned container that should be draggable.
*/ */
export function TitlebarDragRegion() { export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) { if (isNative || !getIsElectronRuntime()) {
return null; return null;
} }
+4 -3
View File
@@ -1,12 +1,13 @@
import React from "react"; import React from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { getCodeInsets } from "./code-insets"; import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps { interface DiffViewerProps {
diffLines: DiffLine[]; diffLines: DiffLine[];
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
@@ -3,7 +3,6 @@ import {
View, View,
Text, Text,
Pressable, Pressable,
Platform,
useWindowDimensions, useWindowDimensions,
StyleSheet as RNStyleSheet, StyleSheet as RNStyleSheet,
} from "react-native"; } from "react-native";
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {} function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
// Mobile: full-screen overlay with gesture. // Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps. // On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused // Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces. // screens so only the active screen exposes explorer/terminal surfaces.
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}> <View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */} {/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector> </GestureDetector>
<SidebarContent <SidebarContent
@@ -1,10 +1,11 @@
import { View, Text, Platform } from "react-native"; import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react"; import { useEffect } from "react";
import { Upload } from "lucide-react-native"; import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone"; import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input"; import type { ImageAttachment } from "./message-input";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps { interface FileDropZoneProps {
children: React.ReactNode; children: React.ReactNode;
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
disabled?: boolean; disabled?: boolean;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) { export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -7,7 +7,6 @@ import {
Pressable, Pressable,
Text, Text,
View, View,
Platform,
} from "react-native"; } from "react-native";
import { Gesture } from "react-native-gesture-handler"; import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -52,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" }, { value: "name", label: "Name" },
@@ -91,7 +91,7 @@ export function FileExplorerPane({
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const daemons = useHosts(); const daemons = useHosts();
const daemonProfile = useMemo( const daemonProfile = useMemo(
+2 -2
View File
@@ -6,7 +6,6 @@ import {
ScrollView as RNScrollView, ScrollView as RNScrollView,
Text, Text,
View, View,
Platform,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -21,6 +20,7 @@ import {
type HighlightStyle, type HighlightStyle,
} from "@getpaseo/highlight"; } from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets"; import { lineNumberGutterWidth } from "@/components/code-insets";
import { isWeb } from "@/constants/platform";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
@@ -243,7 +243,7 @@ export function FilePane({
filePath: string; filePath: string;
}) { }) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
@@ -7,7 +7,6 @@ import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
FlatList, FlatList,
Platform,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -80,6 +79,7 @@ import {
formatDiffGutterText, formatDiffGutterText,
hasVisibleDiffTokens, hasVisibleDiffTokens,
} from "@/utils/diff-rendering"; } from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -100,7 +100,7 @@ type WrappedWebTextStyle = TextStyle & {
}; };
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") { if (isNative) {
return undefined; return undefined;
} }
return wrapLines return wrapLines
@@ -454,7 +454,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
}} }}
onPressOut={(event) => { onPressOut={(event) => {
if ( if (
Platform.OS !== "web" && isNative &&
!pressHandledRef.current && !pressHandledRef.current &&
layoutYRef.current === 0 && layoutYRef.current === 0 &&
pressInRef.current pressInRef.current
@@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile; const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter(); const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
@@ -1,16 +1,10 @@
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
Platform,
Text,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
interface HeaderToggleButtonState { interface HeaderToggleButtonState {
hovered: boolean; hovered: boolean;
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
: undefined; : undefined;
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded; const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
const ariaExpandedProps = const ariaExpandedProps =
Platform.OS === "web" && typeof expandedState === "boolean" isWeb && typeof expandedState === "boolean"
? ({ "aria-expanded": expandedState } as any) ? ({ "aria-expanded": expandedState } as any)
: null; : null;
@@ -8,9 +8,7 @@ interface AiderIconProps {
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) { export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
return ( return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}> <Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path <Path d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z" />
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
/>
</Svg> </Svg>
); );
} }
+3 -8
View File
@@ -14,7 +14,6 @@ import {
View, View,
Pressable, Pressable,
Text, Text,
Platform,
useWindowDimensions, useWindowDimensions,
StyleSheet as RNStyleSheet, StyleSheet as RNStyleSheet,
} from "react-native"; } from "react-native";
@@ -59,6 +58,7 @@ import {
parseServerIdFromPathname, parseServerIdFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
@@ -527,7 +527,7 @@ function MobileSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
})); }));
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -833,12 +833,7 @@ function DesktopSidebar({
{/* Resize handle - absolutely positioned over right border */} {/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector> </GestureDetector>
</View> </View>
</Animated.View> </Animated.View>
+54 -32
View File
@@ -9,7 +9,6 @@ import {
TextInputKeyPressEventData, TextInputKeyPressEventData,
TextInputSelectionChangeEventData, TextInputSelectionChangeEventData,
Image, Image,
Platform,
BackHandler, BackHandler,
} from "react-native"; } from "react-native";
import { import {
@@ -48,6 +47,7 @@ import {
markScrollInvestigationEvent, markScrollInvestigationEvent,
markScrollInvestigationRender, markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation"; } from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform";
export type ImageAttachment = AttachmentMetadata; export type ImageAttachment = AttachmentMetadata;
@@ -121,14 +121,16 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT_MOBILE = 30; const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46; const MIN_INPUT_HEIGHT_DESKTOP = 46;
const MAX_INPUT_HEIGHT = 160; const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web"; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
const MIN_INPUT_HEIGHT = IS_WEB ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent< type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & { TextInputKeyPressEventData & {
metaKey?: boolean; metaKey?: boolean;
ctrlKey?: boolean; ctrlKey?: boolean;
shiftKey?: boolean; shiftKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
} }
>; >;
@@ -232,7 +234,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref, ref,
) { ) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
markScrollInvestigationRender(investigationComponentId); markScrollInvestigationRender(investigationComponentId);
const toast = useToast(); const toast = useToast();
@@ -303,7 +305,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return false; return false;
}, },
getNativeElement: () => { getNativeElement: () => {
if (!IS_WEB) return null; if (!isWeb) return null;
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null; const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null; return native instanceof HTMLElement ? native : null;
@@ -338,7 +340,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Autofocus on web when autoFocus is true, and re-run when focus key changes. // Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => { useEffect(() => {
if (!IS_WEB || !autoFocus) return; if (!isWeb || !autoFocus) return;
return focusWithRetries({ return focusWithRetries({
focus: () => textInputRef.current?.focus(), focus: () => textInputRef.current?.focus(),
isFocused: () => { isFocused: () => {
@@ -381,7 +383,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText(nextValue); onChangeText(nextValue);
} }
if (IS_WEB && typeof requestAnimationFrame === "function") { if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => { requestAnimationFrame(() => {
measureWebInputHeight("dictation"); measureWebInputHeight("dictation");
}); });
@@ -637,13 +639,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const webTextareaRef = useRef<HTMLElement | null>(null); const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
if (IS_WEB) { if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null; webTextareaRef.current = getWebTextArea() as HTMLElement | null;
} }
}, [getWebTextArea]); }, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, { const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT, enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
}); });
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
@@ -657,7 +659,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!IS_WEB || !onAddImages) { if (!isWeb || !onAddImages) {
return; return;
} }
@@ -710,7 +712,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]); ]);
useEffect(() => { useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === "undefined") { if (!isWeb || typeof ResizeObserver === "undefined") {
return; return;
} }
@@ -764,7 +766,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebElement, getWebTextArea]); }, [getWebElement, getWebTextArea]);
useEffect(() => { useEffect(() => {
if (!IS_WEB) { if (!isWeb) {
return; return;
} }
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
@@ -802,7 +804,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebTextArea]); }, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean { function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false; if (!isWeb) return false;
const textarea = getWebTextArea(); const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false; if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0; const scrollHeight = textarea.scrollHeight ?? 0;
@@ -856,7 +858,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>, event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) { ) {
const contentHeight = event.nativeEvent.contentSize.height; const contentHeight = event.nativeEvent.contentSize.height;
if (IS_WEB) { if (isWeb) {
logWebStickyBottom("composer_content_size_change", { logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight, reportedHeight: contentHeight,
}); });
@@ -876,7 +878,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) { function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0; const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start; const end = event.nativeEvent.selection?.end ?? start;
if (IS_WEB) { if (isWeb) {
const textarea = getWebTextArea(); const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", { logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(), now: getDebugNow(),
@@ -890,12 +892,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChangeCallback?.({ start, end }); onSelectionChangeCallback?.({ start, end });
} }
const shouldHandleDesktopSubmit = IS_WEB; const shouldHandleDesktopSubmit = isWeb;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, "keyPress"); markScrollInvestigationEvent(investigationComponentId, "keyPress");
if (!shouldHandleDesktopSubmit) return; if (!shouldHandleDesktopSubmit) return;
// IME composition in progress (e.g. CJK input) — all key events belong to the
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
// cleared before the keydown fires.
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation) // Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) { if (onKeyPressCallback) {
const handled = onKeyPressCallback({ const handled = onKeyPressCallback({
@@ -930,7 +937,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const hasRealContent = value.trim().length > 0 || hasImages; const hasRealContent = value.trim().length > 0 || hasImages;
const hasSendableContent = hasRealContent || allowEmptySubmit; const hasSendableContent = hasRealContent || allowEmptySubmit;
const shouldShowSendButton = hasSendableContent || isSubmitLoading; const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const showEmptySubmitLabel = allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading; const showEmptySubmitLabel =
allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled = const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
@@ -947,7 +955,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(nextValue: string) => { (nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange"); markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue); onChangeText(nextValue);
if (IS_WEB) { if (isWeb) {
logWebStickyBottom("composer_text_changed", { logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length, valueLength: nextValue.length,
lineCount: nextValue.split("\n").length, lineCount: nextValue.split("\n").length,
@@ -960,7 +968,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return ( return (
<View ref={rootRef} style={styles.container} testID="message-input-root"> <View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */} {/* Regular input */}
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}> <Animated.View
ref={inputWrapperRef}
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
>
{/* Image preview pills */} {/* Image preview pills */}
{hasImages && ( {hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview"> <View style={styles.imagePreviewContainer} testID="message-input-image-preview">
@@ -978,7 +989,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View <View
style={[ style={[
styles.removeImageButton, styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible, (hovered || !isWeb) && styles.removeImageButtonVisible,
]} ]}
> >
<X size={theme.iconSize.md} color="white" /> <X size={theme.iconSize.md} color="white" />
@@ -1010,7 +1021,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}} }}
style={[ style={[
styles.textInput, styles.textInput,
IS_WEB isWeb
? { ? {
height: inputHeight, height: inputHeight,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
@@ -1022,12 +1033,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, },
]} ]}
multiline multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus} autoFocus={isWeb && autoFocus}
/> />
{inputScrollbar} {inputScrollbar}
</View> </View>
@@ -1051,7 +1062,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]} ]}
> >
{({ hovered }) => ( {({ hovered }) => (
<Paperclip size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> <Paperclip
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)} )}
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
@@ -1091,9 +1105,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
isDictating ? ( isDictating ? (
<Square size={buttonIconSize} color="white" fill="white" /> <Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> <MicOff
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : ( ) : (
<Mic size={buttonIconSize} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> <Mic
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) )
} }
</TooltipTrigger> </TooltipTrigger>
@@ -1150,7 +1170,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipTrigger <TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction} onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
disabled={isSendButtonDisabled} disabled={isSendButtonDisabled}
accessibilityLabel={showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel} accessibilityLabel={
showEmptySubmitLabel ? emptySubmitLabel : submitAccessibilityLabel
}
accessibilityRole="button" accessibilityRole="button"
style={[ style={[
showEmptySubmitLabel ? styles.emptySubmitButton : styles.sendButton, showEmptySubmitLabel ? styles.emptySubmitButton : styles.sendButton,
@@ -1233,7 +1255,7 @@ const styles = StyleSheet.create(((theme: any) => ({
xs: theme.spacing[3], xs: theme.spacing[3],
md: theme.spacing[4], md: theme.spacing[4],
}, },
...(IS_WEB ...(isWeb
? { ? {
transitionProperty: "border-color", transitionProperty: "border-color",
transitionDuration: "200ms", transitionDuration: "200ms",
@@ -1252,7 +1274,7 @@ const styles = StyleSheet.create(((theme: any) => ({
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
overflow: "hidden", overflow: "hidden",
...(IS_WEB ...(isWeb
? { ? {
cursor: "pointer", cursor: "pointer",
} }
@@ -1277,7 +1299,7 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "center", justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0, opacity: 0,
...(IS_WEB ...(isWeb
? { ? {
transitionProperty: "opacity", transitionProperty: "opacity",
transitionDuration: "150ms", transitionDuration: "150ms",
@@ -1296,7 +1318,7 @@ const styles = StyleSheet.create(((theme: any) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4, lineHeight: theme.fontSize.base * 1.4,
...(IS_WEB ...(isWeb
? { ? {
outlineStyle: "none" as const, outlineStyle: "none" as const,
outlineWidth: 0, outlineWidth: 0,
@@ -1318,7 +1340,7 @@ const styles = StyleSheet.create(((theme: any) => ({
rightButtonGroup: { rightButtonGroup: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], gap: isWeb ? theme.spacing[2] : theme.spacing[1],
}, },
attachButton: { attachButton: {
width: 28, width: 28,
+14 -13
View File
@@ -7,7 +7,6 @@ import {
type LayoutChangeEvent, type LayoutChangeEvent,
StyleProp, StyleProp,
ViewStyle, ViewStyle,
Platform,
} from "react-native"; } from "react-native";
import * as React from "react"; import * as React from "react";
import { import {
@@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
interface UserMessageProps { interface UserMessageProps {
message: string; message: string;
@@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y"; type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() { function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
if (typeof document === "undefined") { if (typeof document === "undefined") {
@@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true, isLastInGroup = true,
disableOuterSpacing, disableOuterSpacing,
}: UserMessageProps) { }: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false); const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0; const hasText = message.trim().length > 0;
const hasImages = images.length > 0; const hasImages = images.length > 0;
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
return ( return (
<View <View
@@ -361,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
> >
<Pressable <Pressable
style={userMessageStylesheet.content} style={userMessageStylesheet.content}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined} onHoverIn={() => setMessageHovered(true)}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined} onHoverOut={() => setMessageHovered(false)}
> >
<View style={userMessageStylesheet.bubble}> <View style={userMessageStylesheet.bubble}>
{hasImages ? ( {hasImages ? (
@@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: 13, fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto", userSelect: isWeb ? "text" : "auto",
}, },
imageFrame: { imageFrame: {
width: "100%", width: "100%",
@@ -657,7 +658,7 @@ function MarkdownLink({
children: ReactNode; children: ReactNode;
}) { }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") { if (isNative) {
return ( return (
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}> <Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children} {children}
@@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return ( return (
<Pressable <Pressable
onPress={handleCopy} onPress={handleCopy}
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined} onHoverIn={() => onHoverChange?.(true)}
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined} onHoverOut={() => onHoverChange?.(false)}
style={[turnCopyButtonStylesheet.container, containerStyle]} style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
@@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text <Text
key={node.key} key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)} onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={Platform.OS === "web" ? undefined : false} selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
> >
{content} {content}
@@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
32, 32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0), Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
); );
const isWebShimmer = isLoading && Platform.OS === "web"; const isWebShimmer = isLoading && isWeb;
const shouldMeasureWebShimmer = isWebShimmer; const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; const shouldMeasureNativeShimmer = isLoading && isNative;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel const webShimmerSpanEndX = secondaryLabel
@@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]); }, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { if (isNative || !isExpanded || !hasDetailContent) {
return; return;
} }
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes"; import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
if (!open || Platform.OS !== "web") return; if (!open || isNative) return;
function handler(event: KeyboardEvent) { function handler(event: KeyboardEvent) {
const key = event.key; const key = event.key;
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native"; import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
interface ProviderDiagnosticSheetProps { interface ProviderDiagnosticSheetProps {
provider: string; provider: string;
@@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({
}: ProviderDiagnosticSheetProps) { }: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null); const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const providerLabel = const providerLabel = resolveProviderLabel(provider, snapshotEntries);
AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
const fetchDiagnostic = useCallback(async () => { const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return; if (!client || !provider) return;
@@ -1,10 +1,11 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native"; import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import { isWeb } from "@/constants/platform";
interface QuestionOption { interface QuestionOption {
label: string; label: string;
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
isResponding: boolean; isResponding: boolean;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -50,7 +50,10 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { buildHostNewWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import {
buildHostNewWorkspaceRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { import {
type SidebarProjectEntry, type SidebarProjectEntry,
@@ -99,6 +102,7 @@ import {
import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -214,18 +218,12 @@ export function PrBadge({ hint }: { hint: PrHint }) {
hitSlop={4} hitSlop={4}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPress={handlePress} onPress={handlePress}
onPointerEnter={() => setIsHovered(true)} onHoverIn={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)} onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [ style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
prBadgeStyles.badge,
pressed && prBadgeStyles.badgePressed,
]}
> >
<GitPullRequest size={12} color={activeColor} /> <GitPullRequest size={12} color={activeColor} />
<Text <Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
style={[prBadgeStyles.text, { color: activeColor }]}
numberOfLines={1}
>
#{hint.number} #{hint.number}
</Text> </Text>
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} /> <ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
@@ -281,7 +279,6 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
}, },
})); }));
function WorkspaceStatusIndicator({ function WorkspaceStatusIndicator({
bucket, bucket,
workspaceKind, workspaceKind,
@@ -613,7 +610,7 @@ function useLongPressDragInteraction(input: {
input.drag(); input.drag();
}, DRAG_ARM_DELAY_MS); }, DRAG_ARM_DELAY_MS);
if (!input.menuController || Platform.OS === "web") { if (!input.menuController || platformIsWeb) {
return; return;
} }
@@ -778,7 +775,9 @@ function ProjectHeaderRow({
if (!serverId) { if (!serverId) {
return; return;
} }
router.navigate(buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any); router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any,
);
onWorkspacePress?.(); onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]); }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
@@ -836,15 +835,18 @@ function ProjectHeaderRow({
<NewWorktreeButton <NewWorktreeButton
displayName={displayName} displayName={displayName}
onPress={handleBeginWorkspaceSetup} onPress={handleBeginWorkspaceSetup}
visible={isHovered || isMobileBreakpoint} visible={isHovered || platformIsNative || isMobileBreakpoint}
showShortcutHint={isProjectActive} showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`} testID={`sidebar-project-new-worktree-${project.projectKey}`}
/> />
) : null} ) : null}
{onRemoveProject ? ( {onRemoveProject ? (
<View <View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden} style={
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"} !(isHovered || platformIsNative || isMobileBreakpoint) &&
styles.projectKebabButtonHidden
}
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
> >
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -954,8 +956,9 @@ function WorkspaceRowInner({
archiveShortcutKeys, archiveShortcutKeys,
}: WorkspaceRowInnerProps) { }: WorkspaceRowInnerProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web"; const isTouchPlatform = platformIsNative;
const workspaceDirectory = resolveWorkspaceExecutionDirectory({ const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
}); });
@@ -1080,7 +1083,9 @@ function WorkspaceRowInner({
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`} testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />} leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null} trailing={
archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null
}
status={archiveStatus} status={archiveStatus}
pendingLabel={archivePendingLabel} pendingLabel={archivePendingLabel}
onSelect={onArchive} onSelect={onArchive}
@@ -1870,7 +1875,6 @@ export function SidebarWorkspaceList({
parentGestureRef, parentGestureRef,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname(); const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set()); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -2104,7 +2108,7 @@ export function SidebarWorkspaceList({
drag={drag} drag={drag}
isDragging={isActive} isDragging={isActive}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
useNestable={isNative} useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds} creatingWorkspaceIds={creatingWorkspaceIds}
/> />
); );
@@ -2121,7 +2125,7 @@ export function SidebarWorkspaceList({
serverId, serverId,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
isNative, platformIsNative,
creatingWorkspaceIds, creatingWorkspaceIds,
], ],
); );
@@ -2145,7 +2149,7 @@ export function SidebarWorkspaceList({
onDragEnd={handleProjectDragEnd} onDragEnd={handleProjectDragEnd}
scrollEnabled={false} scrollEnabled={false}
useDragHandle useDragHandle
nestable={isNative} nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer} containerStyle={styles.projectListContainer}
/> />
@@ -2156,7 +2160,7 @@ export function SidebarWorkspaceList({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isNative ? ( {platformIsNative ? (
<NestableScrollContainer <NestableScrollContainer
style={styles.list} style={styles.list}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
@@ -27,7 +27,7 @@ import {
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native"; import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -69,6 +69,7 @@ import {
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity"; import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps { interface SplitContainerProps {
layout: WorkspaceLayout; layout: WorkspaceLayout;
@@ -828,7 +829,7 @@ function SplitPaneView({
); );
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
+4 -3
View File
@@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import { import {
@@ -234,8 +235,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none"> <View style={styles.container} pointerEvents="box-none">
<Animated.View <Animated.View
testID={toast.testID ?? "app-toast"} testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss} onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={resumeDismiss} onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[ style={[
styles.toast, styles.toast,
toast.variant === "success" ? styles.toastSuccess : null, toast.variant === "success" ? styles.toastSuccess : null,
@@ -265,7 +266,7 @@ export function ToastViewport({
</View> </View>
); );
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") { if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot()); return createPortal(content, getOverlayRoot());
} }
@@ -1,5 +1,5 @@
import React, { useMemo, ReactNode } from "react"; import React, { useMemo, ReactNode } from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
@@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { DiffViewer } from "./diff-viewer"; import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets"; import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; const ScrollView = isWeb ? RNScrollView : GHScrollView;
// ---- Content Component ---- // ---- Content Component ----
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
lineHeight: 18, lineHeight: 18,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
+65 -27
View File
@@ -37,8 +37,9 @@ import {
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export type ComboboxOption = ComboboxOptionModel; export type ComboboxOption = ComboboxOptionModel;
@@ -107,6 +108,7 @@ export interface SearchInputProps {
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
onSubmitEditing?: () => void; onSubmitEditing?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
useBottomSheetInput?: boolean;
} }
export function SearchInput({ export function SearchInput({
@@ -115,10 +117,11 @@ export function SearchInput({
onChangeText, onChangeText,
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
useBottomSheetInput = false,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -264,9 +267,8 @@ export function Combobox({
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false); const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
@@ -274,11 +276,13 @@ export function Combobox({
null, null,
); );
const [referenceWidth, setReferenceWidth] = useState<number | null>(null); const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null); const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1); const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null); const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
const isControlled = typeof open === "boolean"; const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
@@ -322,8 +326,8 @@ export function Combobox({
const middleware = useMemo( const middleware = useMemo(
() => [ () => [
floatingOffset(Platform.OS === "web" ? 5 : 4), floatingOffset(isWeb ? 5 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), ...(isWeb ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({ floatingSize({
padding: collisionPadding, padding: collisionPadding,
@@ -336,6 +340,9 @@ export function Combobox({
}); });
setReferenceWidth((prev) => { setReferenceWidth((prev) => {
const next = rects.reference.width; const next = rects.reference.width;
if (!(next > 0)) {
return prev;
}
if (prev === next) return prev; if (prev === next) return prev;
return next; return next;
}); });
@@ -346,7 +353,7 @@ export function Combobox({
); );
const { refs, floatingStyles, update } = useFloating({ const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", placement: isWeb ? desktopPlacement : "bottom-start",
middleware, middleware,
sameScrollView: false, sameScrollView: false,
elements: { elements: {
@@ -357,15 +364,18 @@ export function Combobox({
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setAvailableSize(null); setAvailableSize(null);
setDesktopContentWidth(null);
setReferenceLeft(null);
setReferenceWidth(null); setReferenceWidth(null);
return; return;
} }
const raf = requestAnimationFrame(() => void update()); const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]); }, [desktopPlacement, isMobile, isOpen, update]);
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setReferenceLeft(null);
setReferenceAtOrigin(false); setReferenceAtOrigin(false);
setReferenceTop(null); setReferenceTop(null);
return; return;
@@ -379,9 +389,16 @@ export function Combobox({
} }
const measure = () => { const measure = () => {
referenceEl.measureInWindow((x, y) => { referenceEl.measureInWindow((x, y, width, height) => {
setReferenceLeft((prev) => (prev === x ? prev : x));
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y)); setReferenceTop((prev) => (prev === y ? prev : y));
setReferenceWidth((prev) => {
if (!(width > 0)) {
return prev;
}
return prev === width ? prev : width;
});
}); });
}; };
@@ -396,32 +413,46 @@ export function Combobox({
isDesktopAboveSearch && referenceTop !== null isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding) ? Math.max(windowHeight - referenceTop, collisionPadding)
: null; : null;
const hasResolvedDesktopPosition = const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
referenceWidth !== null && const useMeasuredTopStartPosition =
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
// For top-placed popups: once position resolves, use bottom-based CSS positioning
// so height changes grow upward naturally without floating-ui needing to reposition.
const useStableBottom =
!isDesktopAboveSearch && !isDesktopAboveSearch &&
IS_WEB && IS_WEB &&
!isMobile && !isMobile &&
hasResolvedDesktopPosition && desktopPlacement === "top-start" &&
desktopPlacement.startsWith("top") && referenceTop !== null &&
referenceTop !== null; referenceLeft !== null &&
desktopContentWidth !== null;
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
? Math.max(
collisionPadding,
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
)
: null;
const measuredTopStartBottom = useMeasuredTopStartPosition
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
referenceWidth > 0 &&
(isDesktopAboveSearch
? floatingLeft !== null && desktopAboveSearchBottom !== null
: useMeasuredTopStartPosition
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
: floatingLeft !== null &&
floatingTop !== null &&
(hasNonZeroFloatingPosition || !referenceAtOrigin));
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch const desktopPositionStyle = isDesktopAboveSearch
? { ? {
left: floatingLeft ?? 0, left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0, bottom: desktopAboveSearchBottom ?? 0,
} }
: useStableBottom : useMeasuredTopStartPosition
? { ? {
left: floatingLeft ?? 0, left: clampedMeasuredTopStartLeft ?? 0,
bottom: Math.max(windowHeight - referenceTop!, collisionPadding), bottom: measuredTopStartBottom ?? 0,
} }
: floatingStyles; : floatingStyles;
@@ -626,6 +657,7 @@ export function Combobox({
onChangeText={setSearchQueryWithCallback} onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch} onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile} autoFocus={!isMobile}
useBottomSheetInput={isMobile}
/> />
); );
@@ -725,7 +757,13 @@ export function Combobox({
]} ]}
ref={refs.setFloating} ref={refs.setFloating}
collapsable={false} collapsable={false}
onLayout={() => update()} onLayout={(event) => {
const { width, height } = event.nativeEvent.layout;
setDesktopContentWidth((prev) => (prev === width ? prev : width));
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
void update();
}
}}
> >
{children ? ( {children ? (
<> <>
@@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb, isNative } from "@/constants/platform";
// Keep parity with dropdown-menu action statuses. // Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success"; export type ActionStatus = "idle" | "pending" | "success";
@@ -254,8 +255,7 @@ export function ContextMenuTrigger({
>): ReactElement { >): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger"); const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform = const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback( const openAtEvent = useCallback(
(event: unknown) => { (event: unknown) => {
@@ -294,7 +294,7 @@ export function ContextMenuTrigger({
disabled={disabled} disabled={disabled}
delayLongPress={longPressDelayMs} delayLongPress={longPressDelayMs}
onLongPress={(event) => { onLongPress={(event) => {
if (Platform.OS === "web") { if (isWeb) {
props.onLongPress?.(event); props.onLongPress?.(event);
return; return;
} }
@@ -303,7 +303,7 @@ export function ContextMenuTrigger({
}} }}
// @ts-ignore - onContextMenu is web-only and not in RN types. // @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => { onContextMenu={(event: unknown) => {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
const e: any = event; const e: any = event;
+11 -22
View File
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right"; type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end"; type Align = "start" | "center" | "end";
@@ -108,18 +110,6 @@ function measureElement(element: View): Promise<Rect> {
}); });
} }
function isMobileTooltipEnvironment(): boolean {
if (Platform.OS !== "web") {
return true;
}
if (typeof navigator === "undefined") {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
}
function computePosition({ function computePosition({
triggerRect, triggerRect,
contentSize, contentSize,
@@ -227,8 +217,8 @@ export function Tooltip({
onOpenChange, onOpenChange,
}); });
const isMobile = isMobileTooltipEnvironment(); const isCompact = useIsCompactFormFactor();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop; const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>( const value = useMemo<TooltipContextValue>(
() => ({ () => ({
@@ -236,10 +226,10 @@ export function Tooltip({
setOpen: setIsOpen, setOpen: setIsOpen,
triggerRef, triggerRef,
enabled, enabled,
openOnPress: isMobile, openOnPress: isCompact,
delayDuration, delayDuration,
}), }),
[isOpen, setIsOpen, enabled, isMobile, delayDuration], [isOpen, setIsOpen, enabled, isCompact, delayDuration],
); );
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>; return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -257,10 +247,9 @@ export function TooltipTrigger({
triggerRefProp = "ref", triggerRefProp = "ref",
...props ...props
}: PressableProps & { }: PressableProps & {
asChild?: boolean; asChild?: boolean;
triggerRefProp?: string; triggerRefProp?: string;
} }): ReactElement {
): ReactElement {
const ctx = useTooltipContext("TooltipTrigger"); const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -353,7 +342,7 @@ export function TooltipTrigger({
onFocus: handleFocus, onFocus: handleFocus,
onBlur: handleBlur, onBlur: handleBlur,
onPress: handlePress, onPress: handlePress,
...(Platform.OS === "web" ...(isWeb
? ({ ? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable. // RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn, onPointerEnter: handleHoverIn,
@@ -472,7 +461,7 @@ export function TooltipContent({
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can // On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops. // exact same positioning math as DropdownMenu, without hover feedback loops.
if (Platform.OS === "web") { if (isWeb) {
return ( return (
<Portal hostName={bottomSheetInternal?.hostName}> <Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}> <View pointerEvents="none" style={styles.portalOverlay}>
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import { import {
Platform,
type FlatList, type FlatList,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
useWebDesktopScrollbarMetrics, useWebDesktopScrollbarMetrics,
type ScrollbarMetrics, type ScrollbarMetrics,
} from "./web-desktop-scrollbar"; } from "./web-desktop-scrollbar";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5; const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
contentRef?: RefObject<HTMLElement | null>; contentRef?: RefObject<HTMLElement | null>;
}, },
): ReactNode { ): ReactNode {
const isWeb = Platform.OS === "web"; const enabled = (options?.enabled ?? true) && platformIsWeb;
const enabled = (options?.enabled ?? true) && isWeb;
const contentRef = options?.contentRef; const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({ const [metrics, setMetrics] = useState<ScrollbarMetrics>({
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>, scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean }, options?: { enabled?: boolean },
): WebScrollViewScrollbar { ): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web"; const enabled = (options?.enabled ?? true) && platformIsWeb;
const enabled = (options?.enabled ?? true) && isWeb;
const metricsHook = useWebDesktopScrollbarMetrics(); const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback( const onScrollToOffset = useCallback(
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
PanResponder, PanResponder,
Platform,
View, View,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
computeScrollOffsetFromDragDelta, computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry, computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math"; } from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5; const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6; const HANDLE_WIDTH_IDLE = 6;
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
maxScrollOffset: 0, maxScrollOffset: 0,
}); });
const onScrollToOffsetRef = useRef(onScrollToOffset); const onScrollToOffsetRef = useRef(onScrollToOffset);
const isWeb = Platform.OS === "web";
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
const normalizedOffset = inverted const normalizedOffset = inverted
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
); );
const panResponder = useMemo(() => { const panResponder = useMemo(() => {
if (isWeb) { if (platformIsWeb) {
return null; return null;
} }
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
setIsDragging(false); setIsDragging(false);
}, },
}); });
}, [applyDragDelta, isWeb]); }, [applyDragDelta, platformIsWeb]);
const startWebDrag = useCallback( const startWebDrag = useCallback(
(event: any) => { (event: any) => {
if (!isWeb) { if (!platformIsWeb) {
return; return;
} }
const clientY = readClientY(event); const clientY = readClientY(event);
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
dragStartClientYRef.current = clientY; dragStartClientYRef.current = clientY;
setIsDragging(true); setIsDragging(true);
}, },
[isWeb], [platformIsWeb],
); );
const handleGrabHoverIn = useCallback(() => { const handleGrabHoverIn = useCallback(() => {
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isWeb || !isDragging) { if (!platformIsWeb || !isDragging) {
return; return;
} }
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging); window.removeEventListener("pointercancel", stopDragging);
}; };
}, [applyDragDelta, isDragging, isWeb]); }, [applyDragDelta, isDragging, platformIsWeb]);
if (!enabled || !geometry.isVisible) { if (!enabled || !geometry.isVisible) {
return null; return null;
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
height: thumbRegionHeight, height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }], transform: [{ translateY: thumbRegionOffset }],
}, },
isWeb && platformIsWeb &&
({ ({
cursor: handleCursor, cursor: handleCursor,
touchAction: "none", touchAction: "none",
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
]} ]}
pointerEvents={handleVisible ? "auto" : "none"} pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})} {...(panResponder?.panHandlers ?? {})}
{...(isWeb {...(platformIsWeb
? ({ ? ({
onPointerDown: startWebDrag, onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn, onPointerEnter: handleGrabHoverIn,
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
backgroundColor: handleColor, backgroundColor: handleColor,
opacity: handleOpacity, opacity: handleOpacity,
}, },
isWeb && platformIsWeb &&
({ ({
transitionProperty: "opacity, width, background-color", transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
+49 -50
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native"; import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
@@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes"; import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
type WelcomeAction = { type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link"; key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -242,8 +243,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
useEffect(() => { useEffect(() => {
const currentPathname = const currentPathname = typeof window === "undefined" ? null : window.location.pathname || null;
typeof window === "undefined" ? null : (window.location.pathname || null);
if (currentPathname && currentPathname !== "/welcome") { if (currentPathname && currentPathname !== "/welcome") {
return; return;
} }
@@ -260,52 +260,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router], [router],
); );
const actions: WelcomeAction[] = const actions: WelcomeAction[] = isWeb
Platform.OS === "web" ? [
? [ {
{ key: "direct-connection",
key: "direct-connection", label: "Direct connection",
label: "Direct connection", testID: "welcome-direct-connection",
testID: "welcome-direct-connection", primary: true,
primary: true, icon: Link2,
icon: Link2, onPress: () => setIsDirectOpen(true),
onPress: () => setIsDirectOpen(true), },
}, {
{ key: "paste-pairing-link",
key: "paste-pairing-link", label: "Paste pairing link",
label: "Paste pairing link", testID: "welcome-paste-pairing-link",
testID: "welcome-paste-pairing-link", primary: false,
primary: false, icon: ClipboardPaste,
icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true),
onPress: () => setIsPasteLinkOpen(true), },
}, ]
] : [
: [ {
{ key: "scan-qr",
key: "scan-qr", label: "Scan QR code",
label: "Scan QR code", testID: "welcome-scan-qr",
testID: "welcome-scan-qr", primary: true,
primary: true, icon: QrCode,
icon: QrCode, onPress: () => router.push("/pair-scan?source=onboarding"),
onPress: () => router.push("/pair-scan?source=onboarding"), },
}, {
{ key: "direct-connection",
key: "direct-connection", label: "Direct connection",
label: "Direct connection", testID: "welcome-direct-connection",
testID: "welcome-direct-connection", primary: false,
primary: false, icon: Link2,
icon: Link2, onPress: () => setIsDirectOpen(true),
onPress: () => setIsDirectOpen(true), },
}, {
{ key: "paste-pairing-link",
key: "paste-pairing-link", label: "Paste pairing link",
label: "Paste pairing link", testID: "welcome-paste-pairing-link",
testID: "welcome-paste-pairing-link", primary: false,
primary: false, icon: ClipboardPaste,
icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true),
onPress: () => setIsPasteLinkOpen(true), },
}, ];
];
const showHostList = hosts.length > 0 && !anyOnlineServerId; const showHostList = hosts.length > 0 && !anyOnlineServerId;
@@ -326,7 +325,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text> </Text>
{!showHostList && Platform.OS !== "web" && ( {!showHostList && isNative && (
<> <>
<Text style={styles.setupHint}> <Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first. You need the Paseo desktop app or server running on your computer first.
@@ -289,10 +289,7 @@ function WorkspaceHoverCardContent({
<> <>
<View style={styles.separator} /> <View style={styles.separator} />
<Pressable <Pressable
style={({ hovered }) => [ style={({ hovered }) => [styles.checksSummaryRow, hovered && styles.listRowHovered]}
styles.checksSummaryRow,
hovered && styles.listRowHovered,
]}
onPress={() => void openExternalUrl(`${prHint.url}/checks`)} onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
> >
{({ hovered }) => { {({ hovered }) => {
@@ -333,10 +330,7 @@ function WorkspaceHoverCardContent({
}, },
]} ]}
> >
<ExternalLink <ExternalLink size={12} color={theme.colors.foreground} />
size={12}
color={theme.colors.foreground}
/>
</View> </View>
) : null} ) : null}
</> </>
@@ -14,9 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
requireWorkspaceExecutionAuthority,
} from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input"; import type { ImageAttachment, MessagePayload } from "./message-input";
@@ -174,7 +172,9 @@ export function WorkspaceSetupDialog() {
} }
const encodedImages = await encodeImages(images); const encodedImages = await encodeImages(images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace,
}).workspaceDirectory;
const agent = await connectedClient.createAgent({ const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider, provider: composerState.selectedProvider,
cwd: workspaceDirectory, cwd: workspaceDirectory,
@@ -222,7 +222,6 @@ export function WorkspaceSetupDialog() {
], ],
); );
const workspaceTitle = const workspaceTitle =
workspace?.name || workspace?.name ||
workspace?.projectDisplayName || workspace?.projectDisplayName ||
+6 -40
View File
@@ -1,6 +1,5 @@
import { Platform } from "react-native";
import { useUnistyles } from "react-native-unistyles"; import { useUnistyles } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75; export const FOOTER_HEIGHT = 75;
@@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140; export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48; export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in the Electron desktop runtime (any OS) export {
function isElectronDesktopRuntime(): boolean { getIsElectron as getIsElectronRuntime,
if (Platform.OS !== "web") return false; getIsElectronMac as getIsElectronRuntimeMac,
return isElectronRuntime(); } from "./platform";
}
// Check if running in the Electron desktop runtime on macOS
function isElectronDesktopRuntimeMac(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntimeMac();
}
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isElectronRuntimeMacCached: boolean | null = null;
let _isElectronRuntimeCached: boolean | null = null;
export function getIsElectronRuntimeMac(): boolean {
if (_isElectronRuntimeMacCached === true) {
return true;
}
const result = isElectronDesktopRuntimeMac();
if (result) {
_isElectronRuntimeMacCached = true;
}
return result;
}
export function getIsElectronRuntime(): boolean {
if (_isElectronRuntimeCached === true) {
return true;
}
const result = isElectronDesktopRuntime();
if (result) {
_isElectronRuntimeCached = true;
}
return result;
}
/** /**
* Reactive hook — re-renders the component when the breakpoint changes. * Reactive hook — re-renders the component when the breakpoint changes.
@@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean {
// Keep that capability distinct from desktop-width layout so touch tablets // Keep that capability distinct from desktop-width layout so touch tablets
// can use the desktop shell without entering web-only code paths. // can use the desktop shell without entering web-only code paths.
export function supportsDesktopPaneSplits(): boolean { export function supportsDesktopPaneSplits(): boolean {
return Platform.OS === "web"; return isWeb;
} }
+49
View File
@@ -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;
}
+10 -8
View File
@@ -1,6 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
@@ -55,6 +55,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { isNative } from "@/constants/platform";
// Re-export types from session-store and draft-store for backward compatibility // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -301,12 +302,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map()); const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
const sendAgentMessageRef = useRef< const sendAgentMessageRef = useRef<
(( | ((
agentId: string, agentId: string,
message: string, message: string,
images?: AttachmentMetadata[], images?: AttachmentMetadata[],
attachments?: AgentAttachment[], attachments?: AgentAttachment[],
) => Promise<void>) | null ) => Promise<void>)
| null
>(null); >(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map()); const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
@@ -562,7 +564,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
(awayMs: number) => { (awayMs: number) => {
scheduleAuthoritativeRevalidation(); scheduleAuthoritativeRevalidation();
if (Platform.OS !== "web") { if (isNative) {
const session = useSessionStore.getState().sessions[serverId]; const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId; const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined; const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
@@ -154,10 +154,7 @@ describe("processTimelineResponse", () => {
direction: "before", direction: "before",
startSeq: 1, startSeq: 1,
endSeq: 2, endSeq: 2,
entries: [ entries: [makeTimelineEntry(1, "hello", "user_message"), makeTimelineEntry(2, "older")],
makeTimelineEntry(1, "hello", "user_message"),
makeTimelineEntry(2, "older"),
],
}, },
}); });
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { isWeb, isNative } from "@/constants/platform";
export type DesktopPermissionKind = "notifications" | "microphone"; export type DesktopPermissionKind = "notifications" | "microphone";
@@ -45,7 +45,7 @@ type NavigatorLike = {
}; };
export function shouldShowDesktopPermissionSection(): boolean { export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === "web" && getDesktopHost() !== null; return isWeb && getDesktopHost() !== null;
} }
function status(input: DesktopPermissionStatus): DesktopPermissionStatus { function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
} }
function getWebNotificationConstructor(): NotificationConstructorLike | null { function getWebNotificationConstructor(): NotificationConstructorLike | null {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
} }
function getNavigatorLike(): NavigatorLike | null { function getNavigatorLike(): NavigatorLike | null {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
const webNavigator = (globalThis as { navigator?: unknown }).navigator; const webNavigator = (globalThis as { navigator?: unknown }).navigator;
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
} }
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> { async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop notification status is only available on web runtime.", detail: "Desktop notification status is only available on web runtime.",
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
} }
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.", detail: "Desktop microphone status is only available on web runtime.",
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
} }
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> { async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.", detail: "Desktop notification requests are only available on web runtime.",
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
} }
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.", detail: "Desktop microphone requests are only available on web runtime.",
@@ -1,6 +1,6 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import { isWeb } from "@/constants/platform";
export interface DesktopAppUpdateCheckResult { export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean; hasUpdate: boolean;
@@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
} }
export function shouldShowDesktopUpdateSection(): boolean { export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === "web" && isElectronRuntime(); return isWeb && isElectronRuntime();
} }
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult { export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { import {
shouldClearAgentAttention, shouldClearAgentAttention,
type AgentAttentionClearTrigger, type AgentAttentionClearTrigger,
} from "@/utils/agent-attention"; } from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
type AttentionReason = "finished" | "error" | "permission" | null | undefined; type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -70,7 +71,7 @@ export function useAgentAttentionClear({
const appStateSubscription = AppState.addEventListener("change", updateVisibility); const appStateSubscription = AppState.addEventListener("change", updateVisibility);
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", updateVisibility); document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility); window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility); window.addEventListener("blur", updateVisibility);
@@ -1,12 +1,117 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state"; import { __private__ } from "./use-agent-form-state";
import { import { buildProviderDefinitions } from "@/utils/provider-definitions";
AGENT_PROVIDER_DEFINITIONS, import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
type AgentProviderDefinition, import type {
} from "@server/server/agent/provider-manifest"; AgentModelDefinition,
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
id: "codex",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
id: "claude",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
function makeProviderMap(
...definitions: AgentProviderDefinition[]
): Map<AgentProvider, AgentProviderDefinition> {
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
}
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
describe("useAgentFormState", () => { describe("useAgentFormState", () => {
describe("buildProviderDefinitions", () => {
it("returns empty array when snapshot data is unavailable", () => {
expect(buildProviderDefinitions(undefined)).toEqual([]);
expect(buildProviderDefinitions([])).toEqual([]);
});
it("builds custom provider definitions from snapshot metadata", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "zai",
status: "ready",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
provider: "claude",
status: "ready",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
},
];
const definitions = buildProviderDefinitions(entries);
expect(definitions).toEqual([
{
id: "zai",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
id: "claude",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Always Ask",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
]);
});
});
describe("__private__.combineInitialValues", () => { describe("__private__.combineInitialValues", () => {
it("returns undefined when no initial values and no initial server id", () => { it("returns undefined when no initial values and no initial server id", () => {
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined(); expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
@@ -78,6 +183,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe(""); expect(resolved.model).toBe("");
@@ -106,6 +212,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -134,6 +241,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.thinkingOptionId).toBe("xhigh"); expect(resolved.thinkingOptionId).toBe("xhigh");
@@ -161,6 +269,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -188,6 +297,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -215,6 +325,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -256,6 +367,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
claudeProviderMap,
); );
expect(resolved.model).toBe("default"); expect(resolved.model).toBe("default");
@@ -263,11 +375,6 @@ describe("useAgentFormState", () => {
}); });
it("resolves provider only from allowed provider map", () => { it("resolves provider only from allowed provider map", () => {
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map(
(definition) => [definition.id as AgentProvider, definition],
),
);
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
{ provider: "codex" }, { provider: "codex" },
@@ -289,7 +396,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
allowedProviderMap, claudeProviderMap,
); );
expect(resolved.provider).toBe("claude"); expect(resolved.provider).toBe("claude");
+14 -20
View File
@@ -1,8 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type { import type {
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -10,6 +7,7 @@ import type {
ProviderSnapshotEntry, ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useProvidersSnapshot } from "./use-providers-snapshot";
import { import {
useFormPreferences, useFormPreferences,
@@ -99,13 +97,8 @@ export type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>; persistFormPreferences: () => Promise<void>;
}; };
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS; const DEFAULT_PROVIDER: AgentProvider = "claude";
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>( const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
allProviderDefinitions.map((definition) => [definition.id, definition]),
);
const fallbackDefinition = allProviderDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
function normalizeSelectedModelId(modelId: string | null | undefined): string { function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : ""; const normalized = typeof modelId === "string" ? modelId.trim() : "";
@@ -180,7 +173,7 @@ function resolveFormState(
userModified: UserModifiedFields, userModified: UserModifiedFields,
currentState: FormState, currentState: FormState,
validServerIds: Set<string>, validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap, allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
): FormState { ): FormState {
// Start with current state - we only update non-user-modified fields // Start with current state - we only update non-user-modified fields
const result = { ...currentState }; const result = { ...currentState };
@@ -374,10 +367,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
} = useProvidersSnapshot(formState.serverId); } = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(() => { const snapshotProviderDefinitions = useMemo(
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider)); () => buildProviderDefinitions(snapshotEntries),
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id)); [snapshotEntries],
}, [snapshotEntries]); );
const snapshotProviderDefinitionMap = useMemo( const snapshotProviderDefinitionMap = useMemo(
() => () =>
new Map<AgentProvider, AgentProviderDefinition>( new Map<AgentProvider, AgentProviderDefinition>(
@@ -386,17 +379,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotProviderDefinitions], [snapshotProviderDefinitions],
); );
const snapshotSelectableProviderDefinitionMap = useMemo(() => { const snapshotSelectableProviderDefinitionMap = useMemo(() => {
if (!snapshotEntries?.length) {
return snapshotProviderDefinitionMap;
}
const readyProviders = new Set( const readyProviders = new Set(
(snapshotEntries ?? []) snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
.filter((entry) => entry.status === "ready")
.map((entry) => entry.provider),
); );
return new Map<AgentProvider, AgentProviderDefinition>( return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id)) .filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]), .map((definition) => [definition.id, definition]),
); );
}, [snapshotEntries, snapshotProviderDefinitions]); }, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => { const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) { for (const entry of snapshotEntries ?? []) {
@@ -1,5 +1,4 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { import {
@@ -10,13 +9,14 @@ import {
rejectInitDeferred, rejectInitDeferred,
} from "@/utils/agent-initialization"; } from "@/utils/agent-initialization";
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy"; import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
import { isWeb } from "@/constants/platform";
const INIT_TIMEOUT_MS = 5 * 60_000; const INIT_TIMEOUT_MS = 5 * 60_000;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200; const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0; const UNBOUNDED_TIMELINE_LIMIT = 0;
function resolveInitialTimelineLimit(): number { function resolveInitialTimelineLimit(): number {
return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT; return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
} }
export const __private__ = { export const __private__ = {
@@ -123,7 +123,9 @@ function buildDraftComposerCommandConfig(input: {
return { return {
provider: input.provider, provider: input.provider,
cwd, cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}), ...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId ...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId } ? { thinkingOptionId: input.effectiveThinkingOptionId }
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
let current = getIsAppActivelyVisible(); let current = getIsAppActivelyVisible();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -29,7 +30,7 @@ export function useAppVisible(): boolean {
useEffect(() => { useEffect(() => {
const appStateSubscription = AppState.addEventListener("change", notify); const appStateSubscription = AppState.addEventListener("change", notify);
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", notify); document.addEventListener("visibilitychange", notify);
window.addEventListener("focus", notify); window.addEventListener("focus", notify);
window.addEventListener("blur", notify); window.addEventListener("blur", notify);
@@ -37,7 +38,7 @@ export function useAppVisible(): boolean {
return () => { return () => {
appStateSubscription.remove(); appStateSubscription.remove();
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", notify); document.removeEventListener("visibilitychange", notify);
window.removeEventListener("focus", notify); window.removeEventListener("focus", notify);
window.removeEventListener("blur", notify); window.removeEventListener("blur", notify);
@@ -135,18 +135,8 @@ describe("useArchiveAgent", () => {
it("can apply archived agent close results without invalidating cached lists", () => { it("can apply archived agent close results without invalidating cached lists", () => {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
useSessionStore useSessionStore.getState().initializeSession("server-a", {} as DaemonClient);
.getState() useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]]));
.initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents(
"server-a",
new Map([
[
"agent-1",
makeAgent(),
],
]),
);
queryClient.setQueryData(["sidebarAgentsList", "server-a"], { queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
}); });
+5 -1
View File
@@ -181,7 +181,11 @@ function restoreArchivedAgentListCacheSnapshot(
serverId: string, serverId: string,
snapshot: ArchivedAgentListCacheSnapshot, snapshot: ArchivedAgentListCacheSnapshot,
): void { ): void {
restoreCachedListSnapshot(queryClient, ["sidebarAgentsList", serverId], snapshot.sidebarAgentsList); restoreCachedListSnapshot(
queryClient,
["sidebarAgentsList", serverId],
snapshot.sidebarAgentsList,
);
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents); restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
} }
@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback } from "react";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
const HEARTBEAT_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
@@ -32,7 +33,7 @@ export function useClientActivity({
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId); const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(0); const lastImmediateHeartbeatAtRef = useRef<number>(0);
const deviceType = Platform.OS === "web" ? "web" : "mobile"; const deviceType = isWeb ? "web" : "mobile";
const recordUserActivity = useCallback(() => { const recordUserActivity = useCallback(() => {
lastActivityAtRef.current = new Date(); lastActivityAtRef.current = new Date();
@@ -96,7 +97,7 @@ export function useClientActivity({
// Track user activity on web for accurate staleness. // Track user activity on web for accurate staleness.
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const handleUserActivity = () => { const handleUserActivity = () => {
+5 -9
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout"; import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents"; import { useAggregatedAgents } from "./use-aggregated-agents";
@@ -9,6 +8,7 @@ import {
deriveMacDockBadgeCountFromWorkspaceStatuses, deriveMacDockBadgeCountFromWorkspaceStatuses,
type DesktopBadgeWorkspaceStatus, type DesktopBadgeWorkspaceStatus,
} from "@/utils/desktop-badge-state"; } from "@/utils/desktop-badge-state";
import { isNative } from "@/constants/platform";
type FaviconStatus = "none" | "running" | "attention"; type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light"; type ColorScheme = "dark" | "light";
@@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
} }
function getSystemColorScheme(): ColorScheme { function getSystemColorScheme(): ColorScheme {
if ( if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") {
Platform.OS !== "web" ||
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return "dark"; return "dark";
} }
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
async function updateMacDockBadge(count?: number) { async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return; if (isNative || !getIsElectronRuntimeMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.(); const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") { if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
@@ -119,7 +115,7 @@ export function useFaviconStatus() {
// Listen for system color scheme changes // Listen for system color scheme changes
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof window === "undefined") return; if (isNative || typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => { const handler = (e: MediaQueryListEvent) => {
@@ -132,7 +128,7 @@ export function useFaviconStatus() {
// Update favicon when agents or color scheme changes // Update favicon when agents or color scheme changes
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
const status = deriveFaviconStatus(agents); const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme); updateFavicon(status, colorScheme);
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { isWeb } from "@/constants/platform";
interface UseFileDropZoneOptions { interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void; onFilesDropped: (files: ImageAttachment[]) => void;
@@ -14,7 +14,7 @@ interface UseFileDropZoneReturn {
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = { const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png", ".png": "image/png",
".jpg": "image/jpeg", ".jpg": "image/jpeg",
@@ -8,6 +8,7 @@ import {
openImagePathsWithDesktopDialog, openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput, type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker"; } from "@/hooks/image-attachment-picker";
import { isWeb } from "@/constants/platform";
interface UseImageAttachmentPickerResult { interface UseImageAttachmentPickerResult {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>; pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
@@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true; isPickingRef.current = true;
try { try {
if (Platform.OS === "web" && isElectronRuntime()) { if (isWeb && isElectronRuntime()) {
const selectedPaths = await openImagePathsWithDesktopDialog(); const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) { if (selectedPaths.length === 0) {
return null; return null;
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native"; import { usePathname, useRouter } from "expo-router";
import { usePathname } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
buildHostSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseServerIdFromPathname, parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
@@ -26,6 +26,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -47,6 +48,7 @@ export function useKeyboardShortcuts({
cycleTheme?: () => void; cycleTheme?: () => void;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const hosts = useHosts(); const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
@@ -65,7 +67,7 @@ export function useKeyboardShortcuts({
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
if (Platform.OS !== "web") return; if (isNative) return;
if (isMobile) return; if (isMobile) return;
const isDesktopApp = getIsElectronRuntime(); const isDesktopApp = getIsElectronRuntime();
@@ -244,6 +246,16 @@ export function useKeyboardShortcuts({
case "sidebar.toggle.left": case "sidebar.toggle.left":
toggleAgentList(); toggleAgentList();
return true; return true;
case "settings.toggle":
if (pathname.endsWith("/settings")) {
router.back();
return true;
}
if (!activeServerId) {
return false;
}
router.push(buildHostSettingsRoute(activeServerId));
return true;
case "sidebar.toggle.both": case "sidebar.toggle.both":
if (toggleBothSidebars) { if (toggleBothSidebars) {
toggleBothSidebars(); toggleBothSidebars();
@@ -93,7 +93,9 @@ describe("openProjectDirectly", () => {
expect(result).toBe(true); expect(result).toBe(true);
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true); expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([ expect(
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []),
).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "1", id: "1",
projectId: "1", projectId: "1",
+5 -1
View File
@@ -2,7 +2,11 @@ import { useCallback } from "react";
import { router } from "expo-router"; import { router } from "expo-router";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import {
normalizeWorkspaceDescriptor,
type WorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
@@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import Constants from "expo-constants"; import Constants from "expo-constants";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb } from "@/constants/platform";
const STORAGE_PREFIX = "@paseo:expo-push-token:"; const STORAGE_PREFIX = "@paseo:expo-push-token:";
@@ -31,7 +32,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
const lastSentTokenRef = useRef<string | null>(null); const lastSentTokenRef = useRef<string | null>(null);
const registerIfPossible = useCallback(async () => { const registerIfPossible = useCallback(async () => {
if (Platform.OS === "web") return; if (isWeb) return;
if (!client.isConnected) return; if (!client.isConnected) return;
const token = tokenRef.current; const token = tokenRef.current;
if (!token) return; if (!token) return;
@@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
}, [client]); }, [client]);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") return; if (isWeb) return;
const storageKey = `${STORAGE_PREFIX}${serverId}`; const storageKey = `${STORAGE_PREFIX}${serverId}`;
let cancelled = false; let cancelled = false;
@@ -236,7 +236,6 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor { function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload); return normalizeWorkspaceDescriptor(payload);
} }
export function useSidebarWorkspacesList(options?: { export function useSidebarWorkspacesList(options?: {
+1
View File
@@ -37,6 +37,7 @@ export type KeyboardActionId =
| "sidebar.toggle.left" | "sidebar.toggle.left"
| "sidebar.toggle.right" | "sidebar.toggle.right"
| "sidebar.toggle.both" | "sidebar.toggle.both"
| "settings.toggle"
| "command-center.toggle" | "command-center.toggle"
| "shortcuts.dialog.toggle" | "shortcuts.dialog.toggle"
| "workspace.terminal.new" | "workspace.terminal.new"
@@ -712,6 +712,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
}, },
}, },
// --- Settings toggle ---
{
id: "settings-toggle-cmd-comma-mac",
action: "settings.toggle",
combo: "Cmd+,",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-settings",
section: "panels",
label: "Toggle settings",
keys: ["mod", ","],
},
},
{
id: "settings-toggle-ctrl-comma-non-mac",
action: "settings.toggle",
combo: "Ctrl+,",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-settings",
section: "panels",
label: "Toggle settings",
keys: ["mod", ","],
},
},
// --- Focus mode --- // --- Focus mode ---
{ {
id: "view-toggle-focus-cmd-shift-f-mac", id: "view-toggle-focus-cmd-shift-f-mac",
@@ -31,6 +31,7 @@ KEY_MAP["Digit"] = { code: "Digit" };
KEY_MAP["\\"] = { code: "Backslash" }; KEY_MAP["\\"] = { code: "Backslash" };
KEY_MAP["["] = { code: "BracketLeft", key: "[" }; KEY_MAP["["] = { code: "BracketLeft", key: "[" };
KEY_MAP["]"] = { code: "BracketRight", key: "]" }; KEY_MAP["]"] = { code: "BracketRight", key: "]" };
KEY_MAP[","] = { code: "Comma", key: "," };
KEY_MAP["."] = { code: "Period", key: "." }; KEY_MAP["."] = { code: "Period", key: "." };
KEY_MAP["`"] = { code: "Backquote", key: "`" }; KEY_MAP["`"] = { code: "Backquote", key: "`" };
KEY_MAP["/"] = { code: "Slash" }; KEY_MAP["/"] = { code: "Slash" };
+12 -4
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -46,6 +46,7 @@ import {
deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest, deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor"; } from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { isNative } from "@/constants/platform";
function formatProviderLabel(provider: Agent["provider"]): string { function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) { if (!provider) {
@@ -242,7 +243,7 @@ function AgentPanelBody({
); );
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => { useShallow((state) => {
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null; const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null;
return { return {
serverId: agent?.serverId ?? null, serverId: agent?.serverId ?? null,
id: agent?.id ?? null, id: agent?.id ?? null,
@@ -690,7 +691,14 @@ function ChatAgentContent({
projectPlacement, projectPlacement,
} }
: null, : null,
[agentState.serverId, agentState.id, agentState.status, agentState.cwd, agentState.lastError, projectPlacement], [
agentState.serverId,
agentState.id,
agentState.status,
agentState.cwd,
agentState.lastError,
projectPlacement,
],
); );
const placeholderAgent: AgentScreenAgent | null = useMemo(() => { const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
@@ -791,7 +799,7 @@ function ChatAgentContent({
if (!isConnected || !hasSession) { if (!isConnected || !hasSession) {
return; return;
} }
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
if (!shouldSyncOnEntry) { if (!shouldSyncOnEntry) {
return; return;
} }
+15 -20
View File
@@ -18,7 +18,7 @@ function useSetupPanelDescriptor(
serverId: context.serverId, serverId: context.serverId,
workspaceId: target.workspaceId, workspaceId: target.workspaceId,
}); });
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
if (snapshot?.status === "completed") { if (snapshot?.status === "completed") {
return { return {
@@ -98,7 +98,7 @@ function SetupPanel() {
serverId, serverId,
workspaceId: target.workspaceId, workspaceId: target.workspaceId,
}); });
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null)); const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress); const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// On mount, if no snapshot in the store, request cached status from server // On mount, if no snapshot in the store, request cached status from server
@@ -160,13 +160,14 @@ function SetupPanel() {
return null; return null;
})(); })();
const statusLabel = snapshot?.status === "running" const statusLabel =
? "Running" snapshot?.status === "running"
: snapshot?.status === "completed" ? "Running"
? "Completed" : snapshot?.status === "completed"
: snapshot?.status === "failed" ? "Completed"
? "Failed" : snapshot?.status === "failed"
: "Waiting for setup output"; ? "Failed"
: "Waiting for setup output";
return ( return (
<ScrollView <ScrollView
@@ -175,10 +176,9 @@ function SetupPanel() {
testID="workspace-setup-panel" testID="workspace-setup-panel"
> >
{/* Hidden element for status — preserves testID for E2E */} {/* Hidden element for status — preserves testID for E2E */}
<Text <Text style={styles.hiddenStatus} testID="workspace-setup-status">
style={styles.hiddenStatus} {statusLabel}
testID="workspace-setup-status" </Text>
>{statusLabel}</Text>
{isWaiting ? ( {isWaiting ? (
<View style={styles.waitingContainer}> <View style={styles.waitingContainer}>
@@ -241,17 +241,12 @@ function SetupPanel() {
{command.command} {command.command}
</Text> </Text>
{command.durationMs != null ? ( {command.durationMs != null ? (
<Text style={styles.commandDuration}> <Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
{formatDuration(command.durationMs)}
</Text>
) : null} ) : null}
<ChevronRight <ChevronRight
size={14} size={14}
color={theme.colors.foregroundMuted} color={theme.colors.foregroundMuted}
style={[ style={[styles.chevron, showDetail && styles.chevronExpanded]}
styles.chevron,
showDetail && styles.chevronExpanded,
]}
/> />
</Pressable> </Pressable>
{showDetail ? ( {showDetail ? (
+6 -2
View File
@@ -39,7 +39,9 @@ function useTerminalPanelDescriptor(
queryFn: async (): Promise<ListTerminalsPayload> => { queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client || !workspaceDirectory) { if (!client || !workspaceDirectory) {
throw new Error( throw new Error(
workspaceAuthority.ok ? "Workspace execution directory not found" : workspaceAuthority.message, workspaceAuthority.ok
? "Workspace execution directory not found"
: workspaceAuthority.message,
); );
} }
return client.listTerminals(workspaceDirectory); return client.listTerminals(workspaceDirectory);
@@ -76,7 +78,9 @@ function TerminalPanel() {
return ( return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}> <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
<Text> <Text>
{workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message} {workspaceAuthority.ok
? "Workspace execution directory not found."
: workspaceAuthority.message}
</Text> </Text>
</View> </View>
); );
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native"; import { View, Text, Pressable, ScrollView, Keyboard } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -57,6 +57,8 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -67,10 +69,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsReasoningStream: false, supportsReasoningStream: false,
supportsToolInvocations: false, supportsToolInvocations: false,
}; };
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
function getParamValue(value: string | string[] | undefined) { function getParamValue(value: string | string[] | undefined) {
if (typeof value === "string") { if (typeof value === "string") {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -91,16 +89,14 @@ function getValidProvider(value: string | undefined) {
if (!value) { if (!value) {
return undefined; return undefined;
} }
return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined; return value as AgentProvider;
} }
function getValidMode(provider: AgentProvider | undefined, value: string | undefined) { function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
if (!provider || !value) { if (!provider || !value) {
return undefined; return undefined;
} }
const definition = PROVIDER_DEFINITION_MAP.get(provider); return value;
const modes = definition?.modes ?? [];
return modes.some((mode) => mode.id === value) ? value : undefined;
} }
type DraftAgentParams = { type DraftAgentParams = {
@@ -205,22 +201,20 @@ function DraftAgentScreenContent({
const draftIdRef = useRef(generateDraftId()); const draftIdRef = useRef(generateDraftId());
const draftAgentIdRef = useRef(generateDraftId()); const draftAgentIdRef = useRef(generateDraftId());
const draftInput = useAgentInputDraft( const draftInput = useAgentInputDraft({
{ draftKey: ({ selectedServerId }) =>
draftKey: ({ selectedServerId }) => buildDraftStoreKey({
buildDraftStoreKey({ serverId: selectedServerId ?? "",
serverId: selectedServerId ?? "", agentId: draftAgentIdRef.current,
agentId: draftAgentIdRef.current, draftId: draftIdRef.current,
draftId: draftIdRef.current, }),
}), composer: {
composer: { initialServerId: resolvedServerId ?? null,
initialServerId: resolvedServerId ?? null, initialValues,
initialValues, isVisible,
isVisible, onlineServerIds,
onlineServerIds,
},
}, },
); });
const composerState = draftInput.composerState; const composerState = draftInput.composerState;
if (!composerState) { if (!composerState) {
throw new Error("Draft agent composer state is required"); throw new Error("Draft agent composer state is required");
@@ -636,7 +630,9 @@ function DraftAgentScreenContent({
useCallback( useCallback(
(state) => (state) =>
resolveWorkspaceIdByExecutionDirectory({ resolveWorkspaceIdByExecutionDirectory({
workspaces: selectedServerId ? state.sessions[selectedServerId]?.workspaces?.values() : null, workspaces: selectedServerId
? state.sessions[selectedServerId]?.workspaces?.values()
: null,
workspaceDirectory: explorerCwd, workspaceDirectory: explorerCwd,
}), }),
[explorerCwd, selectedServerId], [explorerCwd, selectedServerId],
@@ -802,7 +798,7 @@ function DraftAgentScreenContent({
}, },
onBeforeSubmit: () => { onBeforeSubmit: () => {
void persistFormPreferences(); void persistFormPreferences();
if (Platform.OS === "web") { if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.(); (document.activeElement as HTMLElement | null)?.blur?.();
} }
Keyboard.dismiss(); Keyboard.dismiss();
@@ -868,9 +864,7 @@ function DraftAgentScreenContent({
cwd: resolvedWorkingDir, cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(effectiveModelId ? { model: effectiveModelId } : {}), ...(effectiveModelId ? { model: effectiveModelId } : {}),
...(effectiveThinkingOptionId ...(effectiveThinkingOptionId ? { thinkingOptionId: effectiveThinkingOptionId } : {}),
? { thinkingOptionId: effectiveThinkingOptionId }
: {}),
}; };
const effectiveBaseBranch = baseBranch.trim(); const effectiveBaseBranch = baseBranch.trim();
@@ -932,9 +926,7 @@ function DraftAgentScreenContent({
}, },
onCreateSuccess: ({ result }) => { onCreateSuccess: ({ result }) => {
if (!result.workspaceId) { if (!result.workspaceId) {
router.replace( router.replace(buildHostAgentDetailRoute(selectedServerId as string, result.id) as any);
buildHostAgentDetailRoute(selectedServerId as string, result.id) as any,
);
return; return;
} }
const route = prepareWorkspaceTab({ const route = prepareWorkspaceTab({
@@ -3,7 +3,13 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CircleDot, ChevronDown, ExternalLink, GitBranch, GitPullRequest } from "lucide-react-native"; import {
CircleDot,
ChevronDown,
ExternalLink,
GitBranch,
GitPullRequest,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Combobox, ComboboxItem } from "@/components/ui/combobox";
@@ -26,9 +32,7 @@ import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments"; import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
import { import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
requireWorkspaceExecutionAuthority,
} from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "@/components/message-input"; import type { ImageAttachment, MessagePayload } from "@/components/message-input";
import type { GitHubSearchItem } from "@server/shared/messages"; import type { GitHubSearchItem } from "@server/shared/messages";
@@ -123,14 +127,13 @@ export function NewWorkspaceScreen({
const githubSearchOptions: ComboboxOptionType[] = useMemo(() => { const githubSearchOptions: ComboboxOptionType[] = useMemo(() => {
const items = githubSearchResultsQuery.data?.items ?? []; const items = githubSearchResultsQuery.data?.items ?? [];
return items return items.map((item) => ({
.map((item) => ({ id: `${item.kind}:${item.number}`,
id: `${item.kind}:${item.number}`, label: `#${item.number} ${item.title}`,
label: `#${item.number} ${item.title}`, // Include search query so the Combobox's client-side filter doesn't
// Include search query so the Combobox's client-side filter doesn't // discard server-searched results that match on body but not title.
// discard server-searched results that match on body but not title. description: githubSearchQuery_trimmed,
description: githubSearchQuery_trimmed, }));
}));
}, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]); }, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]);
const handleSelectGithubItem = useCallback( const handleSelectGithubItem = useCallback(
@@ -198,7 +201,9 @@ export function NewWorkspaceScreen({
const initialPrompt = text.trim(); const initialPrompt = text.trim();
const encodedImages = await encodeImages(images); const encodedImages = await encodeImages(images);
const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem); const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem);
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace,
}).workspaceDirectory;
const agent = await connectedClient.createAgent({ const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider, provider: composerState.selectedProvider,
cwd: workspaceDirectory, cwd: workspaceDirectory,
@@ -234,7 +239,15 @@ export function NewWorkspaceScreen({
setPendingAction(null); setPendingAction(null);
} }
}, },
[composerState, ensureWorkspace, selectedGithubItem, serverId, setAgents, toast, withConnectedClient], [
composerState,
ensureWorkspace,
selectedGithubItem,
serverId,
setAgents,
toast,
withConnectedClient,
],
); );
const workspaceTitle = const workspaceTitle =
@@ -337,10 +350,7 @@ export function NewWorkspaceScreen({
active={active} active={active}
onPress={onPress} onPress={onPress}
leadingSlot={ leadingSlot={
<GitBranch <GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
} }
/> />
)} )}
@@ -366,7 +376,10 @@ export function NewWorkspaceScreen({
<> <>
<View style={styles.badgeIcon}> <View style={styles.badgeIcon}>
{selectedGithubItem.kind === "pr" ? ( {selectedGithubItem.kind === "pr" ? (
<GitPullRequest size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <GitPullRequest
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
) : ( ) : (
<CircleDot size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <CircleDot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)} )}
@@ -400,7 +413,11 @@ export function NewWorkspaceScreen({
</Pressable> </Pressable>
<Combobox <Combobox
options={githubSearchOptions} options={githubSearchOptions}
value={selectedGithubItem ? `${selectedGithubItem.kind}:${selectedGithubItem.number}` : ""} value={
selectedGithubItem
? `${selectedGithubItem.kind}:${selectedGithubItem.number}`
: ""
}
onSelect={handleSelectGithubItem} onSelect={handleSelectGithubItem}
searchable searchable
searchPlaceholder="Search issues and PRs..." searchPlaceholder="Search issues and PRs..."
@@ -414,9 +431,7 @@ export function NewWorkspaceScreen({
desktopPlacement="bottom-start" desktopPlacement="bottom-start"
anchorRef={githubAnchorRef} anchorRef={githubAnchorRef}
emptyText={ emptyText={
githubSearchResultsQuery.isFetching githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."
? "Searching..."
: "No results found."
} }
renderOption={({ option, selected, active, onPress }) => { renderOption={({ option, selected, active, onPress }) => {
const item = (githubSearchResultsQuery.data?.items ?? []).find( const item = (githubSearchResultsQuery.data?.items ?? []).find(
@@ -452,7 +467,9 @@ export function NewWorkspaceScreen({
{({ hovered }) => ( {({ hovered }) => (
<ExternalLink <ExternalLink
size={theme.iconSize.sm} size={theme.iconSize.sm}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={
hovered ? theme.colors.foreground : theme.colors.foregroundMuted
}
/> />
)} )}
</Pressable> </Pressable>
+9 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject, ComponentType } from "react"; import type { MutableRefObject, ComponentType } from "react";
import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native"; import { View, Text, ScrollView, Alert, Pressable } from "react-native";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -68,10 +68,11 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { isWeb } from "@/constants/platform";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section definitions // Section definitions
@@ -522,6 +523,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
const isConnected = useHostRuntimeIsConnected(routeServerId); const isConnected = useHostRuntimeIsConnected(routeServerId);
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null); const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
const providerDefinitions = buildProviderDefinitions(entries);
const hasServer = routeServerId.length > 0; const hasServer = routeServerId.length > 0;
@@ -557,7 +559,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
</View> </View>
) : ( ) : (
<View style={[settingsStyles.card, styles.audioCard]}> <View style={[settingsStyles.card, styles.audioCard]}>
{AGENT_PROVIDER_DEFINITIONS.map((def) => { {providerDefinitions.map((def) => {
const entry = entries?.find((e) => e.provider === def.id); const entry = entries?.find((e) => e.provider === def.id);
const status = entry?.status ?? "unavailable"; const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id); const ProviderIcon = getProviderIcon(def.id);
@@ -1672,22 +1674,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
<View style={styles.hostHeaderRight}> <View style={styles.hostHeaderRight}>
<View <View
style={[ style={[
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile, isWeb ? styles.statusPill : styles.statusPillMobile,
{ backgroundColor: statusPillBg }, { backgroundColor: statusPillBg },
]} ]}
> >
<View style={[styles.statusDot, { backgroundColor: statusColor }]} /> <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
{Platform.OS === "web" ? ( {isWeb ? (
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text> <Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
) : null} ) : null}
</View> </View>
{connectionBadge ? ( {connectionBadge ? (
<View <View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
>
{connectionBadge.icon} {connectionBadge.icon}
{Platform.OS === "web" ? ( {isWeb ? (
<Text style={styles.connectionText} numberOfLines={1}> <Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text} {connectionBadge.text}
</Text> </Text>
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { View, Text, Platform } from "react-native"; import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
@@ -20,6 +20,7 @@ import {
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
function ShortcutSequence({ function ShortcutSequence({
chord, chord,
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
} }
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
if (capturingBindingId === null) return; if (capturingBindingId === null) return;
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
}; };
}, [setCapturingShortcut]); }, [setCapturingShortcut]);
if (Platform.OS !== "web") { if (isNative) {
return ( return (
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text> <Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
type StartupSplashScreenProps = { type StartupSplashScreenProps = {
bootstrapState?: { bootstrapState?: {
@@ -42,7 +43,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
errorScrollView: { errorScrollView: {
flex: 1, flex: 1,
...(Platform.OS === "web" ...(isWeb
? { ? {
overflowX: "auto", overflowX: "auto",
overflowY: "auto", overflowY: "auto",
@@ -145,7 +146,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
lineHeight: 18, lineHeight: 18,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
@@ -9,7 +9,6 @@ import {
} from "react"; } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
@@ -30,6 +29,7 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list"; import { SortableInlineList } from "@/components/sortable-inline-list";
import { isNative, isWeb } from "@/constants/platform";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -110,6 +110,7 @@ function useMiddleClickClose(onClose: () => void) {
const ref = useRef<View>(null); const ref = useRef<View>(null);
useEffect(() => { useEffect(() => {
if (isNative) return;
const node = ref.current as unknown as HTMLElement | null; const node = ref.current as unknown as HTMLElement | null;
if (!node) return; if (!node) return;
@@ -171,17 +172,16 @@ function TabChip({
); );
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered; const isHighlighted = isActive || hovered || isCloseHovered;
const closeButtonDragBlockers = const closeButtonDragBlockers = isWeb
Platform.OS === "web" ? ({
? ({ onPointerDown: (event: { stopPropagation?: () => void }) => {
onPointerDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.();
event.stopPropagation?.(); },
}, onMouseDown: (event: { stopPropagation?: () => void }) => {
onMouseDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.();
event.stopPropagation?.(); },
}, } as const)
} as const) : undefined;
: undefined;
return ( return (
<View ref={middleClickRef}> <View ref={middleClickRef}>
@@ -196,7 +196,7 @@ function TabChip({
enabledOnMobile={false} enabledOnMobile={false}
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.tab, styles.tab,
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const), isWeb && isDragging && ({ cursor: "grabbing" } as const),
{ {
minWidth: resolvedTabWidth, minWidth: resolvedTabWidth,
width: resolvedTabWidth, width: resolvedTabWidth,
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { Keyboard, Platform, ScrollView, Text, View } from "react-native"; import { Keyboard, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
@@ -17,6 +17,7 @@ import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { AgentSnapshotPayload } from "@server/shared/messages";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -63,18 +64,16 @@ export function WorkspaceDraftAgentTab({
}), }),
[draftId, serverId, tabId], [draftId, serverId, tabId],
); );
const draftInput = useAgentInputDraft( const draftInput = useAgentInputDraft({
{ draftKey: draftStoreKey,
draftKey: draftStoreKey, composer: {
composer: { initialServerId: serverId,
initialServerId: serverId, initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, isVisible: true,
isVisible: true, onlineServerIds: isConnected ? [serverId] : [],
onlineServerIds: isConnected ? [serverId] : [], lockedWorkingDir: workspaceDirectory ?? undefined,
lockedWorkingDir: workspaceDirectory ?? undefined,
},
}, },
); });
const composerState = draftInput.composerState; const composerState = draftInput.composerState;
if (!composerState) { if (!composerState) {
throw new Error("Workspace draft composer state is required"); throw new Error("Workspace draft composer state is required");
@@ -112,7 +111,7 @@ export function WorkspaceDraftAgentTab({
}, },
onBeforeSubmit: () => { onBeforeSubmit: () => {
void composerState.persistFormPreferences(); void composerState.persistFormPreferences();
if (Platform.OS === "web") { if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.(); (document.activeElement as HTMLElement | null)?.blur?.();
} }
Keyboard.dismiss(); Keyboard.dismiss();
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native"; import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native"; import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -15,6 +15,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
interface WorkspaceOpenInEditorButtonProps { interface WorkspaceOpenInEditorButtonProps {
serverId: string; serverId: string;
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors = const shouldLoadEditors =
Platform.OS === "web" && isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({ const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId], queryKey: ["available-editors", serverId],
@@ -1,15 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
ActivityIndicator,
BackHandler,
Keyboard,
Platform,
Pressable,
Text,
View,
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
@@ -125,6 +117,7 @@ import {
import { findAdjacentPane } from "@/utils/split-navigation"; import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
@@ -263,7 +256,7 @@ function WorkspaceDocumentTitleEffect({
titleState: "ready" | "loading"; titleState: "ready" | "loading";
}) { }) {
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined") { if (isNative || typeof document === "undefined") {
return; return;
} }
const resolvedLabel = label.trim(); const resolvedLabel = label.trim();
@@ -659,9 +652,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
type ListTerminalsPayload = ListTerminalsResponse["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"];
const terminalsQuery = useQuery({ const terminalsQuery = useQuery({
queryKey: terminalsQueryKey, queryKey: terminalsQueryKey,
enabled: enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory),
Boolean(client && isConnected) &&
Boolean(workspaceDirectory),
queryFn: async () => { queryFn: async () => {
if (!client || !workspaceDirectory) { if (!client || !workspaceDirectory) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
@@ -763,9 +754,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
normalizedServerId, normalizedServerId,
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`, workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
), ),
enabled: enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory),
Boolean(client && isConnected) &&
Boolean(workspaceDirectory),
queryFn: async () => { queryFn: async () => {
if (!client || !workspaceDirectory) { if (!client || !workspaceDirectory) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
@@ -840,7 +829,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}); });
useEffect(() => { useEffect(() => {
if (Platform.OS === "web" || !isExplorerOpen) { if (isWeb || !isExplorerOpen) {
return; return;
} }
@@ -868,7 +857,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
); );
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) => const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
persistenceKey ? state.snapshots[persistenceKey] ?? null : null, persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
); );
const uiTabs = useMemo( const uiTabs = useMemo(
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
@@ -880,7 +869,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent); const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore(
(state) => state.convertDraftToAgent,
);
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs); const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
@@ -1079,8 +1070,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const hasSetupTab = useMemo( const hasSetupTab = useMemo(
() => () =>
uiTabs.some( uiTabs.some(
(tab) => (tab) => tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId,
tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId,
), ),
[normalizedWorkspaceId, uiTabs], [normalizedWorkspaceId, uiTabs],
); );
@@ -1350,7 +1340,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
if (isRunning) { if (isRunning) {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
title: "Archive running agent?", title: "Archive running agent?",
message: "This agent is still running. Archiving it will stop the agent and close the tab.", message:
"This agent is still running. Archiving it will stop the agent and close the tab.",
confirmLabel: "Archive", confirmLabel: "Archive",
cancelLabel: "Cancel", cancelLabel: "Cancel",
destructive: true, destructive: true,
@@ -1369,7 +1360,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}); });
} }
void archiveAgent({ serverId: normalizedServerId, agentId }); // Errors (e.g. timeout) are handled by the mutation's onSettled callback
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
}); });
}, },
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey], [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
@@ -1803,7 +1795,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits; const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) { if (isNative || typeof document === "undefined" || activeTabDescriptor) {
return; return;
} }
document.title = "Workspace"; document.title = "Workspace";
@@ -2038,7 +2030,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return ( return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}> <View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? ( {isWeb && activeTabDescriptor ? (
<WorkspaceTabPresentationResolver <WorkspaceTabPresentationResolver
tab={activeTabDescriptor} tab={activeTabDescriptor}
serverId={normalizedServerId} serverId={normalizedServerId}
@@ -2377,8 +2369,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</View> </View>
</View> </View>
{(!isFocusModeEnabled || isMobile) && ( {(!isFocusModeEnabled || isMobile) &&
workspaceDirectory ? ( (workspaceDirectory ? (
<ExplorerSidebar <ExplorerSidebar
serverId={normalizedServerId} serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId} workspaceId={normalizedWorkspaceId}
@@ -2386,8 +2378,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
isGit={isGitCheckout} isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer} onOpenFile={handleOpenFileFromExplorer}
/> />
) : null ) : null)}
)}
</View> </View>
</View> </View>
); );
@@ -1,7 +1,14 @@
import { type ReactElement } from "react"; import { type ReactElement } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { ChevronDown, ExternalLink, Globe, LoaderCircle, Play, SquareTerminal } from "lucide-react-native"; import {
ChevronDown,
ExternalLink,
Globe,
LoaderCircle,
Play,
SquareTerminal,
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -58,10 +65,9 @@ export function WorkspaceScriptsButton({
return result; return result;
}, },
onError: (error, scriptName) => { onError: (error, scriptName) => {
toast.show( toast.show(error instanceof Error ? error.message : `Failed to start ${scriptName}`, {
error instanceof Error ? error.message : `Failed to start ${scriptName}`, variant: "error",
{ variant: "error" }, });
);
}, },
}); });
@@ -88,9 +94,7 @@ export function WorkspaceScriptsButton({
<Play <Play
size={14} size={14}
color={ color={
hasAnyRunning hasAnyRunning ? theme.colors.palette.blue[500] : theme.colors.foregroundMuted
? theme.colors.palette.blue[500]
: theme.colors.foregroundMuted
} }
fill="transparent" fill="transparent"
/> />
+4 -4
View File
@@ -1,11 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { Platform } from "react-native";
import { File as FSFile, Paths } from "expo-file-system"; import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy"; import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb } from "@/constants/platform";
interface DownloadProgress { interface DownloadProgress {
percent: number; percent: number;
@@ -97,10 +97,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
const downloadUrl = buildDownloadUrl( const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl, downloadTarget.baseUrl,
tokenResponse.token, tokenResponse.token,
Platform.OS === "web" ? downloadTarget.authCredentials : null, isWeb ? downloadTarget.authCredentials : null,
); );
if (Platform.OS === "web") { if (isWeb) {
triggerBrowserDownload(downloadUrl, resolvedFileName); triggerBrowserDownload(downloadUrl, resolvedFileName);
get().completeDownload(id); get().completeDownload(id);
return; return;
@@ -153,7 +153,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to download file."; const message = error instanceof Error ? error.message : "Failed to download file.";
if (Platform.OS === "web") { if (isWeb) {
console.warn("[DownloadStore] Download failed:", message); console.warn("[DownloadStore] Download failed:", message);
get().failDownload(id, message); get().failDownload(id, message);
return; return;

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