`, `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`, `
`, `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
diff --git a/docs/AD-HOC-DAEMON-TESTING.md b/docs/AD-HOC-DAEMON-TESTING.md
new file mode 100644
index 000000000..30c0d2e65
--- /dev/null
+++ b/docs/AD-HOC-DAEMON-TESTING.md
@@ -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.
diff --git a/nix/package.nix b/nix/package.nix
index 1ef702bf2..7781643dd 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
- npmDepsHash = "sha256-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU=";
+ npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
diff --git a/package-lock.json b/package-lock.json
index f08ca897c..3348ae3bc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "paseo",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36169,16 +36169,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
- "@getpaseo/expo-two-way-audio": "0.1.54",
- "@getpaseo/highlight": "0.1.54",
- "@getpaseo/server": "0.1.54",
+ "@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
+ "@getpaseo/highlight": "0.1.55-rc.1",
+ "@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -36319,11 +36319,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/relay": "0.1.54",
- "@getpaseo/server": "0.1.54",
+ "@getpaseo/relay": "0.1.55-rc.1",
+ "@getpaseo/server": "0.1.55-rc.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36364,11 +36364,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
- "@getpaseo/cli": "0.1.54",
- "@getpaseo/server": "0.1.54",
+ "@getpaseo/cli": "0.1.55-rc.1",
+ "@getpaseo/server": "0.1.55-rc.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -36403,7 +36403,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -36604,7 +36604,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -36630,7 +36630,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -36646,14 +36646,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
- "@getpaseo/highlight": "0.1.54",
- "@getpaseo/relay": "0.1.54",
+ "@getpaseo/highlight": "0.1.55-rc.1",
+ "@getpaseo/relay": "0.1.55-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -36672,6 +36672,7 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
+ "p-limit": "^7.3.0",
"pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
@@ -36949,6 +36950,21 @@
"node": ">= 0.6"
}
},
+ "packages/server/node_modules/p-limit": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
+ "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"packages/server/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
@@ -37046,6 +37062,18 @@
"node": ">= 0.6"
}
},
+ "packages/server/node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"packages/server/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -37057,7 +37085,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
diff --git a/package.json b/package.json
index 4360930e5..28f82c417 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "paseo",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts
index c42861f64..a823d8a58 100644
--- a/packages/app/e2e/helpers/launcher.ts
+++ b/packages/app/e2e/helpers/launcher.ts
@@ -117,7 +117,6 @@ export async function clickTerminal(page: Page): Promise
{
await button.click();
}
-
// ─── Tab title assertions ──────────────────────────────────────────────────
/** Wait for any tab in the bar to display the given title text. */
@@ -188,6 +187,8 @@ export async function sampleTabsDuringTransition(
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */
-export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType {
+export async function createWorkspace(
+ prefix = "launcher-e2e-",
+): ReturnType {
return createTempGitRepo(prefix);
}
diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts
new file mode 100644
index 000000000..e9d9a401a
--- /dev/null
+++ b/packages/app/e2e/helpers/new-workspace.ts
@@ -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>;
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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") ?? ""),
+ );
+ throw new Error(
+ [
+ "Expected a newly created workspace to load.",
+ `Current URL: ${page.url()}`,
+ `Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || ""}`,
+ ].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 };
+}
diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts
index 026351e4f..aeab46838 100644
--- a/packages/app/e2e/helpers/terminal-perf.ts
+++ b/packages/app/e2e/helpers/terminal-perf.ts
@@ -8,9 +8,7 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise;
close(): Promise;
- openProject(
- cwd: string,
- ): Promise<{
+ openProject(cwd: string): Promise<{
workspace: { id: number; name: string; projectRootPath: string } | null;
error: string | null;
}>;
diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/helpers/workspace-lifecycle.ts
index 4006e42d2..a462bbfd1 100644
--- a/packages/app/e2e/helpers/workspace-lifecycle.ts
+++ b/packages/app/e2e/helpers/workspace-lifecycle.ts
@@ -1,8 +1,5 @@
import { expect, type Page } from "@playwright/test";
-import {
- clickNewChat,
- clickTerminal,
-} from "./launcher";
+import { clickNewChat, clickTerminal } from "./launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts
index de47cf3aa..7ec6f6252 100644
--- a/packages/app/e2e/helpers/workspace-setup.ts
+++ b/packages/app/e2e/helpers/workspace-setup.ts
@@ -10,9 +10,7 @@ import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = {
connect(): Promise;
close(): Promise;
- openProject(
- cwd: string,
- ): Promise<{
+ openProject(cwd: string): Promise<{
workspace: {
id: number;
name: string;
@@ -21,9 +19,7 @@ type WorkspaceSetupDaemonClient = {
} | null;
error: string | null;
}>;
- createPaseoWorktree(
- input: { cwd: string; worktreeSlug?: string },
- ): Promise<{
+ createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
workspace: {
id: number;
name: string;
@@ -45,15 +41,11 @@ type WorkspaceSetupDaemonClient = {
agent: { id: string; cwd: string; workspaceId?: string | null };
}>;
}>;
- fetchAgent(
- agentId: string,
- ): Promise<{
+ fetchAgent(agentId: string): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown | null;
} | null>;
- listTerminals(
- cwd: string,
- ): Promise<{
+ listTerminals(cwd: string): Promise<{
cwd?: string;
terminals: Array<{ id: string; cwd: string; name: string }>;
error?: string | null;
@@ -77,7 +69,11 @@ function getDaemonWsUrl(): string {
}
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 moduleUrl = pathToFileURL(
@@ -149,7 +145,9 @@ export async function createWorkspaceFromSidebar(page: Page, repoPath: string):
await expect(button).toBeEnabled({ timeout: 30_000 });
await button.click();
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 {
@@ -193,7 +191,10 @@ export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Pr
.click();
}
-export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise {
+export async function waitForWorkspaceSetupDialogToClose(
+ page: Page,
+ timeoutMs = 45_000,
+): Promise {
const dialog = workspaceSetupDialog(page);
try {
@@ -243,7 +244,9 @@ export async function expectSetupLogContains(page: Page, text: string): Promise<
}
export async function expectNoSetupMessage(page: Page): Promise {
- 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,
});
}
@@ -276,7 +279,8 @@ export async function findWorktreeWorkspaceForProject(
const workspace =
payload.entries.find(
(entry) =>
- entry.projectRootPath === normalizedRepoPath && entry.workspaceDirectory !== normalizedRepoPath,
+ entry.projectRootPath === normalizedRepoPath &&
+ entry.workspaceDirectory !== normalizedRepoPath,
) ?? null;
if (!workspace) {
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts
index 34211cf48..7d3202a59 100644
--- a/packages/app/e2e/launcher-tab.spec.ts
+++ b/packages/app/e2e/launcher-tab.spec.ts
@@ -63,9 +63,7 @@ test.describe("Tab creation", () => {
const countAfterFirst = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
- await expect
- .poll(() => countTabsOfKind(page, "draft"))
- .toBe(countAfterFirst + 1);
+ await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countAfterFirst + 1);
});
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);
// Sample tabs at high frequency across the transition
- const snapshots = await sampleTabsDuringTransition(
- page,
- () => clickNewChat(page),
- 2_000,
- 30,
- );
+ const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
// Every snapshot should have at least one tab — no blank/zero-tab frames
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 elapsed = await measureTileTransition(
- page,
- () => clickNewChat(page),
- composer,
- 10_000,
- );
+ const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts
new file mode 100644
index 000000000..e4aa23ff4
--- /dev/null
+++ b/packages/app/e2e/new-workspace.spec.ts
@@ -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>;
+ const localWorkspaceIds = new Set();
+ const createdWorktreeIds = new Set();
+
+ 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();
+ }
+ });
+});
diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts
index fd77319d9..19fbce129 100644
--- a/packages/app/e2e/sidebar-workspace.spec.ts
+++ b/packages/app/e2e/sidebar-workspace.spec.ts
@@ -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));
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
@@ -65,10 +68,7 @@ async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, w
return row;
}
-async function waitForSidebarProject(
- page: import("@playwright/test").Page,
- projectName: string,
-) {
+async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
const row = page
.getByRole("button", {
name: new RegExp(escapeRegex(projectName), "i"),
diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts
index d24a77421..2a8abe1d3 100644
--- a/packages/app/e2e/workspace-cwd.spec.ts
+++ b/packages/app/e2e/workspace-cwd.spec.ts
@@ -2,14 +2,8 @@ import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
-import {
- clickTerminal,
- waitForTabBar,
-} from "./helpers/launcher";
-import {
- setupDeterministicPrompt,
- waitForTerminalContent,
-} from "./helpers/terminal-perf";
+import { clickTerminal, waitForTabBar } from "./helpers/launcher";
+import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
@@ -88,10 +82,13 @@ test.describe("Workspace cwd correctness", () => {
try {
await seedProjectForWorkspaceSetup(client, repo.path);
- execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
- cwd: repo.path,
- stdio: "ignore",
- });
+ execSync(
+ `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
+ {
+ cwd: repo.path,
+ stdio: "ignore",
+ },
+ );
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
@@ -132,5 +129,4 @@ test.describe("Workspace cwd correctness", () => {
await repo.cleanup();
}
});
-
});
diff --git a/packages/app/e2e/workspace-hover-card.spec.ts b/packages/app/e2e/workspace-hover-card.spec.ts
index f58ff4898..e58baf13e 100644
--- a/packages/app/e2e/workspace-hover-card.spec.ts
+++ b/packages/app/e2e/workspace-hover-card.spec.ts
@@ -45,17 +45,21 @@ async function expectScriptInCard(page: Page, scriptName: string): Promise
/** Asserts the script status dot indicates "running". */
async function expectScriptRunning(page: Page, scriptName: string): Promise {
const card = page.getByTestId("workspace-hover-card");
- await expect(
- card.getByTestId(`hover-card-script-status-${scriptName}`),
- ).toHaveAttribute("aria-label", "Running", { timeout: 10_000 });
+ await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute(
+ "aria-label",
+ "Running",
+ { timeout: 10_000 },
+ );
}
/** Asserts the script lifecycle is stopped. */
async function expectScriptStopped(page: Page, scriptName: string): Promise {
const card = page.getByTestId("workspace-hover-card");
- await expect(
- card.getByTestId(`hover-card-script-status-${scriptName}`),
- ).toHaveAttribute("aria-label", "Stopped", { timeout: 10_000 });
+ await expect(card.getByTestId(`hover-card-script-status-${scriptName}`)).toHaveAttribute(
+ "aria-label",
+ "Stopped",
+ { timeout: 10_000 },
+ );
}
/** 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)
const completed = waitForWorkspaceSetupProgress(
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, {
cwd: repo.path,
@@ -182,7 +187,9 @@ test.describe("Workspace hover card", () => {
}
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 expectHoverCard(page, workspace.workspace.name);
diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/workspace-lifecycle.spec.ts
index 86b058e8f..ce2591199 100644
--- a/packages/app/e2e/workspace-lifecycle.spec.ts
+++ b/packages/app/e2e/workspace-lifecycle.spec.ts
@@ -107,10 +107,13 @@ test.describe("Workspace lifecycle", () => {
try {
await seedProjectForWorkspaceSetup(client, repo.path);
- execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
- cwd: repo.path,
- stdio: "ignore",
- });
+ execSync(
+ `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
+ {
+ cwd: repo.path,
+ stdio: "ignore",
+ },
+ );
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
@@ -154,10 +157,13 @@ test.describe("Workspace lifecycle", () => {
try {
await seedProjectForWorkspaceSetup(client, repo.path);
- execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
- cwd: repo.path,
- stdio: "ignore",
- });
+ execSync(
+ `git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
+ {
+ cwd: repo.path,
+ stdio: "ignore",
+ },
+ );
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts
index 1f2820552..0ea8fe84f 100644
--- a/packages/app/e2e/workspace-setup-runtime.spec.ts
+++ b/packages/app/e2e/workspace-setup-runtime.spec.ts
@@ -1,10 +1,7 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
-import {
- clickTerminal,
- waitForTabBar,
-} from "./helpers/launcher";
+import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
@@ -82,15 +79,11 @@ test.describe("Workspace setup runtime authority", () => {
// Verify terminal is listed under the worktree directory, not the original repo
await expect
- .poll(
- async () =>
- (await client.listTerminals(workspaceDir)).terminals.length > 0,
- { timeout: 30_000 },
- )
+ .poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, {
+ timeout: 30_000,
+ })
.toBe(true);
- expect(
- (await client.listTerminals(repo.path)).terminals.length,
- ).toBe(0);
+ expect((await client.listTerminals(repo.path)).terminals.length).toBe(0);
} finally {
await client.close();
await repo.cleanup();
diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts
index 72c8fa240..288d33ec1 100644
--- a/packages/app/e2e/workspace-setup-streaming.spec.ts
+++ b/packages/app/e2e/workspace-setup-streaming.spec.ts
@@ -36,7 +36,9 @@ test.describe("Workspace setup streaming", () => {
const repo = await createTempGitRepo("setup-open-", {
paseoConfig: {
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).
const completed = waitForWorkspaceSetupProgress(
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, {
cwd: repo.path,
@@ -138,7 +141,8 @@ test.describe("Workspace setup streaming", () => {
);
const completed = waitForWorkspaceSetupProgress(
client,
- (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
+ (payload) =>
+ payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -243,7 +247,8 @@ test.describe("Workspace setup streaming", () => {
// Wait for setup completion via daemon (setup snapshots are per-session)
const completed = waitForWorkspaceSetupProgress(
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, {
cwd: repo.path,
@@ -296,7 +301,8 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
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({
diff --git a/packages/app/package.json b/packages/app/package.json
index 0e028248b..3022abd71 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
- "version": "0.1.54",
+ "version": "0.1.55-rc.1",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
- "@getpaseo/expo-two-way-audio": "0.1.54",
- "@getpaseo/highlight": "0.1.54",
- "@getpaseo/server": "0.1.54",
+ "@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
+ "@getpaseo/highlight": "0.1.55-rc.1",
+ "@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 27d6c740e..c212b7e87 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -83,6 +83,7 @@ import {
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
+import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto();
@@ -92,6 +93,22 @@ export type HostRuntimeBootstrapState = {
retry: () => void;
};
+function getRouteParamValue(value: string | string[] | undefined): string | undefined {
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+ }
+ if (Array.isArray(value)) {
+ const firstValue = value[0];
+ if (typeof firstValue !== "string") {
+ return undefined;
+ }
+ const trimmed = firstValue.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+ }
+ return undefined;
+}
+
const HostRuntimeBootstrapContext = createContext({
phase: "starting-daemon",
error: null,
@@ -103,7 +120,7 @@ function PushNotificationRouter() {
const lastHandledIdRef = useRef(null);
useEffect(() => {
- if (Platform.OS === "web") {
+ if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
@@ -392,10 +409,10 @@ function AppContainer({
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
- const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
- const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
- const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
- const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
+ const windowW = isWeb ? window.innerWidth : undefined;
+ const windowH = isWeb ? window.innerHeight : undefined;
+ const dpr = isWeb ? window.devicePixelRatio : undefined;
+ const ua = isWeb ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
@@ -580,7 +597,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [settingsLoading, settings.theme]);
useEffect(() => {
- if (settingsLoading || Platform.OS !== "web") {
+ if (settingsLoading || isNative) {
return;
}
@@ -789,7 +806,14 @@ function RootStack() {
-
+ {
+ const serverId = getRouteParamValue(params?.serverId);
+ const workspaceId = getRouteParamValue(params?.workspaceId);
+ return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
+ }}
+ />
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
index ac38f8434..6962ef4ab 100644
--- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
+++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
@@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from "react";
-import { useGlobalSearchParams, useLocalSearchParams, usePathname, useRootNavigationState } from "expo-router";
-import { Platform } from "react-native";
+import {
+ useGlobalSearchParams,
+ useLocalSearchParams,
+ usePathname,
+ useRootNavigationState,
+} from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
@@ -11,6 +15,7 @@ import {
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
+import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
@@ -91,7 +96,7 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
- if (Platform.OS === "web" && typeof window !== "undefined") {
+ if (isWeb && typeof window !== "undefined") {
const url = new URL(window.location.href);
if (url.searchParams.has("open")) {
url.searchParams.delete("open");
diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx
index 1594e2f20..fe647aa50 100644
--- a/packages/app/src/app/pair-scan.tsx
+++ b/packages/app/src/app/pair-scan.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { Alert, Platform, Pressable, Text, View } from "react-native";
+import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
+import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
}, [router, source, sourceServerId, targetServerId]);
useEffect(() => {
- if (Platform.OS === "web") return;
+ if (isWeb) return;
if (permission && permission.granted) return;
void requestPermission().catch(() => undefined);
}, [permission, requestPermission]);
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
- if (Platform.OS === "web") {
+ if (isWeb) {
return (
diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts
index ea6fd2dc1..ce0837766 100644
--- a/packages/app/src/attachments/store.ts
+++ b/packages/app/src/attachments/store.ts
@@ -1,11 +1,11 @@
-import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
+import { isWeb } from "@/constants/platform";
let attachmentStorePromise: Promise | null = null;
async function createAttachmentStore(): Promise {
- if (Platform.OS === "web") {
+ if (isWeb) {
if (isElectronRuntime()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
index fb9882791..3bdda743f 100644
--- a/packages/app/src/components/adaptive-modal-sheet.tsx
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
-import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
+import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -16,6 +16,7 @@ import {
import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
+import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
@@ -143,7 +144,7 @@ export function AdaptiveModalSheet({
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
useEffect(() => {
- if (isMobile || !visible || Platform.OS !== "web" || typeof window === "undefined") return;
+ if (isMobile || !visible || !isWeb || typeof window === "undefined") return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
@@ -253,16 +254,16 @@ export function AdaptiveModalSheet({
/>
{onFilesDropped ? (
-
- {cardInner}
-
- ) : cardInner}
+ {cardInner}
+ ) : (
+ cardInner
+ )}
);
// On web, use portal to overlay root for consistent stacking with toasts
- if (Platform.OS === "web" && typeof document !== "undefined") {
+ if (isWeb && typeof document !== "undefined") {
if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot());
}
diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx
index 35bc56255..e5364e06d 100644
--- a/packages/app/src/components/add-host-method-modal.tsx
+++ b/packages/app/src/components/add-host-method-modal.tsx
@@ -1,8 +1,9 @@
import { useCallback } from "react";
-import { Pressable, Text, View, Platform } from "react-native";
+import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
+import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
option: {
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
- {Platform.OS !== "web" ? (
+ {isNative ? (
diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
index 6e2d750bb..d8a12afd3 100644
--- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
+++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
-import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
+import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme";
+import { isNative } from "@/constants/platform";
const MODE_ICON_MAP: Record = {
ShieldCheck,
@@ -168,7 +169,7 @@ export function SelectField({
const handleKeyDown = useCallback(
(event: unknown) => {
- if (Platform.OS !== "web") return;
+ if (isNative) return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
const handleKeyDown = useCallback(
(event: unknown) => {
- if (Platform.OS !== "web") return;
+ if (isNative) return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
@@ -556,7 +557,11 @@ export function AgentConfigRow({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
- const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
+ const selectedModeVisuals = getModeVisuals(
+ selectedProvider,
+ effectiveSelectedMode,
+ providerDefinitions,
+ );
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx
index 9245ae1f6..9db2e94c2 100644
--- a/packages/app/src/components/agent-list.tsx
+++ b/packages/app/src/components/agent-list.tsx
@@ -262,23 +262,20 @@ export function AgentList({
[isActionSheetVisible, onAgentSelect],
);
- const handleAgentLongPress = useCallback(
- (agent: AggregatedAgent) => {
- const isRunning = agent.status === "running" || agent.status === "initializing";
- if (isRunning) {
- setActionAgent(agent);
- return;
- }
+ const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
+ const isRunning = agent.status === "running" || agent.status === "initializing";
+ if (isRunning) {
+ setActionAgent(agent);
+ return;
+ }
- const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
- if (!client) {
- setActionAgent(agent);
- return;
- }
- void client.archiveAgent(agent.id);
- },
- [],
- );
+ const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
+ if (!client) {
+ setActionAgent(agent);
+ return;
+ }
+ void client.archiveAgent(agent.id);
+ }, []);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null);
@@ -288,7 +285,8 @@ export function AgentList({
if (!actionAgent || !actionClient) {
return;
}
- void actionClient.archiveAgent(actionAgent.id);
+ // Timeout errors are swallowed — the daemon will still process the archive
+ void actionClient.archiveAgent(actionAgent.id).catch(() => {});
setActionAgent(null);
}, [actionAgent, actionClient]);
diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx
index 501963f7a..f79392b9d 100644
--- a/packages/app/src/components/agent-status-bar.tsx
+++ b/packages/app/src/components/agent-status-bar.tsx
@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
-import { View, Text, Platform, Pressable, Keyboard } from "react-native";
+import { View, Text, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
+import { resolveProviderDefinition } from "@/utils/provider-definitions";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
@@ -40,7 +41,6 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
- AGENT_PROVIDER_DEFINITIONS,
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
@@ -51,6 +51,7 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
+import { isWeb as platformIsWeb } from "@/constants/platform";
type StatusOption = {
id: string;
@@ -59,10 +60,6 @@ type StatusOption = {
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
-const PROVIDER_DEFINITION_MAP = new Map(
- AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
-);
-
type ControlledAgentStatusBarProps = {
provider: string;
providerOptions?: StatusOption[];
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
- providerDefinitions?: AgentProviderDefinition[];
+ providerDefinitions: AgentProviderDefinition[];
allProviderModels?: Map;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set;
@@ -222,7 +219,6 @@ function ControlledStatusBar({
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
- const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState(null);
@@ -252,7 +248,9 @@ function ControlledStatusBar({
thinkingOptions?.[0]?.label ?? "Unknown",
);
- const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
+ const modeVisuals = selectedModeId
+ ? getModeVisuals(provider, selectedModeId, providerDefinitions)
+ : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider);
@@ -300,9 +298,7 @@ function ControlledStatusBar({
);
return map;
}, [modelOptions, provider]);
- const effectiveProviderDefinitions =
- providerDefinitions ??
- (PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
+ const effectiveProviderDefinitions = providerDefinitions;
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo(
@@ -322,7 +318,7 @@ function ControlledStatusBar({
active: boolean;
onPress: () => void;
}) => {
- const visuals = getModeVisuals(provider, option.id);
+ const visuals = getModeVisuals(provider, option.id, providerDefinitions);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
);
},
- [provider, theme.colors.foreground],
+ [provider, providerDefinitions, theme.colors.foreground],
);
const handleOpenChange = useCallback(
@@ -356,7 +352,7 @@ function ControlledStatusBar({
return (
- {isWeb ? (
+ {platformIsWeb ? (
<>
{providerOptions && providerOptions.length > 0 ? (
<>
@@ -745,7 +741,7 @@ function ControlledStatusBar({
{modeOptions.map((mode) => {
- const visuals = getModeVisuals(provider, mode.id);
+ const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
{
- const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
+ const definition = agent?.provider
+ ? resolveProviderDefinition(agent.provider, snapshotEntries)
+ : undefined;
return definition ? [definition] : [];
- }, [agent?.provider]);
+ }, [agent?.provider, snapshotEntries]);
const agentProviderModels = useMemo(() => {
const map = new Map();
@@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({
onModelSelectorOpen,
disabled = false,
}: DraftAgentStatusBarProps) {
- const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo(() => {
@@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
- if (isWeb) {
+ if (platformIsWeb) {
return (
item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -222,7 +223,7 @@ const AgentStreamViewComponent = forwardRef {
- if (autoFocus && Platform.OS === "web" && inputRef.current) {
+ if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
@@ -363,7 +363,7 @@ function ProviderSearchInput({
(null);
const [isOpen, setIsOpen] = useState(false);
- const [isContentReady, setIsContentReady] = useState(isWeb);
+ const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
@@ -601,7 +600,7 @@ export function CombinedModelSelector({
}, [selectedModelLabel]);
useEffect(() => {
- if (isWeb) {
+ if (platformIsWeb) {
return;
}
@@ -615,7 +614,7 @@ export function CombinedModelSelector({
});
return () => cancelAnimationFrame(frame);
- }, [isOpen, isWeb]);
+ }, [isOpen, platformIsWeb]);
return (
<>
@@ -678,7 +677,7 @@ export function CombinedModelSelector({
) : undefined
@@ -793,8 +792,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
- level2Header: {
- },
+ level2Header: {},
backButton: {
flexDirection: "row",
alignItems: "center",
diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx
index 0854707f3..2f1bd0d83 100644
--- a/packages/app/src/components/command-center.tsx
+++ b/packages/app/src/components/command-center.tsx
@@ -1,4 +1,4 @@
-import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
+import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
+import { isNative } from "@/constants/platform";
function agentKey(agent: Pick): string {
return `${agent.serverId}:${agent.id}`;
@@ -90,7 +91,7 @@ export function CommandCenter() {
}
}, [activeIndex, open]);
- if (Platform.OS !== "web" || !open) return null;
+ if (isNative || !open) return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx
index 5d43cd0eb..1ec7fe397 100644
--- a/packages/app/src/components/composer.tsx
+++ b/packages/app/src/components/composer.tsx
@@ -1,4 +1,4 @@
-import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
+import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
+import { isWeb, isNative } from "@/constants/platform";
type QueuedMessage = {
id: string;
@@ -126,7 +127,7 @@ export function Composer({
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
- const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
+ const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -164,7 +165,7 @@ export function Composer({
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor();
- const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
+ const isDesktopWebBreakpoint = isWeb && !isMobile;
const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER;
@@ -225,7 +226,7 @@ export function Composer({
}, [addImages, onAddImages]);
const focusInput = useCallback(() => {
- if (Platform.OS !== "web") return;
+ if (isNative) return;
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
@@ -438,7 +439,7 @@ export function Composer({
case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus":
- if (Platform.OS !== "web") {
+ if (isNative) {
messageInputRef.current?.focus();
return true;
}
@@ -632,14 +633,19 @@ export function Composer({
style={({ hovered }) => [
styles.realtimeVoiceButton 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 }) =>
voice?.isVoiceSwitching ? (
) : (
-
+
)
}
diff --git a/packages/app/src/components/desktop/titlebar-drag-region.tsx b/packages/app/src/components/desktop/titlebar-drag-region.tsx
index d51498a4b..a3067968d 100644
--- a/packages/app/src/components/desktop/titlebar-drag-region.tsx
+++ b/packages/app/src/components/desktop/titlebar-drag-region.tsx
@@ -1,5 +1,5 @@
-import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout";
+import { isNative } from "@/constants/platform";
/**
* VS Code-style titlebar drag region for Electron.
@@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
* Place as FIRST child of any positioned container that should be draggable.
*/
export function TitlebarDragRegion() {
- if (Platform.OS !== "web" || !getIsElectronRuntime()) {
+ if (isNative || !getIsElectronRuntime()) {
return null;
}
diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx
index 29a8d9fd9..7fe88e8df 100644
--- a/packages/app/src/components/diff-viewer.tsx
+++ b/packages/app/src/components/diff-viewer.tsx
@@ -1,12 +1,13 @@
import React from "react";
-import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
+import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { getCodeInsets } from "./code-insets";
+import { isWeb } from "@/constants/platform";
-const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
+const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps {
diffLines: DiffLine[];
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
- ...(Platform.OS === "web"
+ ...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
index 481aadb7a..99281e6a3 100644
--- a/packages/app/src/components/explorer-sidebar.tsx
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -3,7 +3,6 @@ import {
View,
Text,
Pressable,
- Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
+import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record): void {}
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
- const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
+ const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
{/* Resize handle - absolutely positioned over left border */}
-
+
state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx
index 7a6a308ff..6a416cc40 100644
--- a/packages/app/src/components/git-diff-pane.tsx
+++ b/packages/app/src/components/git-diff-pane.tsx
@@ -7,7 +7,6 @@ import {
ActivityIndicator,
Pressable,
FlatList,
- Platform,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
@@ -80,6 +79,7 @@ import {
formatDiffGutterText,
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
+import { isWeb, isNative } from "@/constants/platform";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -100,7 +100,7 @@ type WrappedWebTextStyle = TextStyle & {
};
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
- if (Platform.OS !== "web") {
+ if (isNative) {
return undefined;
}
return wrapLines
@@ -454,7 +454,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
}}
onPressOut={(event) => {
if (
- Platform.OS !== "web" &&
+ isNative &&
!pressHandledRef.current &&
layoutYRef.current === 0 &&
pressInRef.current
@@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
- const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
- const canUseSplitLayout = Platform.OS === "web" && !isMobile;
+ const showDesktopWebScrollbar = isWeb && !isMobile;
+ const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx
index c6a8f1c66..93ebb21a2 100644
--- a/packages/app/src/components/headers/header-toggle-button.tsx
+++ b/packages/app/src/components/headers/header-toggle-button.tsx
@@ -1,16 +1,10 @@
import type { ReactElement, ReactNode } from "react";
-import {
- Platform,
- Text,
- View,
- type PressableProps,
- type StyleProp,
- type ViewStyle,
-} from "react-native";
+import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
+import { isWeb } from "@/constants/platform";
interface HeaderToggleButtonState {
hovered: boolean;
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
: undefined;
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
const ariaExpandedProps =
- Platform.OS === "web" && typeof expandedState === "boolean"
+ isWeb && typeof expandedState === "boolean"
? ({ "aria-expanded": expandedState } as any)
: null;
diff --git a/packages/app/src/components/icons/aider-icon.tsx b/packages/app/src/components/icons/aider-icon.tsx
index 1deb4a218..6b2fc25e6 100644
--- a/packages/app/src/components/icons/aider-icon.tsx
+++ b/packages/app/src/components/icons/aider-icon.tsx
@@ -8,9 +8,7 @@ interface AiderIconProps {
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
return (
);
}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 2ddebe1a4..7ec33e7d0 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -14,7 +14,6 @@ import {
View,
Pressable,
Text,
- Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
@@ -59,6 +58,7 @@ import {
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
+import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400;
@@ -527,7 +527,7 @@ function MobileSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
- const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
+ const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
return (
@@ -833,12 +833,7 @@ function DesktopSidebar({
{/* Resize handle - absolutely positioned over right border */}
-
+
diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx
index 5bbb0ea3d..9fe2400d7 100644
--- a/packages/app/src/components/message-input.tsx
+++ b/packages/app/src/components/message-input.tsx
@@ -9,7 +9,6 @@ import {
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
Image,
- Platform,
BackHandler,
} from "react-native";
import {
@@ -48,6 +47,7 @@ import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation";
+import { isWeb } from "@/constants/platform";
export type ImageAttachment = AttachmentMetadata;
@@ -121,14 +121,16 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46;
const MAX_INPUT_HEIGHT = 160;
-const IS_WEB = Platform.OS === "web";
-const MIN_INPUT_HEIGHT = IS_WEB ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
+const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
+ // Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
+ isComposing?: boolean;
+ keyCode?: number;
}
>;
@@ -232,7 +234,7 @@ export const MessageInput = forwardRef(funct
ref,
) {
const { theme } = useUnistyles();
- const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg;
+ const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
@@ -303,7 +305,7 @@ export const MessageInput = forwardRef(funct
return false;
},
getNativeElement: () => {
- if (!IS_WEB) return null;
+ if (!isWeb) return null;
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null;
@@ -338,7 +340,7 @@ export const MessageInput = forwardRef(funct
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => {
- if (!IS_WEB || !autoFocus) return;
+ if (!isWeb || !autoFocus) return;
return focusWithRetries({
focus: () => textInputRef.current?.focus(),
isFocused: () => {
@@ -381,7 +383,7 @@ export const MessageInput = forwardRef(funct
onChangeText(nextValue);
}
- if (IS_WEB && typeof requestAnimationFrame === "function") {
+ if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
measureWebInputHeight("dictation");
});
@@ -637,13 +639,13 @@ export const MessageInput = forwardRef(funct
const webTextareaRef = useRef(null);
useLayoutEffect(() => {
- if (IS_WEB) {
+ if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
- enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
+ enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
@@ -657,7 +659,7 @@ export const MessageInput = forwardRef(funct
}, []);
useEffect(() => {
- if (!IS_WEB || !onAddImages) {
+ if (!isWeb || !onAddImages) {
return;
}
@@ -710,7 +712,7 @@ export const MessageInput = forwardRef(funct
]);
useEffect(() => {
- if (!IS_WEB || typeof ResizeObserver === "undefined") {
+ if (!isWeb || typeof ResizeObserver === "undefined") {
return;
}
@@ -764,7 +766,7 @@ export const MessageInput = forwardRef(funct
}, [getWebElement, getWebTextArea]);
useEffect(() => {
- if (!IS_WEB) {
+ if (!isWeb) {
return;
}
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
@@ -802,7 +804,7 @@ export const MessageInput = forwardRef(funct
}, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean {
- if (!IS_WEB) return false;
+ if (!isWeb) return false;
const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0;
@@ -856,7 +858,7 @@ export const MessageInput = forwardRef(funct
event: NativeSyntheticEvent,
) {
const contentHeight = event.nativeEvent.contentSize.height;
- if (IS_WEB) {
+ if (isWeb) {
logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight,
});
@@ -876,7 +878,7 @@ export const MessageInput = forwardRef(funct
function handleSelectionChange(event: NativeSyntheticEvent) {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
- if (IS_WEB) {
+ if (isWeb) {
const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(),
@@ -890,12 +892,17 @@ export const MessageInput = forwardRef(funct
onSelectionChangeCallback?.({ start, end });
}
- const shouldHandleDesktopSubmit = IS_WEB;
+ const shouldHandleDesktopSubmit = isWeb;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, "keyPress");
if (!shouldHandleDesktopSubmit) return;
+ // IME composition in progress (e.g. CJK input) — all key events belong to the
+ // IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
+ // cleared before the keydown fires.
+ if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
+
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
const handled = onKeyPressCallback({
@@ -930,7 +937,8 @@ export const MessageInput = forwardRef(funct
const hasRealContent = value.trim().length > 0 || hasImages;
const hasSendableContent = hasRealContent || allowEmptySubmit;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
- const showEmptySubmitLabel = allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading;
+ const showEmptySubmitLabel =
+ allowEmptySubmit && emptySubmitLabel && !hasRealContent && !isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
@@ -947,7 +955,7 @@ export const MessageInput = forwardRef(funct
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue);
- if (IS_WEB) {
+ if (isWeb) {
logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length,
lineCount: nextValue.split("\n").length,
@@ -960,7 +968,10 @@ export const MessageInput = forwardRef(funct
return (
{/* Regular input */}
-
+
{/* Image preview pills */}
{hasImages && (
@@ -978,7 +989,7 @@ export const MessageInput = forwardRef(funct
@@ -1010,7 +1021,7 @@ export const MessageInput = forwardRef(funct
}}
style={[
styles.textInput,
- IS_WEB
+ isWeb
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
@@ -1022,12 +1033,12 @@ export const MessageInput = forwardRef(funct
},
]}
multiline
- scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
+ scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
- autoFocus={IS_WEB && autoFocus}
+ autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
@@ -1051,7 +1062,10 @@ export const MessageInput = forwardRef(funct
]}
>
{({ hovered }) => (
-
+
)}
@@ -1091,9 +1105,15 @@ export const MessageInput = forwardRef(funct
isDictating ? (
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
-
+
) : (
-
+
)
}
@@ -1150,7 +1170,9 @@ export const MessageInput = forwardRef(funct
({
xs: theme.spacing[3],
md: theme.spacing[4],
},
- ...(IS_WEB
+ ...(isWeb
? {
transitionProperty: "border-color",
transitionDuration: "200ms",
@@ -1252,7 +1274,7 @@ const styles = StyleSheet.create(((theme: any) => ({
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
- ...(IS_WEB
+ ...(isWeb
? {
cursor: "pointer",
}
@@ -1277,7 +1299,7 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
- ...(IS_WEB
+ ...(isWeb
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
@@ -1296,7 +1318,7 @@ const styles = StyleSheet.create(((theme: any) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4,
- ...(IS_WEB
+ ...(isWeb
? {
outlineStyle: "none" as const,
outlineWidth: 0,
@@ -1318,7 +1340,7 @@ const styles = StyleSheet.create(((theme: any) => ({
rightButtonGroup: {
flexDirection: "row",
alignItems: "center",
- gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
+ gap: isWeb ? theme.spacing[2] : theme.spacing[1],
},
attachButton: {
width: 28,
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 60a0a02dc..71c4531f8 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -7,7 +7,6 @@ import {
type LayoutChangeEvent,
StyleProp,
ViewStyle,
- Platform,
} from "react-native";
import * as React from "react";
import {
@@ -86,6 +85,7 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { DaemonClient } from "@server/client/daemon-client";
+import { isWeb, isNative } from "@/constants/platform";
interface UserMessageProps {
message: string;
@@ -134,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() {
- if (Platform.OS !== "web") {
+ if (isNative) {
return;
}
if (typeof document === "undefined") {
@@ -341,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
+ const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
- const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
+ const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
return (
setMessageHovered(true) : undefined}
- onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
+ onHoverIn={() => setMessageHovered(true)}
+ onHoverOut={() => setMessageHovered(false)}
>
{hasImages ? (
@@ -434,7 +435,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: 13,
- userSelect: Platform.OS === "web" ? "text" : "auto",
+ userSelect: isWeb ? "text" : "auto",
},
imageFrame: {
width: "100%",
@@ -657,7 +658,7 @@ function MarkdownLink({
children: ReactNode;
}) {
const [hovered, setHovered] = useState(false);
- if (Platform.OS !== "web") {
+ if (isNative) {
return (
onPress(href)} style={style}>
{children}
@@ -779,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return (
onHoverChange?.(true) : undefined}
- onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
+ onHoverIn={() => onHoverChange?.(true)}
+ onHoverOut={() => onHoverChange?.(false)}
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
@@ -1060,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
parsed && onInlinePathPress?.(parsed)}
- selectable={Platform.OS === "web" ? undefined : false}
+ selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
@@ -1653,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
);
- const isWebShimmer = isLoading && Platform.OS === "web";
+ const isWebShimmer = isLoading && isWeb;
const shouldMeasureWebShimmer = isWebShimmer;
- const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
+ const shouldMeasureNativeShimmer = isLoading && isNative;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel
@@ -1732,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
- if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
+ if (isNative || !isExpanded || !hasDetailContent) {
return;
}
diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx
index 785a7add1..a4bd68c61 100644
--- a/packages/app/src/components/project-picker-modal.tsx
+++ b/packages/app/src/components/project-picker-modal.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
+import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
+import { isNative } from "@/constants/platform";
export function ProjectPickerModal() {
const { theme } = useUnistyles();
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
// Keyboard navigation
useEffect(() => {
- if (!open || Platform.OS !== "web") return;
+ if (!open || isNative) return;
function handler(event: KeyboardEvent) {
const key = event.key;
diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx
index 06f6fd7ea..d0e2fffac 100644
--- a/packages/app/src/components/provider-diagnostic-sheet.tsx
+++ b/packages/app/src/components/provider-diagnostic-sheet.tsx
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
+import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
+import { resolveProviderLabel } from "@/utils/provider-definitions";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
-import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
+ const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
const [diagnostic, setDiagnostic] = useState(null);
const [loading, setLoading] = useState(false);
- const providerLabel =
- AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
+ const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx
index d6fa504a6..6609ac108 100644
--- a/packages/app/src/components/question-form-card.tsx
+++ b/packages/app/src/components/question-form-card.tsx
@@ -1,10 +1,11 @@
import { useState, useCallback } from "react";
-import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
+import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
+import { isWeb } from "@/constants/platform";
interface QuestionOption {
label: string;
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
isResponding: boolean;
}
-const IS_WEB = Platform.OS === "web";
+const IS_WEB = isWeb;
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index 6778dbc0b..b182f82b5 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -50,7 +50,10 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
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 {
type SidebarProjectEntry,
@@ -99,6 +102,7 @@ import {
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
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 {
if (!icon) {
@@ -214,18 +218,12 @@ export function PrBadge({ hint }: { hint: PrHint }) {
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
- onPointerEnter={() => setIsHovered(true)}
- onPointerLeave={() => setIsHovered(false)}
- style={({ pressed }) => [
- prBadgeStyles.badge,
- pressed && prBadgeStyles.badgePressed,
- ]}
+ onHoverIn={() => setIsHovered(true)}
+ onHoverOut={() => setIsHovered(false)}
+ style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
>
-
+
#{hint.number}
@@ -281,7 +279,6 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
},
}));
-
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -613,7 +610,7 @@ function useLongPressDragInteraction(input: {
input.drag();
}, DRAG_ARM_DELAY_MS);
- if (!input.menuController || Platform.OS === "web") {
+ if (!input.menuController || platformIsWeb) {
return;
}
@@ -778,7 +775,9 @@ function ProjectHeaderRow({
if (!serverId) {
return;
}
- router.navigate(buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any);
+ router.navigate(
+ buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any,
+ );
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
@@ -836,15 +835,18 @@ function ProjectHeaderRow({
) : null}
{onRemoveProject ? (
}
- trailing={archiveShortcutKeys ? : null}
+ trailing={
+ archiveShortcutKeys ? : null
+ }
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
@@ -1870,7 +1875,6 @@ export function SidebarWorkspaceList({
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor();
- const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set());
@@ -2104,7 +2108,7 @@ export function SidebarWorkspaceList({
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
- useNestable={isNative}
+ useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds}
/>
);
@@ -2121,7 +2125,7 @@ export function SidebarWorkspaceList({
serverId,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
- isNative,
+ platformIsNative,
creatingWorkspaceIds,
],
);
@@ -2145,7 +2149,7 @@ export function SidebarWorkspaceList({
onDragEnd={handleProjectDragEnd}
scrollEnabled={false}
useDragHandle
- nestable={isNative}
+ nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer}
/>
@@ -2156,7 +2160,7 @@ export function SidebarWorkspaceList({
return (
- {isNative ? (
+ {platformIsNative ? (
{
- if (Platform.OS !== "web") {
+ if (isNative) {
return;
}
diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx
index 8b4916875..f2f23b235 100644
--- a/packages/app/src/components/toast-host.tsx
+++ b/packages/app/src/components/toast-host.tsx
@@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
+import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -234,8 +235,8 @@ export function ToastViewport({
);
- if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
+ if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}
diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
index 2e966327c..75c94aa2e 100644
--- a/packages/app/src/components/tool-call-details.tsx
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -1,5 +1,5 @@
import React, { useMemo, ReactNode } from "react";
-import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
+import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
+import { isWeb } from "@/constants/platform";
-const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
+const ScrollView = isWeb ? RNScrollView : GHScrollView;
// ---- Content Component ----
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
- ...(Platform.OS === "web"
+ ...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 39a06b5e0..a646bac20 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -37,8 +37,9 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
+import { isWeb } from "@/constants/platform";
-const IS_WEB = Platform.OS === "web";
+const IS_WEB = isWeb;
export type ComboboxOption = ComboboxOptionModel;
@@ -107,6 +108,7 @@ export interface SearchInputProps {
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
+ useBottomSheetInput?: boolean;
}
export function SearchInput({
@@ -115,10 +117,11 @@ export function SearchInput({
onChangeText,
onSubmitEditing,
autoFocus = false,
+ useBottomSheetInput = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef(null);
- const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
+ const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
@@ -264,9 +267,8 @@ export function Combobox({
}: ComboboxProps): ReactElement {
const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
- const isDesktopAboveSearch =
- !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
- const { height: windowHeight } = useWindowDimensions();
+ const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
+ const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []);
@@ -274,11 +276,13 @@ export function Combobox({
null,
);
const [referenceWidth, setReferenceWidth] = useState(null);
+ const [referenceLeft, setReferenceLeft] = useState(null);
const [referenceTop, setReferenceTop] = useState(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const desktopOptionsScrollRef = useRef(null);
+ const [desktopContentWidth, setDesktopContentWidth] = useState(null);
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
@@ -322,8 +326,8 @@ export function Combobox({
const middleware = useMemo(
() => [
- floatingOffset(Platform.OS === "web" ? 5 : 4),
- ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
+ floatingOffset(isWeb ? 5 : 4),
+ ...(isWeb ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
@@ -336,6 +340,9 @@ export function Combobox({
});
setReferenceWidth((prev) => {
const next = rects.reference.width;
+ if (!(next > 0)) {
+ return prev;
+ }
if (prev === next) return prev;
return next;
});
@@ -346,7 +353,7 @@ export function Combobox({
);
const { refs, floatingStyles, update } = useFloating({
- placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
+ placement: isWeb ? desktopPlacement : "bottom-start",
middleware,
sameScrollView: false,
elements: {
@@ -357,15 +364,18 @@ export function Combobox({
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null);
+ setDesktopContentWidth(null);
+ setReferenceLeft(null);
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf);
- }, [desktopPlacement, isMobile, update, isOpen]);
+ }, [desktopPlacement, isMobile, isOpen, update]);
useEffect(() => {
if (!isOpen || isMobile) {
+ setReferenceLeft(null);
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
@@ -379,9 +389,16 @@ export function Combobox({
}
const measure = () => {
- referenceEl.measureInWindow((x, y) => {
+ referenceEl.measureInWindow((x, y, width, height) => {
+ setReferenceLeft((prev) => (prev === x ? prev : x));
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
+ setReferenceWidth((prev) => {
+ if (!(width > 0)) {
+ return prev;
+ }
+ return prev === width ? prev : width;
+ });
});
};
@@ -396,32 +413,46 @@ export function Combobox({
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null;
- const hasResolvedDesktopPosition =
- referenceWidth !== null &&
- floatingLeft !== null &&
- (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
- ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
- const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
- const shouldUseDesktopFade = !desktopPreventInitialFlash;
- // For top-placed popups: once position resolves, use bottom-based CSS positioning
- // so height changes grow upward naturally without floating-ui needing to reposition.
- const useStableBottom =
+ const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
+ const useMeasuredTopStartPosition =
!isDesktopAboveSearch &&
IS_WEB &&
!isMobile &&
- hasResolvedDesktopPosition &&
- desktopPlacement.startsWith("top") &&
- referenceTop !== null;
+ desktopPlacement === "top-start" &&
+ referenceTop !== null &&
+ referenceLeft !== null &&
+ desktopContentWidth !== null;
+ const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
+ ? Math.max(
+ collisionPadding,
+ Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
+ )
+ : null;
+ const measuredTopStartBottom = useMeasuredTopStartPosition
+ ? Math.max(windowHeight - referenceTop + 5, collisionPadding)
+ : null;
+ const hasResolvedDesktopPosition =
+ referenceWidth !== null &&
+ referenceWidth > 0 &&
+ (isDesktopAboveSearch
+ ? floatingLeft !== null && desktopAboveSearchBottom !== null
+ : useMeasuredTopStartPosition
+ ? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
+ : floatingLeft !== null &&
+ floatingTop !== null &&
+ (hasNonZeroFloatingPosition || !referenceAtOrigin));
+ const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
+ const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
- : useStableBottom
+ : useMeasuredTopStartPosition
? {
- left: floatingLeft ?? 0,
- bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
+ left: clampedMeasuredTopStartLeft ?? 0,
+ bottom: measuredTopStartBottom ?? 0,
}
: floatingStyles;
@@ -626,6 +657,7 @@ export function Combobox({
onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
+ useBottomSheetInput={isMobile}
/>
);
@@ -725,7 +757,13 @@ export function Combobox({
]}
ref={refs.setFloating}
collapsable={false}
- onLayout={() => update()}
+ onLayout={(event) => {
+ const { width, height } = event.nativeEvent.layout;
+ setDesktopContentWidth((prev) => (prev === width ? prev : width));
+ if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
+ void update();
+ }
+ }}
>
{children ? (
<>
diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx
index fef7b5435..a799ba9f4 100644
--- a/packages/app/src/components/ui/context-menu.tsx
+++ b/packages/app/src/components/ui/context-menu.tsx
@@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { isWeb, isNative } from "@/constants/platform";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -254,8 +255,7 @@ export function ContextMenuTrigger({
>): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger");
- const shouldEnableOnThisPlatform =
- enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
+ const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
@@ -294,7 +294,7 @@ export function ContextMenuTrigger({
disabled={disabled}
delayLongPress={longPressDelayMs}
onLongPress={(event) => {
- if (Platform.OS === "web") {
+ if (isWeb) {
props.onLongPress?.(event);
return;
}
@@ -303,7 +303,7 @@ export function ContextMenuTrigger({
}}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => {
- if (Platform.OS !== "web") {
+ if (isNative) {
return;
}
const e: any = event;
diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx
index a3a0618d0..6bd314599 100644
--- a/packages/app/src/components/ui/tooltip.tsx
+++ b/packages/app/src/components/ui/tooltip.tsx
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
+import { useIsCompactFormFactor } from "@/constants/layout";
+import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -108,18 +110,6 @@ function measureElement(element: View): Promise {
});
}
-function isMobileTooltipEnvironment(): boolean {
- if (Platform.OS !== "web") {
- return true;
- }
-
- if (typeof navigator === "undefined") {
- return false;
- }
-
- return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
-}
-
function computePosition({
triggerRect,
contentSize,
@@ -227,8 +217,8 @@ export function Tooltip({
onOpenChange,
});
- const isMobile = isMobileTooltipEnvironment();
- const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
+ const isCompact = useIsCompactFormFactor();
+ const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const value = useMemo(
() => ({
@@ -236,10 +226,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
- openOnPress: isMobile,
+ openOnPress: isCompact,
delayDuration,
}),
- [isOpen, setIsOpen, enabled, isMobile, delayDuration],
+ [isOpen, setIsOpen, enabled, isCompact, delayDuration],
);
return {children};
@@ -257,10 +247,9 @@ export function TooltipTrigger({
triggerRefProp = "ref",
...props
}: PressableProps & {
- asChild?: boolean;
- triggerRefProp?: string;
- }
-): ReactElement {
+ asChild?: boolean;
+ triggerRefProp?: string;
+}): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef | null>(null);
@@ -353,7 +342,7 @@ export function TooltipTrigger({
onFocus: handleFocus,
onBlur: handleBlur,
onPress: handlePress,
- ...(Platform.OS === "web"
+ ...(isWeb
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
@@ -472,7 +461,7 @@ export function TooltipContent({
// On web, avoid React Native's implementation (it uses
- {!showHostList && Platform.OS !== "web" && (
+ {!showHostList && isNative && (
<>
You need the Paseo desktop app or server running on your computer first.
diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx
index fef2f586c..98c7adc3e 100644
--- a/packages/app/src/components/workspace-hover-card.tsx
+++ b/packages/app/src/components/workspace-hover-card.tsx
@@ -289,10 +289,7 @@ function WorkspaceHoverCardContent({
<>
[
- styles.checksSummaryRow,
- hovered && styles.listRowHovered,
- ]}
+ style={({ hovered }) => [styles.checksSummaryRow, hovered && styles.listRowHovered]}
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
>
{({ hovered }) => {
@@ -333,10 +330,7 @@ function WorkspaceHoverCardContent({
},
]}
>
-
+
) : null}
>
diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx
index b59ffbc6d..b509ee4bb 100644
--- a/packages/app/src/components/workspace-setup-dialog.tsx
+++ b/packages/app/src/components/workspace-setup-dialog.tsx
@@ -14,9 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
-import {
- requireWorkspaceExecutionAuthority,
-} from "@/utils/workspace-execution";
+import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input";
@@ -174,7 +172,9 @@ export function WorkspaceSetupDialog() {
}
const encodedImages = await encodeImages(images);
- const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
+ const workspaceDirectory = requireWorkspaceExecutionAuthority({
+ workspace,
+ }).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
@@ -222,7 +222,6 @@ export function WorkspaceSetupDialog() {
],
);
-
const workspaceTitle =
workspace?.name ||
workspace?.projectDisplayName ||
diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts
index 4a02ad47e..a8b077862 100644
--- a/packages/app/src/constants/layout.ts
+++ b/packages/app/src/constants/layout.ts
@@ -1,6 +1,5 @@
-import { Platform } from "react-native";
import { useUnistyles } from "react-native-unistyles";
-import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
+import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75;
@@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
-// Check if running in the Electron desktop runtime (any OS)
-function isElectronDesktopRuntime(): boolean {
- if (Platform.OS !== "web") return false;
- return isElectronRuntime();
-}
-
-// Check if running in the Electron desktop runtime on macOS
-function isElectronDesktopRuntimeMac(): boolean {
- if (Platform.OS !== "web") return false;
- return isElectronRuntimeMac();
-}
-
-// Cached result - only cache true, keep checking if false (in case desktop globals load later)
-let _isElectronRuntimeMacCached: boolean | null = null;
-let _isElectronRuntimeCached: boolean | null = null;
-
-export function getIsElectronRuntimeMac(): boolean {
- if (_isElectronRuntimeMacCached === true) {
- return true;
- }
- const result = isElectronDesktopRuntimeMac();
- if (result) {
- _isElectronRuntimeMacCached = true;
- }
- return result;
-}
-
-export function getIsElectronRuntime(): boolean {
- if (_isElectronRuntimeCached === true) {
- return true;
- }
- const result = isElectronDesktopRuntime();
- if (result) {
- _isElectronRuntimeCached = true;
- }
- return result;
-}
+export {
+ getIsElectron as getIsElectronRuntime,
+ getIsElectronMac as getIsElectronRuntimeMac,
+} from "./platform";
/**
* Reactive hook — re-renders the component when the breakpoint changes.
@@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean {
// Keep that capability distinct from desktop-width layout so touch tablets
// can use the desktop shell without entering web-only code paths.
export function supportsDesktopPaneSplits(): boolean {
- return Platform.OS === "web";
+ return isWeb;
}
diff --git a/packages/app/src/constants/platform.ts b/packages/app/src/constants/platform.ts
new file mode 100644
index 000000000..97fb6d932
--- /dev/null
+++ b/packages/app/src/constants/platform.ts
@@ -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, , 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;
+}
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 79c30c209..1107570b5 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -1,6 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Buffer } from "buffer";
-import { AppState, Platform } from "react-native";
+import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
@@ -55,6 +55,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
+import { isNative } from "@/constants/platform";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -301,12 +302,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const previousAgentStatusRef = useRef