Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 6a298b8f70 fix: always use V2 lazy diff RPCs in new clients
Back-compat is old clients talking to new daemons, not the other way
around. New clients should always use the V2 lazy diff path — there's
no need to fall back to V1 based on a feature flag.

- Remove useLazyDiff feature gate from git-diff-pane
- Always use useCheckoutDiffMetadataQuery (V2)
- Remove useCheckoutDiffQuery (V1) usage from the component
- Remove supportsLazyDiffRpcs utility (no longer needed client-side)
- Server still advertises the flag and serves V1 for old clients
2026-04-14 09:36:38 +07:00
Mohamed Boudra bf1d5d2ab2 feat: lazy-load diff RPCs for mobile performance
Opening a workspace with a large diff blocked the JS thread because the
full structured diff (hunks, lines, syntax tokens) was sent over the
wire and parsed via JSON.parse + Zod safeParse on the main thread.

Add namespaced checkout RPCs that split diff delivery into two phases:

1. **Metadata-only snapshots** (`checkout/subscribe_diff` →
   `checkout/diff_snapshot`) — lightweight file list with status, path,
   insertions/deletions, and a fingerprint. Sent on every file-system
   change. No hunks, no tokens.

2. **On-demand hunk fetching** (`checkout/get_file_hunks` →
   `checkout/file_hunks_response`) — full structured diff for a single
   file, fetched when the user expands it. Served from an in-memory
   cache with a single-file git fallback.

Feature detection via `server_info.features.lazyDiffRpcs`. New clients
fall back to the V1 full-diff path when talking to older daemons. Old
clients are completely unaffected — all new schema fields are optional
and new message types are additive.

Per-file fingerprints (hash of hunks excluding tokens) drive React Query
cache invalidation so expanded files auto-refresh only when their actual
content changes.
2026-04-14 08:59:36 +07:00
github-actions[bot] 8d1def4615 fix: update lockfile signatures and Nix hash 2026-04-13 14:25:52 +00:00
Mohamed Boudra 7ad4482096 chore(release): cut 0.1.55-rc.1 2026-04-13 21:24:53 +07:00
Mohamed Boudra 7ef47e9193 fix: format executable test 2026-04-13 21:05:08 +07:00
Mohamed Boudra bdb300b919 fix: skip Unix-specific which path test on Windows
The "warns when which returns non-absolute path" test exercises
Unix which behavior. On Windows, where.exe path resolution works
differently and the test expectation doesn't apply.
2026-04-13 21:01:28 +07:00
Mohamed Boudra 0d0a8a791a fix: scope Windows CI to Windows-critical test files only
The full server test suite has deep Unix assumptions (hardcoded /tmp
paths, mkdir -p, Unix sockets, bash variable expansion). Rather than
porting the entire suite, run only the tests that exercise
Windows-specific code paths: executable resolution, spawn/exec,
git command handling, provider registry, and config loading.
2026-04-13 20:54:12 +07:00
Mohamed Boudra 4806cd9a51 fix: skip bash-dependent tests on Windows, fix platform-aware assertions
- Skip worktree test suite on Windows (uses bash shell syntax)
- Skip ~ home-root directory test on Windows
- Make findExecutable mock assertions platform-aware (which vs where.exe)
- Use nonexistent binary name for not-found test case
2026-04-13 20:46:10 +07:00
Mohamed Boudra 61201d68a6 fix: Windows test compat — shell quoting, line endings, binary names
- Replace single-quoted git commit messages in worktree tests with
  double quotes (cmd.exe doesn't treat single quotes as delimiters)
- Trim stdout in spawn tests to handle \r\n vs \n
- Use nonexistent binary name in executable test not-found case
2026-04-13 20:36:59 +07:00
Mohamed Boudra 6dfcb4daa9 fix: Windows test compatibility for worktree, snapshot, and history paths
- Replace `mkdir -p` shell calls with `mkdirSync({ recursive: true })`
  in worktree tests (cmd.exe doesn't support -p)
- Normalize path assertions in provider-snapshot-manager tests for
  Windows drive-letter resolved paths
- Add `:` to Claude history path sanitization so Windows drive letters
  (C:\) don't produce invalid directory names
2026-04-13 20:27:44 +07:00
Mohamed Boudra 95e112c185 fix: use PATH-aware resolution for provider availability and fix Windows cmd escaping
Provider isAvailable() was using executableExists() which only checks
filesystem paths, not PATH. Commands like ["claude", "--flag"] would
show as unavailable even though they'd launch fine. Switch to
isCommandAvailable() which uses findExecutable() for proper PATH
resolution. Un-export executableExists from the public API.

Fix Windows cmd.exe metacharacter escaping — &, |, ^, <, >, (, ), !
are now properly escaped with ^, and % is doubled. Add shared
escapeWindowsCmdValue helper used by both quoteWindowsCommand and
quoteWindowsArgument. Replace local quoteForCmd in spawn.ts.

Add server-tests-windows CI job to catch Windows-specific regressions.
2026-04-13 20:14:33 +07:00
Mohamed Boudra 6f28027687 feat: provider profiles — custom provider definitions (#290)
* feat: add provider profiles for custom provider definitions

Users can define custom providers in config.json that appear as first-class
entries alongside built-ins. A provider can override a built-in (custom binary,
env, models) or create a new one by extending a base via `extends`. Generic
ACP transport supported via `extends: "acp"`. Providers can be hidden with
`enabled: false`. Hardcoded models merge with runtime-fetched ones.

- Config schema with Zod validation, auto-migration from old format
- Dynamic provider registry replaces static provider lists
- GenericACPAgentClient for user-defined ACP providers
- Snapshot entries carry label/description/defaultModeId over the wire
- MCP tools accept dynamic provider IDs
- App derives provider definitions from snapshot with static fallback
- CLI `provider ls` calls daemon with label column
- Schedule/session rehydration validates providers against registry

* fix: accept any provider status in CLI provider ls test

The test now connects to a real daemon where providers may be loading
or unavailable, not just the static "available" fallback.

* ci: re-trigger CI checks

* style: fix checkout-git.ts formatting to match CI Biome version

* fix: relax provider ls test assertions for daemon-backed responses

The daemon snapshot may not include all 5 built-in providers in CI
(some require external binaries). Assert at least the core 3
(claude, codex, opencode) instead of all 5.

* fix app combobox dropdown positioning flash

* refactor: stop merging models in provider registry, use override models directly

Override models now replace instead of merge with base provider models.
Also add icon/colorTier fallback from definition modes in fetchModes.

* refactor: make provider definitions fully dynamic from server snapshots

Remove static AGENT_PROVIDER_DEFINITIONS fallbacks from the client — providers,
modes, icons, and color tiers now flow entirely from runtime snapshots. Add icon
and colorTier to AgentMode schema so the server can advertise mode visuals
directly. Fix setAgentMode to persist modeId in agent config so the selected
mode survives session reload. Simplify model merging so profile models replace
runtime models instead of prepending.

* docs: add ad-hoc daemon testing guide
2026-04-13 19:42:01 +07:00
Mohamed Boudra 8122d501b0 fix: add getId to workspace route so navigation updates params (#325)
The workspace Stack.Screen was missing a getId callback, causing
expo-router to reuse the same screen instance across workspace
navigations. This left stale params — sidebar switching and new
workspace creation both appeared to navigate but the header stayed
on the old workspace (skeleton for new workspaces).

Adds e2e tests for sidebar workspace switching and new workspace
creation flow (URL, sidebar row, header, single draft tab).
2026-04-13 18:08:00 +08:00
cjh-storeandMohamed Boudra 8023e1f0c3 fix: skip Enter key handling during IME composition (#270)
* fix: skip Enter key handling during IME composition

During CJK input (Chinese, Japanese, Korean), pressing Enter while
composing characters should confirm character selection, not send the
message. Check isComposing and keyCode 229 on key events before
handling Enter, matching react-native-web's own IME detection logic.

Fixes #241, #264

* fix: scope IME composition guard to web-only handler

Move isComposing check to top of handleDesktopKeyPress so it never
leaks into cross-platform onKeyPress interfaces. This keeps CJK IME
handling explicit and web-gated.

* refactor: remove IS_WEB alias, use isWeb from platform.ts directly

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-13 18:07:30 +08:00
Tianyi CuiandClaude Code a2d652d8d8 Add max reasoning effort option for Opus 4.6 models (#322)
The codebase already has full backend support for "max" effort in types,
validation, SDK passthrough, and error handling. The only missing piece
was the `CLAUDE_THINKING_OPTIONS` array, which didn't surface "max" in
the UI dropdown.

Fixes #271

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-04-13 17:35:26 +08:00
Mohamed Boudra bf76915884 fix: centralize Windows exec handling and async migrations (#318)
* Add Windows exec foundation helpers

* server: centralize windows exec handling phase 2

* migrate cli daemon status to execCommand

* refactor: complete phase 4 async exec migrations

* test: cover windows exec helpers

* fix: use beforeEach for context.skip in test setup
2026-04-13 16:20:59 +08:00
Mohamed Boudra f0c59ec534 feat: add Cmd+, keyboard shortcut to toggle settings (#319)
Add settings.toggle action with Cmd+, (Mac) / Ctrl+, (non-Mac) bindings.
When on a settings route, pressing the shortcut navigates back; otherwise
it pushes to the host settings route. Also adds comma to the shortcut
key map so the combo string parses correctly.
2026-04-13 16:16:23 +08:00
Mohamed Boudra 8aa1833e2b fix(server): await git worktree creation before responding to client
Move createAgentWorktree() from the fire-and-forget background function
into handleCreatePaseoWorktreeRequest so the worktree directory exists
on disk before the client receives the response and navigates. Fixes
ENOENT race when the app queries checkout status on a path that hasn't
been created yet. Setup commands (npm install, etc.) remain background.
2026-04-13 14:55:59 +07:00
Mohamed Boudra b3d5401295 fix(orchestrate): PR delivery requires all CI checks green
The orchestrate skill now keeps the heartbeat alive after PR creation
and monitors CI until all checks pass, fixing failures automatically.
Also requires full PR URLs in all user-facing messages.
2026-04-13 14:49:27 +07:00
Mohamed Boudra 8008ea69dd fix: restore wrapper View for sidebar hover stability
The wrapper View with onPointerEnter/onPointerLeave is needed to
keep hover state stable when the mouse moves from the row to the
kebab dropdown (which opens in an overlay outside the Pressable).
Without it, the Pressable fires onHoverOut and the kebab disappears.

On native, isNative makes controls always-visible so this is
web-only behavior which is correct (onPointerEnter works on web).
2026-04-13 13:39:35 +07:00
Mohamed Boudra 42aaa4e418 fix: format sidebar-workspace-list line wrapping 2026-04-13 13:36:38 +07:00
Mohamed Boudra 0f7fa55a0f Centralize platform gating and fix iPad/tablet support
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
2026-04-13 13:24:00 +07:00
Mohamed Boudraandgithub-actions[bot] 964bf3e13f fix(server): centralize git subprocesses with p-limit throttling (#310)
* Fix uncaught timeout rejection when archiving agents

* fix(server): centralize git subprocesses under runGitCommand with p-limit throttling

Supersedes #299. Same problem (unbounded git subprocess concurrency causing
zombie accumulation), different approach: a single `runGitCommand(args, opts)`
function backed by `p-limit` instead of a custom semaphore pool.

- Always uses `spawn` (one code path, no exec/execFile split)
- Concurrency configurable via PASEO_GIT_CONCURRENCY env var (default 8)
- 30s default timeout for read-only ops, 120s for mutations
- Centralized error formatting, stdout truncation, process reaping
- Runtime tests verifying throttling, timeout kills, deadlock prevention

* fix: update lockfile signatures and Nix hash

* fix(server): add Windows compatibility to runGitCommand

- shell: true on Windows (resolves .cmd/.bat git shims)
- windowsHide: true always (prevents console window flashes)
- quoteForCmd for args with spaces when routing through cmd.exe
- Tests verify shell/windowsHide behavior per platform

* refactor: use spawnProcess instead of duplicating Windows handling

spawnProcess from spawn.ts already handles shell: true on Windows,
arg quoting for cmd.exe, and windowsHide: true. No need to reimplement.

* fix: non-null assertions for stdio streams in strict build

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-13 13:03:01 +08:00
github-actions[bot] e6ff15a1e0 fix: update lockfile signatures and Nix hash 2026-04-12 15:44:09 +00:00
Mohamed Boudra 80a7fd4f3e chore(release): cut 0.1.54 2026-04-12 22:42:51 +07:00
Mohamed Boudra 4547d597ef Add inline image previews in assistant messages 2026-04-12 22:41:54 +07:00
Mohamed Boudra ec445e606c Add 0.1.54 changelog 2026-04-12 22:31:53 +07:00
Mohamed Boudra 0095f61121 Fix Shift+Enter for multiline input in agent terminals
xterm.js sends plain \r for Enter regardless of modifiers. Intercept
modified Enter in the custom key handler so it routes through CSI u
encoding (Kitty keyboard protocol), which Claude Code uses for
Shift+Enter newlines.
2026-04-12 22:10:16 +07:00
Mohamed Boudra 609d11ee8d Replace paseo-orchestrator skill with paseo-orchestrate
Copy the orchestrate skill from ~/.agents/skills into the repo
as paseo-orchestrate, replacing the old paseo-orchestrator.
2026-04-12 22:02:13 +07:00
Mohamed Boudra a161a12a42 Disable MCP injection into agents by default 2026-04-12 22:02:13 +07:00
Mohamed Boudra 39b56af47b Fix Windows MCP config mangling by bypassing cmd.exe shell for Claude spawn
On Windows, spawnProcess defaults to shell: true which routes through cmd.exe.
The SDK passes --mcp-config with inline JSON containing double quotes that
cmd.exe strips, producing an invalid string that Claude Code interprets as a
file path (e.g. D:\xFlow{mcpServers:{paseo:{type:http,url:http:\...}).
2026-04-12 22:02:13 +07:00
Mohamed Boudra 64b9b61223 Align MCP/CLI naming and resolve default model/mode server-side (#291)
* Align create_agent naming and resolve defaults server-side

* Fix test assertions for default model/mode resolution

- Use config.model and config.modeId instead of non-existent snapshot.model
- Strip legacy "default" model ID in normalizeConfig
- Update runtimeInfo assertion to expect resolved default model

* Fix CI: update CLI test for --title, fix formatting in checkout-git
2026-04-12 22:37:59 +08:00
Mohamed Boudra 7c18170d6a Cap app vitest workers to 2 forks to reduce CPU pressure
The app package used the default fork count (= CPU cores), which
combined with concurrent agent test runs was starving the machine.
2026-04-12 15:06:10 +07:00
Mohamed Boudra ca20945b93 Fix ahead-of-origin count for branches with no remote tracking branch
Fall back to counting all local commits when the origin comparison
fails, instead of returning null.
2026-04-12 14:52:41 +07:00
Mohamed Boudra cd4db5d81f Fix release notes sync race when release is recreated by another workflow 2026-04-12 14:01:51 +07:00
github-actions[bot] d1fc271ad4 fix: update lockfile signatures and Nix hash 2026-04-12 06:38:48 +00:00
216 changed files with 11858 additions and 4131 deletions
+25
View File
@@ -76,6 +76,31 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
app-tests: app-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## 0.1.54 - 2026-04-12
### Added
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
### Improved
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
### Fixed
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
## 0.1.53 - 2026-04-12 ## 0.1.53 - 2026-04-12
### Added ### Added
+40
View File
@@ -55,6 +55,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null). - Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Platform gating
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
### The four gates
| Gate | Type | When to use |
|---|---|---|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
### Decision matrix
| I need to... | Use |
|---|---|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
### Rules
- **Default is cross-platform.** Don't gate unless you have a specific reason.
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
```
hooks/
use-audio-recorder.web.ts ← uses Web Audio API
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
## Debugging ## Debugging
+159
View File
@@ -0,0 +1,159 @@
# Ad-hoc daemon testing
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
## Quick start
```typescript
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino";
import { createPaseoDaemon } from "./bootstrap.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
const logger = pino({ level: "warn" });
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const daemon = await createPaseoDaemon(
{
listen: "127.0.0.1:0", // OS picks a free port
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: {},
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
relayEndpoint: "relay.paseo.sh:443",
appBaseUrl: "https://app.paseo.sh",
// Add custom config here, e.g.:
// providerOverrides: { ... },
},
logger,
);
await daemon.start();
const target = daemon.getListenTarget();
const port = target!.type === "tcp" ? target!.port : null;
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.54", // see gotcha #1
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
// ... do your testing ...
await client.close();
await daemon.stop();
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
```
Run with:
```bash
npx tsx packages/server/src/server/your-script.ts
```
## Using the test helper
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
```typescript
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.54",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
// ... test ...
await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
```
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods
```typescript
// Provider discovery
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
const models = await client.listProviderModels("claude");
const modes = await client.listProviderModes("claude");
// Agent lifecycle
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
await client.sendMessage(agent.id, "Hello");
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
```
## Gotchas
### 1. appVersion gates provider visibility
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
Always pass `appVersion`:
```typescript
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.54",
});
```
### 2. Provider snapshots are async
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
```typescript
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
for (let i = 0; i < 20; i++) {
const entry = snapshot.entries.find((e) => e.provider === "gemini");
if (entry && entry.status !== "loading") break;
await new Promise((r) => setTimeout(r, 2_000));
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
}
```
### 3. fetchAgents is required before most operations
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
### 4. listen: "127.0.0.1:0" for port allocation
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
### 5. Script must live inside packages/server
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
### 6. Cleanup on failure
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
```typescript
try {
// ... test logic ...
} finally {
await client.close();
await daemon.stop().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
}
```
### 7. ACP providers spawn real processes
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-0VEwgqUn0j2YWofW8IB5SNpo7Vp3Znfz1TMDAnZFkzs="; npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+47 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.53", "version": "0.1.55-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.53", "version": "0.1.55-rc.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -34906,16 +34906,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.53", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.53", "@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
"@getpaseo/highlight": "0.1.53", "@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/server": "0.1.53", "@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -35056,11 +35056,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.53", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.53", "@getpaseo/relay": "0.1.55-rc.1",
"@getpaseo/server": "0.1.53", "@getpaseo/server": "0.1.55-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -35101,11 +35101,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.53", "version": "0.1.55-rc.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.53", "@getpaseo/cli": "0.1.55-rc.1",
"@getpaseo/server": "0.1.53", "@getpaseo/server": "0.1.55-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -35139,7 +35139,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.53", "version": "0.1.55-rc.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -35340,7 +35340,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.53", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -35366,7 +35366,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.53", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -35382,14 +35382,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.53", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.53", "@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/relay": "0.1.53", "@getpaseo/relay": "0.1.55-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -35406,6 +35406,7 @@
"node-pty": "1.2.0-beta.11", "node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0", "onnxruntime-node": "^1.23.0",
"openai": "^4.20.0", "openai": "^4.20.0",
"p-limit": "^7.3.0",
"pi-acp": "^0.0.24", "pi-acp": "^0.0.24",
"pino": "^10.2.0", "pino": "^10.2.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
@@ -35681,6 +35682,21 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"packages/server/node_modules/p-limit": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/raw-body": { "packages/server/node_modules/raw-body": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
@@ -35778,6 +35794,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/zod": { "packages/server/node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -35789,7 +35817,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.53", "version": "0.1.55-rc.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.53", "version": "0.1.55-rc.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
+231
View File
@@ -0,0 +1,231 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
type NewWorkspaceDaemonClient = Pick<
ServerDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "close"
| "connect"
| "createPaseoWorktree"
| "openProject"
>;
type NewWorkspaceDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export type OpenedProject = {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (
config: NewWorkspaceDaemonClientConfig,
) => NewWorkspaceDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
};
return mod.DaemonClient;
}
function requireWorkspace(payload: OpenProjectPayload) {
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.workspace) {
throw new Error("openProject returned no workspace.");
}
return payload.workspace;
}
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
);
if (!match?.[1]) {
return null;
}
return decodeWorkspaceIdFromPathSegment(match[1]);
}
function parseWorkspaceIdFromSidebarRowTestId(
testId: string,
input: { serverId: string; previousWorkspaceId: string },
): string | null {
const prefix = `sidebar-workspace-row-${input.serverId}:`;
if (!testId.startsWith(prefix)) {
return null;
}
const workspaceId = testId.slice(prefix.length).trim();
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
return null;
}
return workspaceId;
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-new-workspace-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function openProjectViaDaemon(
client: NewWorkspaceDaemonClient,
repoPath: string,
): Promise<OpenedProject> {
const workspace = requireWorkspace(await client.openProject(repoPath));
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
}
export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
if (payload.error) {
throw new Error(payload.error.message);
}
if (!payload.success) {
throw new Error(`Failed to archive workspace: ${workspaceId}`);
}
}
export async function archiveLocalWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<void> {
const payload = await client.archiveWorkspace(workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.archivedAt) {
throw new Error(`Failed to archive workspace: ${workspaceId}`);
}
}
export async function createWorktreeViaDaemon(
client: NewWorkspaceDaemonClient,
input: { cwd: string; slug: string },
): Promise<OpenedProject> {
const payload = await client.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
await expect(button).toBeVisible({ timeout: 30_000 });
await button.click();
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
): Promise<{ workspaceId: string }> {
let workspaceId: string | null = null;
const sidebarWorkspaceRows = page.locator(
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? ""),
);
workspaceId =
sidebarRowTestIds
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
.find((id) => id !== null) ?? null;
if (workspaceId) {
break;
}
await page.waitForTimeout(250);
}
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
);
throw new Error(
[
"Expected a newly created workspace to load.",
`Current URL: ${page.url()}`,
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
].join("\n"),
);
}
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
subtitle: input.projectDisplayName,
});
return { workspaceId };
}
+153
View File
@@ -0,0 +1,153 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
expectWorkspaceHeader,
switchWorkspaceViaSidebar,
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
if (client) {
for (const workspaceId of createdWorktreeIds) {
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeIds.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const firstRepo = await createTempGitRepo("workspace-nav-a-");
const secondRepo = await createTempGitRepo("workspace-nav-b-");
try {
const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path);
localWorkspaceIds.add(firstWorkspace.workspaceId);
localWorkspaceIds.add(secondWorkspace.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(secondRepo.path),
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
});
} finally {
await secondRepo.cleanup();
await firstRepo.cleanup();
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: workspaceLabelFromPath(tempRepo.path),
});
await clickNewWorkspaceButton(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
{
timeout: 30_000,
},
);
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
);
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
subtitle: openedProject.projectDisplayName,
});
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
has: page.getByText("New Agent", { exact: true }),
});
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
} finally {
await tempRepo.cleanup();
}
});
});
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.53", "version": "0.1.55-rc.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.53", "@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
"@getpaseo/highlight": "0.1.53", "@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/server": "0.1.53", "@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
+31 -7
View File
@@ -81,6 +81,7 @@ import {
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto(); polyfillCrypto();
@@ -90,6 +91,22 @@ export type HostRuntimeBootstrapState = {
retry: () => void; retry: () => void;
}; };
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const firstValue = value[0];
if (typeof firstValue !== "string") {
return undefined;
}
const trimmed = firstValue.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
return undefined;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon", phase: "starting-daemon",
error: null, error: null,
@@ -101,7 +118,7 @@ function PushNotificationRouter() {
const lastHandledIdRef = useRef<string | null>(null); const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") { if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null; let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false; let cancelled = false;
@@ -390,10 +407,10 @@ function AppContainer({
const screenW = UnistylesRuntime.screen.width; const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height; const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime(); const isElectron = getIsElectronRuntime();
const windowW = Platform.OS === "web" ? window.innerWidth : undefined; const windowW = isWeb ? window.innerWidth : undefined;
const windowH = Platform.OS === "web" ? window.innerHeight : undefined; const windowH = isWeb ? window.innerHeight : undefined;
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined; const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = Platform.OS === "web" ? navigator.userAgent : undefined; const ua = isWeb ? navigator.userAgent : undefined;
console.log( console.log(
"[layout-debug]", "[layout-debug]",
@@ -577,7 +594,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [settingsLoading, settings.theme]); }, [settingsLoading, settings.theme]);
useEffect(() => { useEffect(() => {
if (settingsLoading || Platform.OS !== "web") { if (settingsLoading || isNative) {
return; return;
} }
@@ -787,7 +804,14 @@ function RootStack() {
<Stack.Screen name="settings" /> <Stack.Screen name="settings" />
<Stack.Screen name="pair-scan" /> <Stack.Screen name="pair-scan" />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" /> <Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) => {
const serverId = getRouteParamValue(params?.serverId);
const workspaceId = getRouteParamValue(params?.workspaceId);
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
}}
/>
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} /> <Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" /> <Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" /> <Stack.Screen name="h/[serverId]/sessions" />
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { Platform } from "react-native";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
@@ -10,6 +9,7 @@ import {
type WorkspaceOpenIntent, type WorkspaceOpenIntent,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string { function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") { if (typeof value === "string") {
@@ -88,7 +88,7 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState // Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the // skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route. // address bar reflects the clean workspace route.
if (Platform.OS === "web" && typeof window !== "undefined") { if (isWeb && typeof window !== "undefined") {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.searchParams.has("open")) { if (url.searchParams.has("open")) {
url.searchParams.delete("open"); url.searchParams.delete("open");
+4 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes"; import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
}, [router, source, sourceServerId, targetServerId]); }, [router, source, sourceServerId, targetServerId]);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") return; if (isWeb) return;
if (permission && permission.granted) return; if (permission && permission.granted) return;
void requestPermission().catch(() => undefined); void requestPermission().catch(() => undefined);
}, [permission, requestPermission]); }, [permission, requestPermission]);
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], [daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
); );
if (Platform.OS === "web") { if (isWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}> <View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
+2 -2
View File
@@ -1,11 +1,11 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types"; import type { AttachmentStore } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
let attachmentStorePromise: Promise<AttachmentStore> | null = null; let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> { async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") { if (isWeb) {
if (isElectronRuntime()) { if (isElectronRuntime()) {
const { createDesktopAttachmentStore } = await import( const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store" "../desktop/attachments/desktop-attachment-store"
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native"; import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -14,6 +14,7 @@ import {
type BottomSheetBackgroundProps, type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
desktopOverlay: { desktopOverlay: {
@@ -216,7 +217,7 @@ export function AdaptiveModalSheet({
); );
// On web, use portal to overlay root for consistent stacking with toasts // On web, use portal to overlay root for consistent stacking with toasts
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
if (!visible) return null; if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot()); return createPortal(desktopContent, getOverlayRoot());
} }
@@ -1,8 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Pressable, Text, View, Platform } from "react-native"; import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
option: { option: {
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
</View> </View>
</Pressable> </Pressable>
{Platform.OS !== "web" ? ( {isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code"> <Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} /> <QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}> <View style={styles.optionBody}>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native"; import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme"; import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = { const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck, ShieldCheck,
@@ -168,7 +169,7 @@ export function SelectField({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: unknown) => { (event: unknown) => {
if (Platform.OS !== "web") return; if (isNative) return;
const key = getWebKey(event); const key = getWebKey(event);
if (key === "Enter" || key === " ") { if (key === "Enter" || key === " ") {
preventWebDefault(event); preventWebDefault(event);
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: unknown) => { (event: unknown) => {
if (Platform.OS !== "web") return; if (isNative) return;
const key = getWebKey(event); const key = getWebKey(event);
if (key === "Enter" || key === " ") { if (key === "Enter" || key === " ") {
preventWebDefault(event); preventWebDefault(event);
@@ -556,7 +557,11 @@ export function AgentConfigRow({
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || ""; selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode); const selectedModeVisuals = getModeVisuals(
selectedProvider,
effectiveSelectedMode,
providerDefinitions,
);
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"]; const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"]; const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
@@ -1,4 +1,4 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit"; import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -118,7 +119,7 @@ export function AgentInputArea({
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`); markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -156,7 +157,7 @@ export function AgentInputArea({
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; const isDesktopWebBreakpoint = isWeb && !isMobile;
const messagePlaceholder = isDesktopWebBreakpoint const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER ? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER; : MOBILE_MESSAGE_PLACEHOLDER;
@@ -217,7 +218,7 @@ export function AgentInputArea({
}, [addImages, onAddImages]); }, [addImages, onAddImages]);
const focusInput = useCallback(() => { const focusInput = useCallback(() => {
if (Platform.OS !== "web") return; if (isNative) return;
focusWithRetries({ focusWithRetries({
focus: () => messageInputRef.current?.focus(), focus: () => messageInputRef.current?.focus(),
isFocused: () => { isFocused: () => {
@@ -430,7 +431,7 @@ export function AgentInputArea({
case "message-input.dictation-confirm": case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus": case "message-input.focus":
if (Platform.OS !== "web") { if (isNative) {
messageInputRef.current?.focus(); messageInputRef.current?.focus();
return true; return true;
} }
+2 -1
View File
@@ -258,7 +258,8 @@ export function AgentList({
if (!actionAgent || !actionClient) { if (!actionAgent || !actionClient) {
return; return;
} }
void actionClient.archiveAgent(actionAgent.id); // Timeout errors are swallowed — the daemon will still process the archive
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
setActionAgent(null); setActionAgent(null);
}, [actionAgent, actionClient]); }, [actionAgent, actionClient]);
@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useRef, useState } from "react"; import { memo, useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native"; import { View, Text, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { import {
buildFavoriteModelKey, buildFavoriteModelKey,
mergeProviderPreferences, mergeProviderPreferences,
@@ -40,7 +41,6 @@ import type {
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { import {
AGENT_PROVIDER_DEFINITIONS,
getModeVisuals, getModeVisuals,
type AgentModeColorTier, type AgentModeColorTier,
type AgentModeIcon, type AgentModeIcon,
@@ -51,6 +51,7 @@ import {
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
type StatusOption = { type StatusOption = {
id: string; id: string;
@@ -59,10 +60,6 @@ type StatusOption = {
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
type ControlledAgentStatusBarProps = { type ControlledAgentStatusBarProps = {
provider: string; provider: string;
providerOptions?: StatusOption[]; providerOptions?: StatusOption[];
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void; onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean; disabled?: boolean;
isModelLoading?: boolean; isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[]; providerDefinitions: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>; allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean; canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>; favoriteKeys?: Set<string>;
@@ -222,7 +219,6 @@ function ControlledStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false); const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null); const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
@@ -252,7 +248,9 @@ function ControlledStatusBar({
thinkingOptions?.[0]?.label ?? "Unknown", thinkingOptions?.[0]?.label ?? "Unknown",
); );
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined; const modeVisuals = selectedModeId
? getModeVisuals(provider, selectedModeId, providerDefinitions)
: undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette); const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider); const ProviderIcon = getProviderIcon(provider);
@@ -300,9 +298,7 @@ function ControlledStatusBar({
); );
return map; return map;
}, [modelOptions, provider]); }, [modelOptions, provider]);
const effectiveProviderDefinitions = const effectiveProviderDefinitions = providerDefinitions;
providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels; const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true); const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>( const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
@@ -322,7 +318,7 @@ function ControlledStatusBar({
active: boolean; active: boolean;
onPress: () => void; onPress: () => void;
}) => { }) => {
const visuals = getModeVisuals(provider, option.id); const visuals = getModeVisuals(provider, option.id, providerDefinitions);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return ( return (
<ComboboxItem <ComboboxItem
@@ -334,7 +330,7 @@ function ControlledStatusBar({
/> />
); );
}, },
[provider, theme.colors.foreground], [provider, providerDefinitions, theme.colors.foreground],
); );
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
@@ -356,7 +352,7 @@ function ControlledStatusBar({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isWeb ? ( {platformIsWeb ? (
<> <>
{providerOptions && providerOptions.length > 0 ? ( {providerOptions && providerOptions.length > 0 ? (
<> <>
@@ -745,7 +741,7 @@ function ControlledStatusBar({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => { {modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id); const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return ( return (
<DropdownMenuItem <DropdownMenuItem
@@ -896,9 +892,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const models = snapshotModels; const models = snapshotModels;
const agentProviderDefinitions = useMemo(() => { const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); const definition = agent?.provider
? resolveProviderDefinition(agent.provider, snapshotEntries)
: undefined;
return definition ? [definition] : []; return definition ? [definition] : [];
}, [agent?.provider]); }, [agent?.provider, snapshotEntries]);
const agentProviderModels = useMemo(() => { const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
@@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => { const mappedModeOptions = useMemo<StatusOption[]>(() => {
@@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) { if (platformIsWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<CombinedModelSelector <CombinedModelSelector
@@ -1129,6 +1126,7 @@ export function DraftAgentStatusBar({
/> />
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={mappedModeOptions} modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode} selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode} onSelectMode={onSelectMode}
@@ -73,6 +73,7 @@ import {
WORKING_INDICATOR_CYCLE_MS, WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS, WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator"; } from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
@@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
tail: streamItems, tail: streamItems,
head: streamHead ?? [], head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native", platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [isMobile, streamHead, streamItems]); }, [isMobile, streamHead, streamItems]);
@@ -331,6 +332,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress} onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
/> />
); );
case "thought": { case "thought": {
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS, DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD, DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
@@ -128,6 +132,25 @@ describe("estimateStreamItemHeight", () => {
expect(estimateStreamItemHeight(item)).toBe(220); expect(estimateStreamItemHeight(item)).toBe(220);
}); });
it("uses cached assistant image metadata when available", () => {
clearAssistantImageMetadataCache();
setAssistantImageMetadata(
{
source: "https://example.com/tall.png",
},
{ width: 800, height: 1600 },
);
const item: StreamItem = {
kind: "assistant_message",
id: "a-image",
text: "Look at this\n\n![Screenshot](https://example.com/tall.png)",
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
});
}); });
describe("web virtualization test overrides", () => { describe("web virtualization test overrides", () => {
@@ -1,4 +1,5 @@
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100; export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50; export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
@@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
case "user_message": case "user_message":
return item.images && item.images.length > 0 ? 220 : 96; return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message": case "assistant_message":
return 220; return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
case "tool_call": case "tool_call":
return 136; return 136;
case "thought": case "thought":
@@ -4,17 +4,17 @@ import {
Text, Text,
TextInput, TextInput,
Pressable, Pressable,
Platform,
ActivityIndicator, ActivityIndicator,
type GestureResponderEvent, type GestureResponderEvent,
} from "react-native"; } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = Platform.OS === "web"; const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
@@ -349,7 +349,7 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput; const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) { if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, 50); }, 50);
@@ -363,7 +363,7 @@ function ProviderSearchInput({
<InputComponent <InputComponent
ref={inputRef as any} ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]} style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
placeholder="Search models..." placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={value} value={value}
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
disabled = false, disabled = false,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb); const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" }); const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -591,7 +590,7 @@ export function CombinedModelSelector({
}, [selectedModelLabel, selectedProviderLabel]); }, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => { useEffect(() => {
if (isWeb) { if (platformIsWeb) {
return; return;
} }
@@ -605,7 +604,7 @@ export function CombinedModelSelector({
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]); }, [isOpen, platformIsWeb]);
return ( return (
<> <>
@@ -668,7 +667,7 @@ export function CombinedModelSelector({
<ProviderSearchInput <ProviderSearchInput
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"} autoFocus={platformIsWeb}
/> />
</View> </View>
) : undefined ) : undefined
@@ -1,4 +1,4 @@
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react"; import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native"; import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; return `${agent.serverId}:${agent.id}`;
@@ -90,7 +91,7 @@ export function CommandCenter() {
} }
}, [activeIndex, open]); }, [activeIndex, open]);
if (Platform.OS !== "web" || !open) return null; if (isNative || !open) return null;
const actionItems = items.filter((item) => item.kind === "action"); const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent"); const agentItems = items.filter((item) => item.kind === "agent");
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
/** /**
* VS Code-style titlebar drag region for Electron. * VS Code-style titlebar drag region for Electron.
@@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
* Place as FIRST child of any positioned container that should be draggable. * Place as FIRST child of any positioned container that should be draggable.
*/ */
export function TitlebarDragRegion() { export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) { if (isNative || !getIsElectronRuntime()) {
return null; return null;
} }
+4 -3
View File
@@ -1,12 +1,13 @@
import React from "react"; import React from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { getCodeInsets } from "./code-insets"; import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps { interface DiffViewerProps {
diffLines: DiffLine[]; diffLines: DiffLine[];
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
@@ -3,7 +3,6 @@ import {
View, View,
Text, Text,
Pressable, Pressable,
Platform,
useWindowDimensions, useWindowDimensions,
StyleSheet as RNStyleSheet, StyleSheet as RNStyleSheet,
} from "react-native"; } from "react-native";
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {} function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
// Mobile: full-screen overlay with gesture. // Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps. // On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused // Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces. // screens so only the active screen exposes explorer/terminal surfaces.
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}> <View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */} {/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector> </GestureDetector>
<SidebarContent <SidebarContent
@@ -1,10 +1,11 @@
import { View, Text, Platform } from "react-native"; import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react"; import { useEffect } from "react";
import { Upload } from "lucide-react-native"; import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone"; import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input"; import type { ImageAttachment } from "./message-input";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps { interface FileDropZoneProps {
children: React.ReactNode; children: React.ReactNode;
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
disabled?: boolean; disabled?: boolean;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) { export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -7,7 +7,6 @@ import {
Pressable, Pressable,
Text, Text,
View, View,
Platform,
} from "react-native"; } from "react-native";
import { Gesture } from "react-native-gesture-handler"; import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -52,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" }, { value: "name", label: "Name" },
@@ -91,7 +91,7 @@ export function FileExplorerPane({
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const daemons = useHosts(); const daemons = useHosts();
const daemonProfile = useMemo( const daemonProfile = useMemo(
+2 -2
View File
@@ -6,7 +6,6 @@ import {
ScrollView as RNScrollView, ScrollView as RNScrollView,
Text, Text,
View, View,
Platform,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -21,6 +20,7 @@ import {
type HighlightStyle, type HighlightStyle,
} from "@getpaseo/highlight"; } from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets"; import { lineNumberGutterWidth } from "@/components/code-insets";
import { isWeb } from "@/constants/platform";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
@@ -243,7 +243,7 @@ export function FilePane({
filePath: string; filePath: string;
}) { }) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
+137 -12
View File
@@ -6,7 +6,6 @@ import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
FlatList, FlatList,
Platform,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -33,11 +32,15 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { import {
useCheckoutDiffQuery,
type ParsedDiffFile, type ParsedDiffFile,
type DiffLine, type DiffLine,
type HighlightToken, type HighlightToken,
} from "@/hooks/use-checkout-diff-query"; } from "@/hooks/use-checkout-diff-query";
import {
useCheckoutDiffMetadataQuery,
type DiffFileMetadata,
} from "@/hooks/use-checkout-diff-metadata-query";
import { useFileHunks } from "@/hooks/use-file-hunks";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -79,6 +82,7 @@ import {
formatDiffGutterText, formatDiffGutterText,
hasVisibleDiffTokens, hasVisibleDiffTokens,
} from "@/utils/diff-rendering"; } from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -99,7 +103,7 @@ type WrappedWebTextStyle = TextStyle & {
}; };
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") { if (isNative) {
return undefined; return undefined;
} }
return wrapLines return wrapLines
@@ -130,8 +134,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
); );
} }
interface DiffFileHeaderFields {
path: string;
isNew: boolean;
isDeleted: boolean;
additions: number;
deletions: number;
}
interface DiffFileSectionProps { interface DiffFileSectionProps {
file: ParsedDiffFile; file: DiffFileHeaderFields;
isExpanded: boolean; isExpanded: boolean;
onToggle: (path: string) => void; onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void; onHeaderHeightChange?: (path: string, height: number) => void;
@@ -453,7 +465,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
}} }}
onPressOut={(event) => { onPressOut={(event) => {
if ( if (
Platform.OS !== "web" && isNative &&
!pressHandledRef.current && !pressHandledRef.current &&
layoutYRef.current === 0 && layoutYRef.current === 0 &&
pressInRef.current pressInRef.current
@@ -617,6 +629,109 @@ function DiffFileBody({
); );
} }
interface LazyDiffFileBodyProps {
file: DiffFileMetadata;
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
layout: "unified" | "split";
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}
function LazyDiffFileBody({
file,
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
layout,
wrapLines,
onBodyHeightChange,
testID,
}: LazyDiffFileBodyProps) {
const {
file: resolvedFile,
isLoading,
isError,
error,
} = useFileHunks({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file,
enabled: true,
});
if (file.status === "binary" || file.status === "too_large") {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
</View>
);
}
if (isLoading) {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<ActivityIndicator size="small" />
</View>
</View>
);
}
if (isError || !resolvedFile) {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{error instanceof Error ? error.message : "Failed to load diff"}
</Text>
</View>
</View>
);
}
return (
<DiffFileBody
file={resolvedFile}
layout={layout}
wrapLines={wrapLines}
onBodyHeightChange={onBodyHeightChange}
testID={testID}
/>
);
}
interface GitDiffPaneProps { interface GitDiffPaneProps {
serverId: string; serverId: string;
workspaceId?: string | null; workspaceId?: string | null;
@@ -625,15 +740,15 @@ interface GitDiffPaneProps {
} }
type DiffFlatItem = type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number }; | { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile; const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter(); const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
@@ -687,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
isError: isDiffError, isError: isDiffError,
error: diffError, error: diffError,
refresh: refreshDiff, refresh: refreshDiff,
} = useCheckoutDiffQuery({ } = useCheckoutDiffMetadataQuery({
serverId, serverId,
cwd, cwd,
mode: diffMode, mode: diffMode,
@@ -788,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
stickyIndices.push(items.length - 1); stickyIndices.push(items.length - 1);
} }
if (isExpanded) { if (isExpanded) {
items.push({ type: "body", file, fileIndex: i }); items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
} }
} }
return { flatItems: items, stickyHeaderIndices: stickyIndices }; return { flatItems: items, stickyHeaderIndices: stickyIndices };
@@ -1068,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
); );
} }
return ( return (
<DiffFileBody <LazyDiffFileBody
file={item.file} file={item.file}
serverId={serverId}
cwd={cwd}
mode={diffMode}
baseRef={baseRef}
ignoreWhitespace={changesPreferences.hideWhitespace}
layout={effectiveLayout} layout={effectiveLayout}
wrapLines={wrapLines} wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange} onBodyHeightChange={handleBodyHeightChange}
@@ -1078,10 +1198,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
); );
}, },
[ [
baseRef,
changesPreferences.hideWhitespace,
cwd,
diffMode,
effectiveLayout, effectiveLayout,
handleBodyHeightChange, handleBodyHeightChange,
handleHeaderHeightChange, handleHeaderHeightChange,
handleToggleExpanded, handleToggleExpanded,
serverId,
wrapLines, wrapLines,
], ],
); );
@@ -1,16 +1,10 @@
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
Platform,
Text,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
interface HeaderToggleButtonState { interface HeaderToggleButtonState {
hovered: boolean; hovered: boolean;
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
: undefined; : undefined;
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded; const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
const ariaExpandedProps = const ariaExpandedProps =
Platform.OS === "web" && typeof expandedState === "boolean" isWeb && typeof expandedState === "boolean"
? ({ "aria-expanded": expandedState } as any) ? ({ "aria-expanded": expandedState } as any)
: null; : null;
+3 -8
View File
@@ -14,7 +14,6 @@ import {
View, View,
Pressable, Pressable,
Text, Text,
Platform,
useWindowDimensions, useWindowDimensions,
StyleSheet as RNStyleSheet, StyleSheet as RNStyleSheet,
} from "react-native"; } from "react-native";
@@ -59,6 +58,7 @@ import {
parseServerIdFromPathname, parseServerIdFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
@@ -527,7 +527,7 @@ function MobileSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
})); }));
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -833,12 +833,7 @@ function DesktopSidebar({
{/* Resize handle - absolutely positioned over right border */} {/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector> </GestureDetector>
</View> </View>
</Animated.View> </Animated.View>
+32 -25
View File
@@ -9,7 +9,6 @@ import {
TextInputKeyPressEventData, TextInputKeyPressEventData,
TextInputSelectionChangeEventData, TextInputSelectionChangeEventData,
Image, Image,
Platform,
BackHandler, BackHandler,
} from "react-native"; } from "react-native";
import { import {
@@ -48,6 +47,7 @@ import {
markScrollInvestigationEvent, markScrollInvestigationEvent,
markScrollInvestigationRender, markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation"; } from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform";
export type ImageAttachment = AttachmentMetadata; export type ImageAttachment = AttachmentMetadata;
@@ -115,13 +115,15 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30; const MIN_INPUT_HEIGHT = 30;
const MAX_INPUT_HEIGHT = 160; const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web";
type WebTextInputKeyPressEvent = NativeSyntheticEvent< type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & { TextInputKeyPressEventData & {
metaKey?: boolean; metaKey?: boolean;
ctrlKey?: boolean; ctrlKey?: boolean;
shiftKey?: boolean; shiftKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
} }
>; >;
@@ -222,7 +224,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref, ref,
) { ) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
markScrollInvestigationRender(investigationComponentId); markScrollInvestigationRender(investigationComponentId);
const toast = useToast(); const toast = useToast();
@@ -293,7 +295,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return false; return false;
}, },
getNativeElement: () => { getNativeElement: () => {
if (!IS_WEB) return null; if (!isWeb) return null;
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null; const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null; return native instanceof HTMLElement ? native : null;
@@ -328,7 +330,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Autofocus on web when autoFocus is true, and re-run when focus key changes. // Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => { useEffect(() => {
if (!IS_WEB || !autoFocus) return; if (!isWeb || !autoFocus) return;
return focusWithRetries({ return focusWithRetries({
focus: () => textInputRef.current?.focus(), focus: () => textInputRef.current?.focus(),
isFocused: () => { isFocused: () => {
@@ -371,7 +373,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText(nextValue); onChangeText(nextValue);
} }
if (IS_WEB && typeof requestAnimationFrame === "function") { if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => { requestAnimationFrame(() => {
measureWebInputHeight("dictation"); measureWebInputHeight("dictation");
}); });
@@ -627,13 +629,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const webTextareaRef = useRef<HTMLElement | null>(null); const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
if (IS_WEB) { if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null; webTextareaRef.current = getWebTextArea() as HTMLElement | null;
} }
}, [getWebTextArea]); }, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, { const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT, enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
}); });
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
@@ -647,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!IS_WEB || !onAddImages) { if (!isWeb || !onAddImages) {
return; return;
} }
@@ -700,7 +702,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]); ]);
useEffect(() => { useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === "undefined") { if (!isWeb || typeof ResizeObserver === "undefined") {
return; return;
} }
@@ -754,7 +756,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebElement, getWebTextArea]); }, [getWebElement, getWebTextArea]);
useEffect(() => { useEffect(() => {
if (!IS_WEB) { if (!isWeb) {
return; return;
} }
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
@@ -792,7 +794,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebTextArea]); }, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean { function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false; if (!isWeb) return false;
const textarea = getWebTextArea(); const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false; if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0; const scrollHeight = textarea.scrollHeight ?? 0;
@@ -846,7 +848,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>, event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) { ) {
const contentHeight = event.nativeEvent.contentSize.height; const contentHeight = event.nativeEvent.contentSize.height;
if (IS_WEB) { if (isWeb) {
logWebStickyBottom("composer_content_size_change", { logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight, reportedHeight: contentHeight,
}); });
@@ -866,7 +868,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) { function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0; const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start; const end = event.nativeEvent.selection?.end ?? start;
if (IS_WEB) { if (isWeb) {
const textarea = getWebTextArea(); const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", { logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(), now: getDebugNow(),
@@ -880,12 +882,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChangeCallback?.({ start, end }); onSelectionChangeCallback?.({ start, end });
} }
const shouldHandleDesktopSubmit = IS_WEB; const shouldHandleDesktopSubmit = isWeb;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, "keyPress"); markScrollInvestigationEvent(investigationComponentId, "keyPress");
if (!shouldHandleDesktopSubmit) return; if (!shouldHandleDesktopSubmit) return;
// IME composition in progress (e.g. CJK input) — all key events belong to the
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
// cleared before the keydown fires.
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation) // Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) { if (onKeyPressCallback) {
const handled = onKeyPressCallback({ const handled = onKeyPressCallback({
@@ -935,7 +942,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(nextValue: string) => { (nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange"); markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue); onChangeText(nextValue);
if (IS_WEB) { if (isWeb) {
logWebStickyBottom("composer_text_changed", { logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length, valueLength: nextValue.length,
lineCount: nextValue.split("\n").length, lineCount: nextValue.split("\n").length,
@@ -966,7 +973,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View <View
style={[ style={[
styles.removeImageButton, styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible, (hovered || !isWeb) && styles.removeImageButtonVisible,
]} ]}
> >
<X size={theme.iconSize.md} color="white" /> <X size={theme.iconSize.md} color="white" />
@@ -998,7 +1005,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}} }}
style={[ style={[
styles.textInput, styles.textInput,
IS_WEB isWeb
? { ? {
height: inputHeight, height: inputHeight,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
@@ -1010,12 +1017,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, },
]} ]}
multiline multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus} autoFocus={isWeb && autoFocus}
/> />
{inputScrollbar} {inputScrollbar}
</View> </View>
@@ -1206,7 +1213,7 @@ const styles = StyleSheet.create(((theme: any) => ({
xs: theme.spacing[3], xs: theme.spacing[3],
md: theme.spacing[4], md: theme.spacing[4],
}, },
...(IS_WEB ...(isWeb
? { ? {
transitionProperty: "border-color", transitionProperty: "border-color",
transitionDuration: "200ms", transitionDuration: "200ms",
@@ -1225,7 +1232,7 @@ const styles = StyleSheet.create(((theme: any) => ({
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
overflow: "hidden", overflow: "hidden",
...(IS_WEB ...(isWeb
? { ? {
cursor: "pointer", cursor: "pointer",
} }
@@ -1250,7 +1257,7 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "center", justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0, opacity: 0,
...(IS_WEB ...(isWeb
? { ? {
transitionProperty: "opacity", transitionProperty: "opacity",
transitionDuration: "150ms", transitionDuration: "150ms",
@@ -1269,7 +1276,7 @@ const styles = StyleSheet.create(((theme: any) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4, lineHeight: theme.fontSize.base * 1.4,
...(IS_WEB ...(isWeb
? { ? {
outlineStyle: "none" as const, outlineStyle: "none" as const,
outlineWidth: 0, outlineWidth: 0,
@@ -1290,7 +1297,7 @@ const styles = StyleSheet.create(((theme: any) => ({
rightButtonGroup: { rightButtonGroup: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], gap: isWeb ? theme.spacing[2] : theme.spacing[1],
}, },
attachButton: { attachButton: {
width: 28, width: 28,
+261 -23
View File
@@ -7,7 +7,6 @@ import {
type LayoutChangeEvent, type LayoutChangeEvent,
StyleProp, StyleProp,
ViewStyle, ViewStyle,
Platform,
} from "react-native"; } from "react-native";
import * as React from "react"; import * as React from "react";
import { import {
@@ -25,6 +24,7 @@ import {
} from "react"; } from "react";
import type { ReactNode, ComponentType } from "react"; import type { ReactNode, ComponentType } from "react";
import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display"; import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display";
import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view"; import MaskedView from "@react-native-masked-view/masked-view";
import { import {
Circle, Circle,
@@ -74,11 +74,18 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation"; import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
export type { InlinePathTarget } from "@/utils/inline-path"; export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card"; import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet"; import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
interface UserMessageProps { interface UserMessageProps {
message: string; message: string;
@@ -127,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y"; type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() { function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
if (typeof document === "undefined") { if (typeof document === "undefined") {
@@ -334,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true, isLastInGroup = true,
disableOuterSpacing, disableOuterSpacing,
}: UserMessageProps) { }: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false); const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0; const hasText = message.trim().length > 0;
const hasImages = images.length > 0; const hasImages = images.length > 0;
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
return ( return (
<View <View
@@ -354,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
> >
<Pressable <Pressable
style={userMessageStylesheet.content} style={userMessageStylesheet.content}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined} onHoverIn={() => setMessageHovered(true)}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined} onHoverOut={() => setMessageHovered(false)}
> >
<View style={userMessageStylesheet.bubble}> <View style={userMessageStylesheet.bubble}>
{hasImages ? ( {hasImages ? (
@@ -401,6 +409,8 @@ interface AssistantMessageProps {
timestamp: number; timestamp: number;
onInlinePathPress?: (target: InlinePathTarget) => void; onInlinePathPress?: (target: InlinePathTarget) => void;
workspaceRoot?: string; workspaceRoot?: string;
serverId?: string;
client?: DaemonClient | null;
disableOuterSpacing?: boolean; disableOuterSpacing?: boolean;
} }
@@ -425,10 +435,217 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: 13, fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto", userSelect: isWeb ? "text" : "auto",
},
imageFrame: {
width: "100%",
minHeight: 160,
marginHorizontal: -theme.spacing[1],
},
imageSurface: {
width: "100%",
overflow: "hidden",
},
image: {
width: "100%",
height: "100%",
},
imageState: {
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
gap: theme.spacing[2],
},
imageErrorText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
}, },
})); }));
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({
uri,
alt,
containerStyle,
source,
workspaceRoot,
serverId,
}: {
uri: string;
alt?: string;
containerStyle?: StyleProp<ViewStyle>;
source: string;
workspaceRoot?: string;
serverId?: string;
}) {
const cachedMetadata = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
[serverId, source, workspaceRoot],
);
const [aspectRatio, setAspectRatio] = useState<number | null>(
cachedMetadata?.aspectRatio ?? null,
);
useEffect(() => {
if (cachedMetadata) {
setAspectRatio(cachedMetadata.aspectRatio);
return;
}
setAspectRatio(null);
let cancelled = false;
Image.getSize(
uri,
(width, height) => {
if (cancelled) {
return;
}
if (width > 0 && height > 0) {
const metadata = setAssistantImageMetadata(
{ source, workspaceRoot, serverId },
{ width, height },
);
setAspectRatio(metadata?.aspectRatio ?? width / height);
}
},
() => {
if (cancelled) {
return;
}
setAspectRatio(null);
},
);
return () => {
cancelled = true;
};
}, [cachedMetadata, serverId, source, uri, workspaceRoot]);
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [
assistantMessageStylesheet.imageSurface,
aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT },
],
[aspectRatio],
);
return (
<View style={[assistantMessageStylesheet.imageFrame, containerStyle]}>
<View style={surfaceStyle}>
<Image
source={{ uri }}
style={assistantMessageStylesheet.image}
resizeMode="contain"
accessibilityLabel={alt}
/>
</View>
</View>
);
});
function AssistantMarkdownImage({
source,
alt,
hasLeadingContent,
client,
workspaceRoot,
serverId,
}: {
source: string;
alt?: string;
hasLeadingContent: boolean;
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
}) {
const { theme } = useUnistyles();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
marginBottom: 0,
}),
[hasLeadingContent, theme],
);
const query = useQuery({
queryKey: [
"assistantMarkdownImage",
serverId ?? "unknown-server",
resolution?.kind === "file_rpc" ? resolution.cwd : null,
resolution?.kind === "file_rpc" ? resolution.path : null,
],
enabled: Boolean(client && resolution?.kind === "file_rpc"),
staleTime: 30_000,
queryFn: async () => {
if (!client || !resolution || resolution.kind !== "file_rpc") {
return null;
}
const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
throw new Error("Image preview unavailable.");
}
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
},
});
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
const resolvedUri = directUri ?? query.data ?? null;
if (resolvedUri) {
return (
<AssistantMarkdownResolvedImage
uri={resolvedUri}
alt={alt}
containerStyle={containerStyle}
source={source}
workspaceRoot={workspaceRoot}
serverId={serverId}
/>
);
}
if (query.isLoading) {
return (
<View
style={[
assistantMessageStylesheet.imageFrame,
containerStyle,
assistantMessageStylesheet.imageState,
]}
>
<ActivityIndicator size="small" />
</View>
);
}
return (
<View
style={[
assistantMessageStylesheet.imageFrame,
containerStyle,
assistantMessageStylesheet.imageState,
]}
>
<Text style={assistantMessageStylesheet.imageErrorText}>
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
</Text>
</View>
);
}
function MarkdownLink({ function MarkdownLink({
href, href,
style, style,
@@ -441,7 +658,7 @@ function MarkdownLink({
children: ReactNode; children: ReactNode;
}) { }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") { if (isNative) {
return ( return (
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}> <Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children} {children}
@@ -563,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return ( return (
<Pressable <Pressable
onPress={handleCopy} onPress={handleCopy}
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined} onHoverIn={() => onHoverChange?.(true)}
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined} onHoverOut={() => onHoverChange?.(false)}
style={[turnCopyButtonStylesheet.container, containerStyle]} style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
@@ -741,6 +958,8 @@ export const AssistantMessage = memo(function AssistantMessage({
timestamp, timestamp,
onInlinePathPress, onInlinePathPress,
workspaceRoot, workspaceRoot,
serverId,
client,
disableOuterSpacing, disableOuterSpacing,
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { theme, rt } = useUnistyles(); const { theme, rt } = useUnistyles();
@@ -777,7 +996,7 @@ export const AssistantMessage = memo(function AssistantMessage({
[onInlinePathPress, workspaceRoot], [onInlinePathPress, workspaceRoot],
); );
const markdownRules = useMemo(() => { const markdownRules = useMemo<RenderRules>(() => {
return { return {
text: ( text: (
node: any, node: any,
@@ -842,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text <Text
key={node.key} key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)} onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={Platform.OS === "web" ? undefined : false} selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
> >
{content} {content}
@@ -892,14 +1111,11 @@ export const AssistantMessage = memo(function AssistantMessage({
</View> </View>
); );
}, },
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => { paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => (
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; <View key={node.key} style={[styles.paragraph, { marginBottom: 0 }]}>
return ( {children}
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}> </View>
{children} ),
</View>
);
},
link: (node: any, children: ReactNode[], _parent: any, styles: any) => ( link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<MarkdownLink <MarkdownLink
key={node.key} key={node.key}
@@ -916,8 +1132,30 @@ export const AssistantMessage = memo(function AssistantMessage({
)} )}
</MarkdownLink> </MarkdownLink>
), ),
image: (node: any, _children: ReactNode[], parent: any, styles: any) => {
const paragraphNode = Array.isArray(parent)
? parent.find((ancestor: any) => ancestor?.type === "paragraph")
: null;
const paragraphChildren = Array.isArray(paragraphNode?.children)
? paragraphNode.children
: [];
const imageIndex = paragraphChildren.findIndex((child: any) => child?.key === node.key);
const hasLeadingContent = imageIndex > 0;
return (
<AssistantMarkdownImage
key={node.key}
source={String(node.attributes?.src ?? "")}
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
hasLeadingContent={hasLeadingContent}
client={client}
workspaceRoot={workspaceRoot}
serverId={serverId}
/>
);
},
}; };
}, [handleLinkPress, markdownParser, onInlinePathPress]); }, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
@@ -1416,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
32, 32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0), Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
); );
const isWebShimmer = isLoading && Platform.OS === "web"; const isWebShimmer = isLoading && isWeb;
const shouldMeasureWebShimmer = isWebShimmer; const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; const shouldMeasureNativeShimmer = isLoading && isNative;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel const webShimmerSpanEndX = secondaryLabel
@@ -1495,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]); }, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { if (isNative || !isExpanded || !hasDetailContent) {
return; return;
} }
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes"; import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
if (!open || Platform.OS !== "web") return; if (!open || isNative) return;
function handler(event: KeyboardEvent) { function handler(event: KeyboardEvent) {
const key = event.key; const key = event.key;
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native"; import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
interface ProviderDiagnosticSheetProps { interface ProviderDiagnosticSheetProps {
provider: string; provider: string;
@@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({
}: ProviderDiagnosticSheetProps) { }: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null); const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const providerLabel = const providerLabel = resolveProviderLabel(provider, snapshotEntries);
AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
const fetchDiagnostic = useCallback(async () => { const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return; if (!client || !provider) return;
@@ -1,10 +1,11 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native"; import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import { isWeb } from "@/constants/platform";
interface QuestionOption { interface QuestionOption {
label: string; label: string;
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
isResponding: boolean; isResponding: boolean;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -199,8 +200,8 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
hitSlop={4} hitSlop={4}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPress={handlePress} onPress={handlePress}
onPointerEnter={() => setIsHovered(true)} onHoverIn={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)} onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]} style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
> >
<GitPullRequest size={12} color={iconColor} /> <GitPullRequest size={12} color={iconColor} />
@@ -543,7 +544,7 @@ function useLongPressDragInteraction(input: {
input.drag(); input.drag();
}, DRAG_ARM_DELAY_MS); }, DRAG_ARM_DELAY_MS);
if (!input.menuController || Platform.OS === "web") { if (!input.menuController || platformIsWeb) {
return; return;
} }
@@ -793,7 +794,7 @@ function ProjectHeaderRow({
<NewWorktreeButton <NewWorktreeButton
displayName={displayName} displayName={displayName}
onPress={() => createWorktreeMutation.mutate()} onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint} visible={isHovered || platformIsNative || isMobileBreakpoint}
loading={createWorktreeMutation.isPending} loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive} showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`} testID={`sidebar-project-new-worktree-${project.projectKey}`}
@@ -801,8 +802,11 @@ function ProjectHeaderRow({
) : null} ) : null}
{onRemoveProject ? ( {onRemoveProject ? (
<View <View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden} style={
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"} !(isHovered || platformIsNative || isMobileBreakpoint) &&
styles.projectKebabButtonHidden
}
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
> >
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -912,8 +916,8 @@ function WorkspaceRowInner({
archiveShortcutKeys, archiveShortcutKeys,
}: WorkspaceRowInnerProps) { }: WorkspaceRowInnerProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const prHint = useWorkspacePrHint({ const prHint = useWorkspacePrHint({
serverId: workspace.serverId, serverId: workspace.serverId,
cwd: workspace.workspaceId, cwd: workspace.workspaceId,
@@ -978,7 +982,7 @@ function WorkspaceRowInner({
</View> </View>
<View style={styles.workspaceRowRight}> <View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null} {isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isTouchPlatform) ? ( {onArchive && (isHovered || platformIsNative || isCompact) ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
hitSlop={8} hitSlop={8}
@@ -1597,7 +1601,6 @@ export function SidebarWorkspaceList({
parentGestureRef, parentGestureRef,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname(); const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set()); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -1831,7 +1834,7 @@ export function SidebarWorkspaceList({
drag={drag} drag={drag}
isDragging={isActive} isDragging={isActive}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
useNestable={isNative} useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds} creatingWorkspaceIds={creatingWorkspaceIds}
/> />
); );
@@ -1848,7 +1851,7 @@ export function SidebarWorkspaceList({
serverId, serverId,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
isNative, platformIsNative,
creatingWorkspaceIds, creatingWorkspaceIds,
], ],
); );
@@ -1872,7 +1875,7 @@ export function SidebarWorkspaceList({
onDragEnd={handleProjectDragEnd} onDragEnd={handleProjectDragEnd}
scrollEnabled={false} scrollEnabled={false}
useDragHandle useDragHandle
nestable={isNative} nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef} simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer} containerStyle={styles.projectListContainer}
/> />
@@ -1883,7 +1886,7 @@ export function SidebarWorkspaceList({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isNative ? ( {platformIsNative ? (
<NestableScrollContainer <NestableScrollContainer
style={styles.list} style={styles.list}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
@@ -27,7 +27,7 @@ import {
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native"; import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -69,6 +69,7 @@ import {
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity"; import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps { interface SplitContainerProps {
layout: WorkspaceLayout; layout: WorkspaceLayout;
@@ -838,7 +839,7 @@ function SplitPaneView({
); );
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
+4 -3
View File
@@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import { import {
@@ -234,8 +235,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none"> <View style={styles.container} pointerEvents="box-none">
<Animated.View <Animated.View
testID={toast.testID ?? "app-toast"} testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss} onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={resumeDismiss} onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[ style={[
styles.toast, styles.toast,
toast.variant === "success" ? styles.toastSuccess : null, toast.variant === "success" ? styles.toastSuccess : null,
@@ -265,7 +266,7 @@ export function ToastViewport({
</View> </View>
); );
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") { if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot()); return createPortal(content, getOverlayRoot());
} }
@@ -1,5 +1,5 @@
import React, { useMemo, ReactNode } from "react"; import React, { useMemo, ReactNode } from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native"; import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
@@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { DiffViewer } from "./diff-viewer"; import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets"; import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView; const ScrollView = isWeb ? RNScrollView : GHScrollView;
// ---- Content Component ---- // ---- Content Component ----
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
lineHeight: 18, lineHeight: 18,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
+65 -27
View File
@@ -37,8 +37,9 @@ import {
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
export type ComboboxOption = ComboboxOptionModel; export type ComboboxOption = ComboboxOptionModel;
@@ -107,6 +108,7 @@ export interface SearchInputProps {
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
onSubmitEditing?: () => void; onSubmitEditing?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
useBottomSheetInput?: boolean;
} }
export function SearchInput({ export function SearchInput({
@@ -115,10 +117,11 @@ export function SearchInput({
onChangeText, onChangeText,
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
useBottomSheetInput = false,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -264,9 +267,8 @@ export function Combobox({
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false); const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
@@ -274,11 +276,13 @@ export function Combobox({
null, null,
); );
const [referenceWidth, setReferenceWidth] = useState<number | null>(null); const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null); const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1); const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null); const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
const isControlled = typeof open === "boolean"; const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
@@ -322,8 +326,8 @@ export function Combobox({
const middleware = useMemo( const middleware = useMemo(
() => [ () => [
floatingOffset(Platform.OS === "web" ? 0 : 4), floatingOffset(isWeb ? 5 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), ...(isWeb ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({ floatingSize({
padding: collisionPadding, padding: collisionPadding,
@@ -336,6 +340,9 @@ export function Combobox({
}); });
setReferenceWidth((prev) => { setReferenceWidth((prev) => {
const next = rects.reference.width; const next = rects.reference.width;
if (!(next > 0)) {
return prev;
}
if (prev === next) return prev; if (prev === next) return prev;
return next; return next;
}); });
@@ -346,7 +353,7 @@ export function Combobox({
); );
const { refs, floatingStyles, update } = useFloating({ const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", placement: isWeb ? desktopPlacement : "bottom-start",
middleware, middleware,
sameScrollView: false, sameScrollView: false,
elements: { elements: {
@@ -357,15 +364,18 @@ export function Combobox({
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setAvailableSize(null); setAvailableSize(null);
setDesktopContentWidth(null);
setReferenceLeft(null);
setReferenceWidth(null); setReferenceWidth(null);
return; return;
} }
const raf = requestAnimationFrame(() => void update()); const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]); }, [desktopPlacement, isMobile, isOpen, update]);
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setReferenceLeft(null);
setReferenceAtOrigin(false); setReferenceAtOrigin(false);
setReferenceTop(null); setReferenceTop(null);
return; return;
@@ -379,9 +389,16 @@ export function Combobox({
} }
const measure = () => { const measure = () => {
referenceEl.measureInWindow((x, y) => { referenceEl.measureInWindow((x, y, width, height) => {
setReferenceLeft((prev) => (prev === x ? prev : x));
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y)); setReferenceTop((prev) => (prev === y ? prev : y));
setReferenceWidth((prev) => {
if (!(width > 0)) {
return prev;
}
return prev === width ? prev : width;
});
}); });
}; };
@@ -396,32 +413,46 @@ export function Combobox({
isDesktopAboveSearch && referenceTop !== null isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding) ? Math.max(windowHeight - referenceTop, collisionPadding)
: null; : null;
const hasResolvedDesktopPosition = const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
referenceWidth !== null && const useMeasuredTopStartPosition =
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
// For top-placed popups: once position resolves, use bottom-based CSS positioning
// so height changes grow upward naturally without floating-ui needing to reposition.
const useStableBottom =
!isDesktopAboveSearch && !isDesktopAboveSearch &&
IS_WEB && IS_WEB &&
!isMobile && !isMobile &&
hasResolvedDesktopPosition && desktopPlacement === "top-start" &&
desktopPlacement.startsWith("top") && referenceTop !== null &&
referenceTop !== null; referenceLeft !== null &&
desktopContentWidth !== null;
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
? Math.max(
collisionPadding,
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
)
: null;
const measuredTopStartBottom = useMeasuredTopStartPosition
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
referenceWidth > 0 &&
(isDesktopAboveSearch
? floatingLeft !== null && desktopAboveSearchBottom !== null
: useMeasuredTopStartPosition
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
: floatingLeft !== null &&
floatingTop !== null &&
(hasNonZeroFloatingPosition || !referenceAtOrigin));
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch const desktopPositionStyle = isDesktopAboveSearch
? { ? {
left: floatingLeft ?? 0, left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0, bottom: desktopAboveSearchBottom ?? 0,
} }
: useStableBottom : useMeasuredTopStartPosition
? { ? {
left: floatingLeft ?? 0, left: clampedMeasuredTopStartLeft ?? 0,
bottom: Math.max(windowHeight - referenceTop!, collisionPadding), bottom: measuredTopStartBottom ?? 0,
} }
: floatingStyles; : floatingStyles;
@@ -626,6 +657,7 @@ export function Combobox({
onChangeText={setSearchQueryWithCallback} onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch} onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile} autoFocus={!isMobile}
useBottomSheetInput={isMobile}
/> />
); );
@@ -725,7 +757,13 @@ export function Combobox({
]} ]}
ref={refs.setFloating} ref={refs.setFloating}
collapsable={false} collapsable={false}
onLayout={() => update()} onLayout={(event) => {
const { width, height } = event.nativeEvent.layout;
setDesktopContentWidth((prev) => (prev === width ? prev : width));
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
void update();
}
}}
> >
{children ? ( {children ? (
<> <>
@@ -32,6 +32,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb, isNative } from "@/constants/platform";
// Keep parity with dropdown-menu action statuses. // Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success"; export type ActionStatus = "idle" | "pending" | "success";
@@ -254,8 +255,7 @@ export function ContextMenuTrigger({
>): ReactElement { >): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger"); const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform = const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback( const openAtEvent = useCallback(
(event: unknown) => { (event: unknown) => {
@@ -294,7 +294,7 @@ export function ContextMenuTrigger({
disabled={disabled} disabled={disabled}
delayLongPress={longPressDelayMs} delayLongPress={longPressDelayMs}
onLongPress={(event) => { onLongPress={(event) => {
if (Platform.OS === "web") { if (isWeb) {
props.onLongPress?.(event); props.onLongPress?.(event);
return; return;
} }
@@ -303,7 +303,7 @@ export function ContextMenuTrigger({
}} }}
// @ts-ignore - onContextMenu is web-only and not in RN types. // @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => { onContextMenu={(event: unknown) => {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
const e: any = event; const e: any = event;
+8 -18
View File
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right"; type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end"; type Align = "start" | "center" | "end";
@@ -108,18 +110,6 @@ function measureElement(element: View): Promise<Rect> {
}); });
} }
function isMobileTooltipEnvironment(): boolean {
if (Platform.OS !== "web") {
return true;
}
if (typeof navigator === "undefined") {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
}
function computePosition({ function computePosition({
triggerRect, triggerRect,
contentSize, contentSize,
@@ -227,8 +217,8 @@ export function Tooltip({
onOpenChange, onOpenChange,
}); });
const isMobile = isMobileTooltipEnvironment(); const isCompact = useIsCompactFormFactor();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop; const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>( const value = useMemo<TooltipContextValue>(
() => ({ () => ({
@@ -236,10 +226,10 @@ export function Tooltip({
setOpen: setIsOpen, setOpen: setIsOpen,
triggerRef, triggerRef,
enabled, enabled,
openOnPress: isMobile, openOnPress: isCompact,
delayDuration, delayDuration,
}), }),
[isOpen, setIsOpen, enabled, isMobile, delayDuration], [isOpen, setIsOpen, enabled, isCompact, delayDuration],
); );
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>; return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -354,7 +344,7 @@ export function TooltipTrigger({
onFocus: handleFocus, onFocus: handleFocus,
onBlur: handleBlur, onBlur: handleBlur,
onPress: handlePress, onPress: handlePress,
...(Platform.OS === "web" ...(isWeb
? ({ ? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable. // RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn, onPointerEnter: handleHoverIn,
@@ -473,7 +463,7 @@ export function TooltipContent({
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can // On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops. // exact same positioning math as DropdownMenu, without hover feedback loops.
if (Platform.OS === "web") { if (isWeb) {
return ( return (
<Portal hostName={bottomSheetInternal?.hostName}> <Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}> <View pointerEvents="none" style={styles.portalOverlay}>
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import { import {
Platform,
type FlatList, type FlatList,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
useWebDesktopScrollbarMetrics, useWebDesktopScrollbarMetrics,
type ScrollbarMetrics, type ScrollbarMetrics,
} from "./web-desktop-scrollbar"; } from "./web-desktop-scrollbar";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5; const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
contentRef?: RefObject<HTMLElement | null>; contentRef?: RefObject<HTMLElement | null>;
}, },
): ReactNode { ): ReactNode {
const isWeb = Platform.OS === "web"; const enabled = (options?.enabled ?? true) && platformIsWeb;
const enabled = (options?.enabled ?? true) && isWeb;
const contentRef = options?.contentRef; const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({ const [metrics, setMetrics] = useState<ScrollbarMetrics>({
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>, scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean }, options?: { enabled?: boolean },
): WebScrollViewScrollbar { ): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web"; const enabled = (options?.enabled ?? true) && platformIsWeb;
const enabled = (options?.enabled ?? true) && isWeb;
const metricsHook = useWebDesktopScrollbarMetrics(); const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback( const onScrollToOffset = useCallback(
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
PanResponder, PanResponder,
Platform,
View, View,
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeScrollEvent, type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
computeScrollOffsetFromDragDelta, computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry, computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math"; } from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5; const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6; const HANDLE_WIDTH_IDLE = 6;
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
maxScrollOffset: 0, maxScrollOffset: 0,
}); });
const onScrollToOffsetRef = useRef(onScrollToOffset); const onScrollToOffsetRef = useRef(onScrollToOffset);
const isWeb = Platform.OS === "web";
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
const normalizedOffset = inverted const normalizedOffset = inverted
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
); );
const panResponder = useMemo(() => { const panResponder = useMemo(() => {
if (isWeb) { if (platformIsWeb) {
return null; return null;
} }
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
setIsDragging(false); setIsDragging(false);
}, },
}); });
}, [applyDragDelta, isWeb]); }, [applyDragDelta, platformIsWeb]);
const startWebDrag = useCallback( const startWebDrag = useCallback(
(event: any) => { (event: any) => {
if (!isWeb) { if (!platformIsWeb) {
return; return;
} }
const clientY = readClientY(event); const clientY = readClientY(event);
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
dragStartClientYRef.current = clientY; dragStartClientYRef.current = clientY;
setIsDragging(true); setIsDragging(true);
}, },
[isWeb], [platformIsWeb],
); );
const handleGrabHoverIn = useCallback(() => { const handleGrabHoverIn = useCallback(() => {
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isWeb || !isDragging) { if (!platformIsWeb || !isDragging) {
return; return;
} }
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging); window.removeEventListener("pointercancel", stopDragging);
}; };
}, [applyDragDelta, isDragging, isWeb]); }, [applyDragDelta, isDragging, platformIsWeb]);
if (!enabled || !geometry.isVisible) { if (!enabled || !geometry.isVisible) {
return null; return null;
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
height: thumbRegionHeight, height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }], transform: [{ translateY: thumbRegionOffset }],
}, },
isWeb && platformIsWeb &&
({ ({
cursor: handleCursor, cursor: handleCursor,
touchAction: "none", touchAction: "none",
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
]} ]}
pointerEvents={handleVisible ? "auto" : "none"} pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})} {...(panResponder?.panHandlers ?? {})}
{...(isWeb {...(platformIsWeb
? ({ ? ({
onPointerDown: startWebDrag, onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn, onPointerEnter: handleGrabHoverIn,
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
backgroundColor: handleColor, backgroundColor: handleColor,
opacity: handleOpacity, opacity: handleOpacity,
}, },
isWeb && platformIsWeb &&
({ ({
transitionProperty: "opacity, width, background-color", transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
+48 -48
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native"; import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
@@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes"; import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
type WelcomeAction = { type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link"; key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -255,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router], [router],
); );
const actions: WelcomeAction[] = const actions: WelcomeAction[] = isWeb
Platform.OS === "web" ? [
? [ {
{ key: "direct-connection",
key: "direct-connection", label: "Direct connection",
label: "Direct connection", testID: "welcome-direct-connection",
testID: "welcome-direct-connection", primary: true,
primary: true, icon: Link2,
icon: Link2, onPress: () => setIsDirectOpen(true),
onPress: () => setIsDirectOpen(true), },
}, {
{ key: "paste-pairing-link",
key: "paste-pairing-link", label: "Paste pairing link",
label: "Paste pairing link", testID: "welcome-paste-pairing-link",
testID: "welcome-paste-pairing-link", primary: false,
primary: false, icon: ClipboardPaste,
icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true),
onPress: () => setIsPasteLinkOpen(true), },
}, ]
] : [
: [ {
{ key: "scan-qr",
key: "scan-qr", label: "Scan QR code",
label: "Scan QR code", testID: "welcome-scan-qr",
testID: "welcome-scan-qr", primary: true,
primary: true, icon: QrCode,
icon: QrCode, onPress: () => router.push("/pair-scan?source=onboarding"),
onPress: () => router.push("/pair-scan?source=onboarding"), },
}, {
{ key: "direct-connection",
key: "direct-connection", label: "Direct connection",
label: "Direct connection", testID: "welcome-direct-connection",
testID: "welcome-direct-connection", primary: false,
primary: false, icon: Link2,
icon: Link2, onPress: () => setIsDirectOpen(true),
onPress: () => setIsDirectOpen(true), },
}, {
{ key: "paste-pairing-link",
key: "paste-pairing-link", label: "Paste pairing link",
label: "Paste pairing link", testID: "welcome-paste-pairing-link",
testID: "welcome-paste-pairing-link", primary: false,
primary: false, icon: ClipboardPaste,
icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true),
onPress: () => setIsPasteLinkOpen(true), },
}, ];
];
const showHostList = hosts.length > 0 && !anyOnlineServerId; const showHostList = hosts.length > 0 && !anyOnlineServerId;
@@ -321,7 +321,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text> </Text>
{!showHostList && Platform.OS !== "web" && ( {!showHostList && isNative && (
<> <>
<Text style={styles.setupHint}> <Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first. You need the Paseo desktop app or server running on your computer first.
+6 -40
View File
@@ -1,6 +1,5 @@
import { Platform } from "react-native";
import { useUnistyles } from "react-native-unistyles"; import { useUnistyles } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75; export const FOOTER_HEIGHT = 75;
@@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140; export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48; export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in the Electron desktop runtime (any OS) export {
function isElectronDesktopRuntime(): boolean { getIsElectron as getIsElectronRuntime,
if (Platform.OS !== "web") return false; getIsElectronMac as getIsElectronRuntimeMac,
return isElectronRuntime(); } from "./platform";
}
// Check if running in the Electron desktop runtime on macOS
function isElectronDesktopRuntimeMac(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntimeMac();
}
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isElectronRuntimeMacCached: boolean | null = null;
let _isElectronRuntimeCached: boolean | null = null;
export function getIsElectronRuntimeMac(): boolean {
if (_isElectronRuntimeMacCached === true) {
return true;
}
const result = isElectronDesktopRuntimeMac();
if (result) {
_isElectronRuntimeMacCached = true;
}
return result;
}
export function getIsElectronRuntime(): boolean {
if (_isElectronRuntimeCached === true) {
return true;
}
const result = isElectronDesktopRuntime();
if (result) {
_isElectronRuntimeCached = true;
}
return result;
}
/** /**
* Reactive hook — re-renders the component when the breakpoint changes. * Reactive hook — re-renders the component when the breakpoint changes.
@@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean {
// Keep that capability distinct from desktop-width layout so touch tablets // Keep that capability distinct from desktop-width layout so touch tablets
// can use the desktop shell without entering web-only code paths. // can use the desktop shell without entering web-only code paths.
export function supportsDesktopPaneSplits(): boolean { export function supportsDesktopPaneSplits(): boolean {
return Platform.OS === "web"; return isWeb;
} }
+49
View File
@@ -0,0 +1,49 @@
import { Platform } from "react-native";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
// ---------------------------------------------------------------------------
// Runtime environment constants
//
// These are the ONLY platform gates in the app. See CLAUDE.md for the
// decision matrix on when to use each one.
//
// Default is cross-platform. Gate only when you must:
// isWeb → DOM APIs (document, window, <div>, addEventListener)
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
//
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
// For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed.
// ---------------------------------------------------------------------------
/** Browser or Electron — the JS runtime has access to the DOM. */
export const isWeb = Platform.OS === "web";
/** iOS or Android — the JS runtime is React Native. */
export const isNative = Platform.OS !== "web";
// ---------------------------------------------------------------------------
// Electron detection (cached — only caches `true`, keeps checking if false
// because the desktop bridge may load after initial module evaluation)
// ---------------------------------------------------------------------------
let _isElectronCached: boolean | null = null;
let _isElectronMacCached: boolean | null = null;
/** Running inside the Electron desktop wrapper (any OS). */
export function getIsElectron(): boolean {
if (_isElectronCached === true) return true;
if (!isWeb) return false;
const result = isElectronRuntime();
if (result) _isElectronCached = true;
return result;
}
/** Running inside the Electron desktop wrapper on macOS. */
export function getIsElectronMac(): boolean {
if (_isElectronMacCached === true) return true;
if (!isWeb) return false;
const result = isElectronRuntimeMac();
if (result) _isElectronMacCached = true;
return result;
}
@@ -1,6 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
@@ -52,6 +52,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { isNative } from "@/constants/platform";
// Re-export types from session-store and draft-store for backward compatibility // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
(awayMs: number) => { (awayMs: number) => {
scheduleAuthoritativeRevalidation(); scheduleAuthoritativeRevalidation();
if (Platform.OS !== "web") { if (isNative) {
const session = useSessionStore.getState().sessions[serverId]; const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId; const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined; const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { isWeb, isNative } from "@/constants/platform";
export type DesktopPermissionKind = "notifications" | "microphone"; export type DesktopPermissionKind = "notifications" | "microphone";
@@ -45,7 +45,7 @@ type NavigatorLike = {
}; };
export function shouldShowDesktopPermissionSection(): boolean { export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === "web" && getDesktopHost() !== null; return isWeb && getDesktopHost() !== null;
} }
function status(input: DesktopPermissionStatus): DesktopPermissionStatus { function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
} }
function getWebNotificationConstructor(): NotificationConstructorLike | null { function getWebNotificationConstructor(): NotificationConstructorLike | null {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
} }
function getNavigatorLike(): NavigatorLike | null { function getNavigatorLike(): NavigatorLike | null {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
const webNavigator = (globalThis as { navigator?: unknown }).navigator; const webNavigator = (globalThis as { navigator?: unknown }).navigator;
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
} }
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> { async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop notification status is only available on web runtime.", detail: "Desktop notification status is only available on web runtime.",
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
} }
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.", detail: "Desktop microphone status is only available on web runtime.",
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
} }
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> { async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.", detail: "Desktop notification requests are only available on web runtime.",
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
} }
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> { async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") { if (isNative) {
return status({ return status({
state: "unavailable", state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.", detail: "Desktop microphone requests are only available on web runtime.",
@@ -1,6 +1,6 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import { isWeb } from "@/constants/platform";
export interface DesktopAppUpdateCheckResult { export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean; hasUpdate: boolean;
@@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
} }
export function shouldShowDesktopUpdateSection(): boolean { export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === "web" && isElectronRuntime(); return isWeb && isElectronRuntime();
} }
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult { export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
@@ -1,6 +1,6 @@
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; interface DiffFileWithPath {
path: string;
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number]; }
export function compareCheckoutDiffPaths(left: string, right: string): number { export function compareCheckoutDiffPaths(left: string, right: string): number {
if (left === right) { if (left === right) {
@@ -9,7 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
return left < right ? -1 : 1; return left < right ? -1 : 1;
} }
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] { export function orderCheckoutDiffFiles<T extends DiffFileWithPath>(files: T[]): T[] {
if (files.length < 2) { if (files.length < 2) {
return files; return files;
} }
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { import {
shouldClearAgentAttention, shouldClearAgentAttention,
type AgentAttentionClearTrigger, type AgentAttentionClearTrigger,
} from "@/utils/agent-attention"; } from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
type AttentionReason = "finished" | "error" | "permission" | null | undefined; type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -70,7 +71,7 @@ export function useAgentAttentionClear({
const appStateSubscription = AppState.addEventListener("change", updateVisibility); const appStateSubscription = AppState.addEventListener("change", updateVisibility);
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", updateVisibility); document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility); window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility); window.addEventListener("blur", updateVisibility);
@@ -1,12 +1,117 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state"; import { __private__ } from "./use-agent-form-state";
import { import { buildProviderDefinitions } from "@/utils/provider-definitions";
AGENT_PROVIDER_DEFINITIONS, import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
type AgentProviderDefinition, import type {
} from "@server/server/agent/provider-manifest"; AgentModelDefinition,
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
id: "codex",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
id: "claude",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
function makeProviderMap(
...definitions: AgentProviderDefinition[]
): Map<AgentProvider, AgentProviderDefinition> {
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
}
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
describe("useAgentFormState", () => { describe("useAgentFormState", () => {
describe("buildProviderDefinitions", () => {
it("returns empty array when snapshot data is unavailable", () => {
expect(buildProviderDefinitions(undefined)).toEqual([]);
expect(buildProviderDefinitions([])).toEqual([]);
});
it("builds custom provider definitions from snapshot metadata", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "zai",
status: "ready",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
provider: "claude",
status: "ready",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
},
];
const definitions = buildProviderDefinitions(entries);
expect(definitions).toEqual([
{
id: "zai",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
id: "claude",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Always Ask",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
]);
});
});
describe("__private__.combineInitialValues", () => { describe("__private__.combineInitialValues", () => {
it("returns undefined when no initial values and no initial server id", () => { it("returns undefined when no initial values and no initial server id", () => {
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined(); expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
@@ -78,6 +183,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -106,6 +212,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -134,6 +241,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.thinkingOptionId).toBe("xhigh"); expect(resolved.thinkingOptionId).toBe("xhigh");
@@ -161,6 +269,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -188,6 +297,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -215,6 +325,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
codexProviderMap,
); );
expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.model).toBe("gpt-5.3-codex");
@@ -256,6 +367,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
claudeProviderMap,
); );
expect(resolved.model).toBe("default"); expect(resolved.model).toBe("default");
@@ -263,11 +375,6 @@ describe("useAgentFormState", () => {
}); });
it("resolves provider only from allowed provider map", () => { it("resolves provider only from allowed provider map", () => {
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map(
(definition) => [definition.id as AgentProvider, definition],
),
);
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
{ provider: "codex" }, { provider: "codex" },
@@ -289,7 +396,7 @@ describe("useAgentFormState", () => {
workingDir: "", workingDir: "",
}, },
new Set<string>(), new Set<string>(),
allowedProviderMap, claudeProviderMap,
); );
expect(resolved.provider).toBe("claude"); expect(resolved.provider).toBe("claude");
+14 -20
View File
@@ -1,8 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type { import type {
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -10,6 +7,7 @@ import type {
ProviderSnapshotEntry, ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useProvidersSnapshot } from "./use-providers-snapshot";
import { import {
useFormPreferences, useFormPreferences,
@@ -99,13 +97,8 @@ type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>; persistFormPreferences: () => Promise<void>;
}; };
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS; const DEFAULT_PROVIDER: AgentProvider = "claude";
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>( const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
allProviderDefinitions.map((definition) => [definition.id, definition]),
);
const fallbackDefinition = allProviderDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
function normalizeSelectedModelId(modelId: string | null | undefined): string { function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : ""; const normalized = typeof modelId === "string" ? modelId.trim() : "";
@@ -180,7 +173,7 @@ function resolveFormState(
userModified: UserModifiedFields, userModified: UserModifiedFields,
currentState: FormState, currentState: FormState,
validServerIds: Set<string>, validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap, allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
): FormState { ): FormState {
// Start with current state - we only update non-user-modified fields // Start with current state - we only update non-user-modified fields
const result = { ...currentState }; const result = { ...currentState };
@@ -376,10 +369,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
} = useProvidersSnapshot(formState.serverId); } = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(() => { const snapshotProviderDefinitions = useMemo(
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider)); () => buildProviderDefinitions(snapshotEntries),
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id)); [snapshotEntries],
}, [snapshotEntries]); );
const snapshotProviderDefinitionMap = useMemo( const snapshotProviderDefinitionMap = useMemo(
() => () =>
new Map<AgentProvider, AgentProviderDefinition>( new Map<AgentProvider, AgentProviderDefinition>(
@@ -388,17 +381,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotProviderDefinitions], [snapshotProviderDefinitions],
); );
const snapshotSelectableProviderDefinitionMap = useMemo(() => { const snapshotSelectableProviderDefinitionMap = useMemo(() => {
if (!snapshotEntries?.length) {
return snapshotProviderDefinitionMap;
}
const readyProviders = new Set( const readyProviders = new Set(
(snapshotEntries ?? []) snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
.filter((entry) => entry.status === "ready")
.map((entry) => entry.provider),
); );
return new Map<AgentProvider, AgentProviderDefinition>( return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id)) .filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]), .map((definition) => [definition.id, definition]),
); );
}, [snapshotEntries, snapshotProviderDefinitions]); }, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => { const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) { for (const entry of snapshotEntries ?? []) {
@@ -1,5 +1,4 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { import {
@@ -10,13 +9,14 @@ import {
rejectInitDeferred, rejectInitDeferred,
} from "@/utils/agent-initialization"; } from "@/utils/agent-initialization";
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy"; import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
import { isWeb } from "@/constants/platform";
const INIT_TIMEOUT_MS = 5 * 60_000; const INIT_TIMEOUT_MS = 5 * 60_000;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200; const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0; const UNBOUNDED_TIMELINE_LIMIT = 0;
function resolveInitialTimelineLimit(): number { function resolveInitialTimelineLimit(): number {
return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT; return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
} }
export const __private__ = { export const __private__ = {
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
let current = getIsAppActivelyVisible(); let current = getIsAppActivelyVisible();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -29,7 +30,7 @@ export function useAppVisible(): boolean {
useEffect(() => { useEffect(() => {
const appStateSubscription = AppState.addEventListener("change", notify); const appStateSubscription = AppState.addEventListener("change", notify);
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", notify); document.addEventListener("visibilitychange", notify);
window.addEventListener("focus", notify); window.addEventListener("focus", notify);
window.addEventListener("blur", notify); window.addEventListener("blur", notify);
@@ -37,7 +38,7 @@ export function useAppVisible(): boolean {
return () => { return () => {
appStateSubscription.remove(); appStateSubscription.remove();
if (Platform.OS === "web" && typeof document !== "undefined") { if (isWeb && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", notify); document.removeEventListener("visibilitychange", notify);
window.removeEventListener("focus", notify); window.removeEventListener("focus", notify);
window.removeEventListener("blur", notify); window.removeEventListener("blur", notify);
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
return {
path,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
fingerprint,
};
}
describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
const files: DiffFileMetadata[] = [
createMetadata("zeta.ts"),
createMetadata("alpha.ts"),
createMetadata("beta.ts"),
];
const ordered = orderCheckoutDiffFiles(files);
expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
});
it("replaces full list on snapshot — does not merge with previous", () => {
const initial: DiffFileMetadata[] = [
createMetadata("a.ts", "fp1"),
createMetadata("b.ts", "fp2"),
createMetadata("c.ts", "fp3"),
];
// Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
const updated: DiffFileMetadata[] = [
createMetadata("a.ts", "fp1"),
createMetadata("c.ts", "fp4"),
];
// Full replacement: the result is exactly the updated list, not a merge
const result = orderCheckoutDiffFiles(updated);
expect(result).toHaveLength(2);
expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
// b.ts is gone — no remnant from initial
expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
// Initial is unchanged (no mutation)
expect(initial).toHaveLength(3);
});
it("preserves fingerprint and status on metadata items", () => {
const file: DiffFileMetadata = {
path: "large.bin",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
status: "binary",
fingerprint: "abc123",
};
const ordered = orderCheckoutDiffFiles([file]);
expect(ordered[0].fingerprint).toBe("abc123");
expect(ordered[0].status).toBe("binary");
});
});
@@ -0,0 +1,214 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useMemo } from "react";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutDiffSnapshot } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
type CheckoutDiffMetadataPayload = Omit<CheckoutDiffSnapshot["payload"], "subscriptionId">;
function checkoutDiffMetadataQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return [
"checkoutDiffMetadata",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
] as const;
}
interface UseCheckoutDiffMetadataQueryOptions {
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
}
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
export function useCheckoutDiffMetadataQuery({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
enabled = true,
}: UseCheckoutDiffMetadataQueryOptions) {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef, ignoreWhitespace],
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo(
() => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const query = useQuery({
queryKey,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
const payload = await client.getCheckoutDiffMetadata(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
});
return {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
};
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
});
useEffect(() => {
if (!client || !isConnected || !cwd || !enabled) {
return;
}
if (!isOpen || explorerTab !== "changes") {
return;
}
const subscriptionId = [
"checkoutDiffMetadata",
hookInstanceId,
serverId,
cwd,
compareMode,
compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":");
let cancelled = false;
const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
if (message.type !== "checkout/diff_snapshot") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
cwd: message.payload.cwd,
files: orderCheckoutDiffFiles(message.payload.files),
error: message.payload.error,
requestId: message.payload.requestId,
});
});
void client
.subscribeCheckoutDiffLazy(
cwd,
{
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
},
{ subscriptionId },
)
.then((payload) => {
if (cancelled) {
return;
}
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
});
})
.catch((error) => {
if (cancelled) {
return;
}
console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
serverId,
cwd,
error,
});
});
return () => {
cancelled = true;
unsubscribeSnapshot();
try {
client.unsubscribeCheckoutDiffLazy(subscriptionId);
} catch {
// Ignore disconnect race during effect cleanup.
}
};
}, [
client,
isConnected,
cwd,
enabled,
isOpen,
explorerTab,
hookInstanceId,
serverId,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
queryKey,
queryClient,
]);
const refresh = useCallback(() => {
return query.refetch();
}, [query]);
const payload = query.data ?? null;
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError || Boolean(payloadError),
error: query.error,
refresh,
};
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback } from "react";
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
const HEARTBEAT_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
@@ -32,7 +33,7 @@ export function useClientActivity({
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId); const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(0); const lastImmediateHeartbeatAtRef = useRef<number>(0);
const deviceType = Platform.OS === "web" ? "web" : "mobile"; const deviceType = isWeb ? "web" : "mobile";
const recordUserActivity = useCallback(() => { const recordUserActivity = useCallback(() => {
lastActivityAtRef.current = new Date(); lastActivityAtRef.current = new Date();
@@ -96,7 +97,7 @@ export function useClientActivity({
// Track user activity on web for accurate staleness. // Track user activity on web for accurate staleness.
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const handleUserActivity = () => { const handleUserActivity = () => {
+5 -9
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout"; import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents"; import { useAggregatedAgents } from "./use-aggregated-agents";
@@ -9,6 +8,7 @@ import {
deriveMacDockBadgeCountFromWorkspaceStatuses, deriveMacDockBadgeCountFromWorkspaceStatuses,
type DesktopBadgeWorkspaceStatus, type DesktopBadgeWorkspaceStatus,
} from "@/utils/desktop-badge-state"; } from "@/utils/desktop-badge-state";
import { isNative } from "@/constants/platform";
type FaviconStatus = "none" | "running" | "attention"; type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light"; type ColorScheme = "dark" | "light";
@@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
} }
function getSystemColorScheme(): ColorScheme { function getSystemColorScheme(): ColorScheme {
if ( if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") {
Platform.OS !== "web" ||
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return "dark"; return "dark";
} }
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
async function updateMacDockBadge(count?: number) { async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return; if (isNative || !getIsElectronRuntimeMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.(); const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") { if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
@@ -119,7 +115,7 @@ export function useFaviconStatus() {
// Listen for system color scheme changes // Listen for system color scheme changes
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof window === "undefined") return; if (isNative || typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => { const handler = (e: MediaQueryListEvent) => {
@@ -132,7 +128,7 @@ export function useFaviconStatus() {
// Update favicon when agents or color scheme changes // Update favicon when agents or color scheme changes
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
const status = deriveFaviconStatus(agents); const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme); updateFavicon(status, colorScheme);
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { isWeb } from "@/constants/platform";
interface UseFileDropZoneOptions { interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void; onFilesDropped: (files: ImageAttachment[]) => void;
@@ -14,7 +14,7 @@ interface UseFileDropZoneReturn {
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = isWeb;
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = { const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png", ".png": "image/png",
".jpg": "image/jpeg", ".jpg": "image/jpeg",
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
/**
* Tests for use-file-hunks hook logic.
*
* The hook itself is a thin React Query wrapper, so we test the key properties:
* 1. Query key includes fingerprint — fingerprint change = automatic refetch
* 2. Metadata fields are passed through to the merged result
*/
function fileHunksQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef: string | undefined,
ignoreWhitespace: boolean | undefined,
path: string,
fingerprint: string,
) {
return [
"checkoutFileHunks",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
path,
fingerprint,
] as const;
}
describe("use-file-hunks query key", () => {
it("includes fingerprint in query key", () => {
const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
expect(key).toContain("fp-abc");
expect(key).toContain("foo.ts");
});
it("different fingerprints produce different query keys", () => {
const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
expect(key1).not.toEqual(key2);
// Only the fingerprint element differs
expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
});
it("same fingerprint + same params produces identical key", () => {
const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
expect(key1).toEqual(key2);
});
it("normalizes missing baseRef to empty string", () => {
const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
expect(key[4]).toBe("");
});
it("normalizes ignoreWhitespace to boolean", () => {
const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
expect(key[5]).toBe(false);
});
});
describe("use-file-hunks metadata merge", () => {
it("metadata fields carry through to merged ParsedDiffFile shape", () => {
const meta: DiffFileMetadata = {
path: "src/index.ts",
isNew: true,
isDeleted: false,
additions: 10,
deletions: 2,
status: "ok",
fingerprint: "abc",
};
// Simulates merging metadata with fetched hunks
const merged = {
path: meta.path,
isNew: meta.isNew,
isDeleted: meta.isDeleted,
additions: meta.additions,
deletions: meta.deletions,
status: meta.status,
hunks: [],
};
expect(merged.path).toBe("src/index.ts");
expect(merged.isNew).toBe(true);
expect(merged.additions).toBe(10);
expect(merged.hunks).toEqual([]);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
function fileHunksQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef: string | undefined,
ignoreWhitespace: boolean | undefined,
path: string,
fingerprint: string,
) {
return [
"checkoutFileHunks",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
path,
fingerprint,
] as const;
}
interface UseFileHunksOptions {
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
file: DiffFileMetadata;
enabled?: boolean;
}
export function useFileHunks({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file,
enabled = true,
}: UseFileHunksOptions) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const queryKey = useMemo(
() =>
fileHunksQueryKey(
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file.path,
file.fingerprint,
),
[serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
);
const query = useQuery({
queryKey,
queryFn: async (): Promise<ParsedDiffFile> => {
if (!client) {
throw new Error("Daemon client not available");
}
const payload = await client.getCheckoutFileHunks(
cwd,
{ mode, baseRef, ignoreWhitespace },
file.path,
);
if (payload.error) {
throw new Error(payload.error.message);
}
return {
path: file.path,
isNew: file.isNew,
isDeleted: file.isDeleted,
additions: file.additions,
deletions: file.deletions,
status: file.status,
hunks: payload.hunks,
};
},
enabled: !!client && isConnected && enabled,
staleTime: 60_000,
});
return {
file: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
};
}
@@ -8,6 +8,7 @@ import {
openImagePathsWithDesktopDialog, openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput, type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker"; } from "@/hooks/image-attachment-picker";
import { isWeb } from "@/constants/platform";
interface UseImageAttachmentPickerResult { interface UseImageAttachmentPickerResult {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>; pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
@@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true; isPickingRef.current = true;
try { try {
if (Platform.OS === "web" && isElectronRuntime()) { if (isWeb && isElectronRuntime()) {
const selectedPaths = await openImagePathsWithDesktopDialog(); const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) { if (selectedPaths.length === 0) {
return null; return null;
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native"; import { usePathname, useRouter } from "expo-router";
import { usePathname } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
buildHostSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseServerIdFromPathname, parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
@@ -26,6 +26,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -47,6 +48,7 @@ export function useKeyboardShortcuts({
cycleTheme?: () => void; cycleTheme?: () => void;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const hosts = useHosts(); const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
@@ -65,7 +67,7 @@ export function useKeyboardShortcuts({
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
if (Platform.OS !== "web") return; if (isNative) return;
if (isMobile) return; if (isMobile) return;
const isDesktopApp = getIsElectronRuntime(); const isDesktopApp = getIsElectronRuntime();
@@ -244,6 +246,16 @@ export function useKeyboardShortcuts({
case "sidebar.toggle.left": case "sidebar.toggle.left":
toggleAgentList(); toggleAgentList();
return true; return true;
case "settings.toggle":
if (pathname.endsWith("/settings")) {
router.back();
return true;
}
if (!activeServerId) {
return false;
}
router.push(buildHostSettingsRoute(activeServerId));
return true;
case "sidebar.toggle.both": case "sidebar.toggle.both":
if (toggleBothSidebars) { if (toggleBothSidebars) {
toggleBothSidebars(); toggleBothSidebars();
@@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import Constants from "expo-constants"; import Constants from "expo-constants";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb } from "@/constants/platform";
const STORAGE_PREFIX = "@paseo:expo-push-token:"; const STORAGE_PREFIX = "@paseo:expo-push-token:";
@@ -31,7 +32,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
const lastSentTokenRef = useRef<string | null>(null); const lastSentTokenRef = useRef<string | null>(null);
const registerIfPossible = useCallback(async () => { const registerIfPossible = useCallback(async () => {
if (Platform.OS === "web") return; if (isWeb) return;
if (!client.isConnected) return; if (!client.isConnected) return;
const token = tokenRef.current; const token = tokenRef.current;
if (!token) return; if (!token) return;
@@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
}, [client]); }, [client]);
useEffect(() => { useEffect(() => {
if (Platform.OS === "web") return; if (isWeb) return;
const storageKey = `${STORAGE_PREFIX}${serverId}`; const storageKey = `${STORAGE_PREFIX}${serverId}`;
let cancelled = false; let cancelled = false;
+1
View File
@@ -37,6 +37,7 @@ export type KeyboardActionId =
| "sidebar.toggle.left" | "sidebar.toggle.left"
| "sidebar.toggle.right" | "sidebar.toggle.right"
| "sidebar.toggle.both" | "sidebar.toggle.both"
| "settings.toggle"
| "command-center.toggle" | "command-center.toggle"
| "shortcuts.dialog.toggle" | "shortcuts.dialog.toggle"
| "workspace.terminal.new" | "workspace.terminal.new"
@@ -712,6 +712,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
}, },
}, },
// --- Settings toggle ---
{
id: "settings-toggle-cmd-comma-mac",
action: "settings.toggle",
combo: "Cmd+,",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-settings",
section: "panels",
label: "Toggle settings",
keys: ["mod", ","],
},
},
{
id: "settings-toggle-ctrl-comma-non-mac",
action: "settings.toggle",
combo: "Ctrl+,",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-settings",
section: "panels",
label: "Toggle settings",
keys: ["mod", ","],
},
},
// --- Focus mode --- // --- Focus mode ---
{ {
id: "view-toggle-focus-cmd-shift-f-mac", id: "view-toggle-focus-cmd-shift-f-mac",
@@ -31,6 +31,7 @@ KEY_MAP["Digit"] = { code: "Digit" };
KEY_MAP["\\"] = { code: "Backslash" }; KEY_MAP["\\"] = { code: "Backslash" };
KEY_MAP["["] = { code: "BracketLeft", key: "[" }; KEY_MAP["["] = { code: "BracketLeft", key: "[" };
KEY_MAP["]"] = { code: "BracketRight", key: "]" }; KEY_MAP["]"] = { code: "BracketRight", key: "]" };
KEY_MAP[","] = { code: "Comma", key: "," };
KEY_MAP["."] = { code: "Period", key: "." }; KEY_MAP["."] = { code: "Period", key: "." };
KEY_MAP["`"] = { code: "Backquote", key: "`" }; KEY_MAP["`"] = { code: "Backquote", key: "`" };
KEY_MAP["/"] = { code: "Slash" }; KEY_MAP["/"] = { code: "Slash" };
+3 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -47,6 +47,7 @@ import {
deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest, deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor"; } from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { isNative } from "@/constants/platform";
function formatProviderLabel(provider: Agent["provider"]): string { function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) { if (!provider) {
@@ -595,7 +596,7 @@ function AgentPanelBody({
if (!isConnected || !hasSession) { if (!isConnected || !hasSession) {
return; return;
} }
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
if (!shouldSyncOnEntry) { if (!shouldSyncOnEntry) {
return; return;
} }
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native"; import { View, Text, Pressable, ScrollView, Keyboard } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -49,7 +49,6 @@ import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentSessionConfig, AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -57,6 +56,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -67,10 +67,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsReasoningStream: false, supportsReasoningStream: false,
supportsToolInvocations: false, supportsToolInvocations: false,
}; };
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
function getParamValue(value: string | string[] | undefined) { function getParamValue(value: string | string[] | undefined) {
if (typeof value === "string") { if (typeof value === "string") {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -91,16 +87,14 @@ function getValidProvider(value: string | undefined) {
if (!value) { if (!value) {
return undefined; return undefined;
} }
return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined; return value as AgentProvider;
} }
function getValidMode(provider: AgentProvider | undefined, value: string | undefined) { function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
if (!provider || !value) { if (!provider || !value) {
return undefined; return undefined;
} }
const definition = PROVIDER_DEFINITION_MAP.get(provider); return value;
const modes = definition?.modes ?? [];
return modes.some((mode) => mode.id === value) ? value : undefined;
} }
type DraftAgentParams = { type DraftAgentParams = {
@@ -850,7 +844,7 @@ function DraftAgentScreenContent({
}, },
onBeforeSubmit: () => { onBeforeSubmit: () => {
void persistFormPreferences(); void persistFormPreferences();
if (Platform.OS === "web") { if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.(); (document.activeElement as HTMLElement | null)?.blur?.();
} }
Keyboard.dismiss(); Keyboard.dismiss();
+9 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject, ComponentType } from "react"; import type { MutableRefObject, ComponentType } from "react";
import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native"; import { View, Text, ScrollView, Alert, Pressable } from "react-native";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -68,10 +68,11 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { isWeb } from "@/constants/platform";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section definitions // Section definitions
@@ -522,6 +523,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
const isConnected = useHostRuntimeIsConnected(routeServerId); const isConnected = useHostRuntimeIsConnected(routeServerId);
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null); const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
const providerDefinitions = buildProviderDefinitions(entries);
const hasServer = routeServerId.length > 0; const hasServer = routeServerId.length > 0;
@@ -557,7 +559,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
</View> </View>
) : ( ) : (
<View style={[settingsStyles.card, styles.audioCard]}> <View style={[settingsStyles.card, styles.audioCard]}>
{AGENT_PROVIDER_DEFINITIONS.map((def) => { {providerDefinitions.map((def) => {
const entry = entries?.find((e) => e.provider === def.id); const entry = entries?.find((e) => e.provider === def.id);
const status = entry?.status ?? "unavailable"; const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id); const ProviderIcon = getProviderIcon(def.id);
@@ -1672,22 +1674,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
<View style={styles.hostHeaderRight}> <View style={styles.hostHeaderRight}>
<View <View
style={[ style={[
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile, isWeb ? styles.statusPill : styles.statusPillMobile,
{ backgroundColor: statusPillBg }, { backgroundColor: statusPillBg },
]} ]}
> >
<View style={[styles.statusDot, { backgroundColor: statusColor }]} /> <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
{Platform.OS === "web" ? ( {isWeb ? (
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text> <Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
) : null} ) : null}
</View> </View>
{connectionBadge ? ( {connectionBadge ? (
<View <View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
>
{connectionBadge.icon} {connectionBadge.icon}
{Platform.OS === "web" ? ( {isWeb ? (
<Text style={styles.connectionText} numberOfLines={1}> <Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text} {connectionBadge.text}
</Text> </Text>
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { View, Text, Platform } from "react-native"; import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
@@ -20,6 +20,7 @@ import {
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
function ShortcutSequence({ function ShortcutSequence({
chord, chord,
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
} }
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (isNative) return;
if (capturingBindingId === null) return; if (capturingBindingId === null) return;
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
}; };
}, [setCapturingShortcut]); }, [setCapturingShortcut]);
if (Platform.OS !== "web") { if (isNative) {
return ( return (
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text> <Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
type StartupSplashScreenProps = { type StartupSplashScreenProps = {
bootstrapState?: { bootstrapState?: {
@@ -42,7 +43,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
errorScrollView: { errorScrollView: {
flex: 1, flex: 1,
...(Platform.OS === "web" ...(isWeb
? { ? {
overflowX: "auto", overflowX: "auto",
overflowY: "auto", overflowY: "auto",
@@ -144,7 +145,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
lineHeight: 18, lineHeight: 18,
...(Platform.OS === "web" ...(isWeb
? { ? {
whiteSpace: "pre", whiteSpace: "pre",
overflowWrap: "normal", overflowWrap: "normal",
@@ -9,7 +9,6 @@ import {
} from "react"; } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
@@ -30,6 +29,7 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list"; import { SortableInlineList } from "@/components/sortable-inline-list";
import { isNative, isWeb } from "@/constants/platform";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -113,6 +113,7 @@ function useMiddleClickClose(onClose: () => void) {
const ref = useRef<View>(null); const ref = useRef<View>(null);
useEffect(() => { useEffect(() => {
if (isNative) return;
const node = ref.current as unknown as HTMLElement | null; const node = ref.current as unknown as HTMLElement | null;
if (!node) return; if (!node) return;
@@ -174,17 +175,16 @@ function TabChip({
); );
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered; const isHighlighted = isActive || hovered || isCloseHovered;
const closeButtonDragBlockers = const closeButtonDragBlockers = isWeb
Platform.OS === "web" ? ({
? ({ onPointerDown: (event: { stopPropagation?: () => void }) => {
onPointerDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.();
event.stopPropagation?.(); },
}, onMouseDown: (event: { stopPropagation?: () => void }) => {
onMouseDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.();
event.stopPropagation?.(); },
}, } as const)
} as const) : undefined;
: undefined;
return ( return (
<View ref={middleClickRef}> <View ref={middleClickRef}>
@@ -199,7 +199,7 @@ function TabChip({
enabledOnMobile={false} enabledOnMobile={false}
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.tab, styles.tab,
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const), isWeb && isDragging && ({ cursor: "grabbing" } as const),
{ {
minWidth: resolvedTabWidth, minWidth: resolvedTabWidth,
width: resolvedTabWidth, width: resolvedTabWidth,
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, Platform, ScrollView, Text, View } from "react-native"; import { Keyboard, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { AgentInputArea } from "@/components/agent-input-area"; import { AgentInputArea } from "@/components/agent-input-area";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
@@ -19,6 +19,7 @@ import type {
AgentSessionConfig, AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { AgentSnapshotPayload } from "@server/shared/messages";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -155,7 +156,7 @@ export function WorkspaceDraftAgentTab({
}, },
onBeforeSubmit: () => { onBeforeSubmit: () => {
void persistFormPreferences(); void persistFormPreferences();
if (Platform.OS === "web") { if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.(); (document.activeElement as HTMLElement | null)?.blur?.();
} }
Keyboard.dismiss(); Keyboard.dismiss();
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native"; import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native"; import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -15,6 +15,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
interface WorkspaceOpenInEditorButtonProps { interface WorkspaceOpenInEditorButtonProps {
serverId: string; serverId: string;
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors = const shouldLoadEditors =
Platform.OS === "web" && isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({ const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId], queryKey: ["available-editors", serverId],
@@ -1,15 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
ActivityIndicator,
BackHandler,
Keyboard,
Platform,
Pressable,
Text,
View,
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { import {
@@ -118,6 +110,7 @@ import {
} from "@/screens/workspace/workspace-bulk-close"; } from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation"; import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -252,7 +245,7 @@ function WorkspaceDocumentTitleEffect({
titleState: "ready" | "loading"; titleState: "ready" | "loading";
}) { }) {
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined") { if (isNative || typeof document === "undefined") {
return; return;
} }
const resolvedLabel = label.trim(); const resolvedLabel = label.trim();
@@ -810,7 +803,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}); });
useEffect(() => { useEffect(() => {
if (Platform.OS === "web" || !isExplorerOpen) { if (isWeb || !isExplorerOpen) {
return; return;
} }
@@ -1291,7 +1284,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}); });
} }
void archiveAgent({ serverId: normalizedServerId, agentId }); // Errors (e.g. timeout) are handled by the mutation's onSettled callback
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
}); });
}, },
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey], [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
@@ -1708,7 +1702,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits; const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) { if (isNative || typeof document === "undefined" || activeTabDescriptor) {
return; return;
} }
document.title = "Workspace"; document.title = "Workspace";
@@ -1932,7 +1926,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return ( return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}> <View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? ( {isWeb && activeTabDescriptor ? (
<WorkspaceTabPresentationResolver <WorkspaceTabPresentationResolver
tab={activeTabDescriptor} tab={activeTabDescriptor}
serverId={normalizedServerId} serverId={normalizedServerId}
+4 -4
View File
@@ -1,11 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { Platform } from "react-native";
import { File as FSFile, Paths } from "expo-file-system"; import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy"; import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb } from "@/constants/platform";
interface DownloadProgress { interface DownloadProgress {
percent: number; percent: number;
@@ -97,10 +97,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
const downloadUrl = buildDownloadUrl( const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl, downloadTarget.baseUrl,
tokenResponse.token, tokenResponse.token,
Platform.OS === "web" ? downloadTarget.authCredentials : null, isWeb ? downloadTarget.authCredentials : null,
); );
if (Platform.OS === "web") { if (isWeb) {
triggerBrowserDownload(downloadUrl, resolvedFileName); triggerBrowserDownload(downloadUrl, resolvedFileName);
get().completeDownload(id); get().completeDownload(id);
return; return;
@@ -153,7 +153,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to download file."; const message = error instanceof Error ? error.message : "Failed to download file.";
if (Platform.OS === "web") { if (isWeb) {
console.warn("[DownloadStore] Download failed:", message); console.warn("[DownloadStore] Download failed:", message);
get().failDownload(id, message); get().failDownload(id, message);
return; return;
+4 -8
View File
@@ -1,7 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import { import {
buildExplorerCheckoutKey, buildExplorerCheckoutKey,
coerceExplorerTabForCheckout, coerceExplorerTabForCheckout,
@@ -9,6 +8,7 @@ import {
resolveExplorerTabForCheckout, resolveExplorerTabForCheckout,
type ExplorerTab, type ExplorerTab,
} from "./explorer-tab-memory"; } from "./explorer-tab-memory";
import { isWeb } from "@/constants/platform";
export type { ExplorerTab } from "./explorer-tab-memory"; export type { ExplorerTab } from "./explorer-tab-memory";
/** /**
@@ -128,7 +128,7 @@ function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab |
}); });
} }
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web"; const DEFAULT_DESKTOP_OPEN = isWeb;
export const usePanelStore = create<PanelState>()( export const usePanelStore = create<PanelState>()(
persist( persist(
@@ -318,11 +318,7 @@ export const usePanelStore = create<PanelState>()(
const state = persistedState as Partial<PanelState> & Record<string, unknown>; const state = persistedState as Partial<PanelState> & Record<string, unknown>;
if (version < 2) { if (version < 2) {
if ( if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
Platform.OS === "web" &&
typeof state.explorerWidth === "number" &&
state.explorerWidth === 400
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
} }
@@ -337,7 +333,7 @@ export const usePanelStore = create<PanelState>()(
if (version < 3) { if (version < 3) {
if ( if (
Platform.OS === "web" && isWeb &&
typeof state.explorerWidth === "number" && typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520) (state.explorerWidth === 400 || state.explorerWidth === 520)
) { ) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Platform } from "react-native";
import type { Theme } from "./theme"; import type { Theme } from "./theme";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { isWeb } from "@/constants/platform";
const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {}; const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
/** /**
* Creates comprehensive markdown styles for react-native-markdown-display. * Creates comprehensive markdown styles for react-native-markdown-display.
@@ -317,7 +317,9 @@ export class TerminalEmulatorRuntime {
!shouldInterceptDomTerminalKey({ !shouldInterceptDomTerminalKey({
key: normalizedKey, key: normalizedKey,
ctrlKey: event.ctrlKey, ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey, altKey: event.altKey,
metaKey: event.metaKey,
pendingModifiers: this.pendingModifiers, pendingModifiers: this.pendingModifiers,
}) })
) { ) {
+3 -2
View File
@@ -1,11 +1,12 @@
import { AppState, Platform } from "react-native"; import { AppState } from "react-native";
import { isNative } from "@/constants/platform";
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean { export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
if (appState !== "active") { if (appState !== "active") {
return false; return false;
} }
if (Platform.OS !== "web") { if (isNative) {
return true; return true;
} }
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
clearAssistantImageMetadataCache,
estimateAssistantMessageHeightFromCache,
extractAssistantImageSources,
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "./assistant-image-metadata";
describe("assistant image metadata", () => {
beforeEach(() => {
clearAssistantImageMetadataCache();
});
it("extracts markdown image sources", () => {
expect(
extractAssistantImageSources(
'Before\n\n![local](/tmp/paseo.png)\n\n![remote](https://example.com/test.png "Remote")',
),
).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]);
});
it("reuses cached metadata across canonical and raw source keys", () => {
setAssistantImageMetadata(
{
source: "/tmp/paseo-codex-screenshot.png",
workspaceRoot: "/Users/moboudra/dev/paseo",
serverId: "server-1",
},
{ width: 1200, height: 800 },
);
expect(
getAssistantImageMetadata({
source: "/tmp/paseo-codex-screenshot.png",
}),
).toEqual({
width: 1200,
height: 800,
aspectRatio: 1.5,
});
});
it("estimates assistant message height from cached image metadata", () => {
setAssistantImageMetadata(
{
source: "https://example.com/landscape.png",
},
{ width: 1200, height: 800 },
);
expect(
estimateAssistantMessageHeightFromCache(
"Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)",
),
).toBeGreaterThan(220);
});
});
@@ -0,0 +1,213 @@
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
export interface AssistantImageMetadata {
width: number;
height: number;
aspectRatio: number;
}
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8;
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
const ASSISTANT_IMAGE_BLOCK_GAP = 24;
const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
const ASSISTANT_MESSAGE_MIN_HEIGHT = 220;
const ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT = 40;
function touchCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void {
cache.delete(key);
cache.set(key, value);
if (cache.size <= limit) {
return;
}
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) {
cache.delete(oldestKey);
}
}
function normalizeAssistantImageSourceToken(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
const inner = trimmed.slice(1, -1).trim();
return inner || null;
}
const titleMatch = /^(.*?)(?:\s+(['"]).*?\2)?$/.exec(trimmed);
const source = titleMatch?.[1]?.trim() ?? trimmed;
return source || null;
}
function createSourceAliasKey(source: string): string {
return `source:${source}`;
}
function createResolutionKey(input: {
source: string;
workspaceRoot?: string;
serverId?: string;
}): string | null {
const resolution = resolveAssistantImageSource({
source: input.source,
workspaceRoot: input.workspaceRoot,
});
if (!resolution) {
return null;
}
if (resolution.kind === "direct") {
return `direct:${resolution.uri}`;
}
return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`;
}
function getAssistantImageMetadataKeys(input: {
source: string;
workspaceRoot?: string;
serverId?: string;
}): string[] {
const source = input.source.trim();
if (!source) {
return [];
}
const keys = [createSourceAliasKey(source)];
const resolutionKey = createResolutionKey(input);
if (resolutionKey) {
keys.unshift(resolutionKey);
}
return [...new Set(keys)];
}
export function getAssistantImageMetadata(input: {
source: string;
workspaceRoot?: string;
serverId?: string;
}): AssistantImageMetadata | null {
for (const key of getAssistantImageMetadataKeys(input)) {
const metadata = assistantImageMetadataCache.get(key);
if (metadata) {
touchCacheEntry(
assistantImageMetadataCache,
key,
metadata,
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
);
return metadata;
}
}
return null;
}
export function setAssistantImageMetadata(
input: {
source: string;
workspaceRoot?: string;
serverId?: string;
},
dimensions: { width: number; height: number },
): AssistantImageMetadata | null {
const { width, height } = dimensions;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
const metadata: AssistantImageMetadata = {
width,
height,
aspectRatio: width / height,
};
for (const key of getAssistantImageMetadataKeys(input)) {
touchCacheEntry(
assistantImageMetadataCache,
key,
metadata,
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
);
}
return metadata;
}
export function extractAssistantImageSources(markdown: string): string[] {
const cachedParse = assistantImageParseCache.get(markdown);
if (cachedParse) {
touchCacheEntry(
assistantImageParseCache,
markdown,
cachedParse,
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
);
return cachedParse.sources;
}
const sources: string[] = [];
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
if (normalized) {
sources.push(normalized);
}
}
const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0;
touchCacheEntry(
assistantImageParseCache,
markdown,
{ sources, hasNonImageText },
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
);
return sources;
}
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
const cachedParse = assistantImageParseCache.get(markdown);
const parsed =
cachedParse ??
(() => {
const sources = extractAssistantImageSources(markdown);
const nextParsed = assistantImageParseCache.get(markdown);
return nextParsed ?? { sources, hasNonImageText: true };
})();
if (parsed.sources.length === 0) {
return null;
}
const knownHeights = parsed.sources
.map((source) => getAssistantImageMetadata({ source }))
.filter((metadata): metadata is AssistantImageMetadata => metadata !== null)
.map((metadata) =>
Math.max(
ASSISTANT_IMAGE_MIN_HEIGHT,
Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio),
),
);
if (knownHeights.length === 0) {
return null;
}
const baseHeight = parsed.hasNonImageText
? ASSISTANT_MESSAGE_BASE_HEIGHT
: ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT;
const estimatedHeight =
baseHeight +
knownHeights.reduce((sum, height) => sum + height, 0) +
ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length;
return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight);
}
export function clearAssistantImageMetadataCache(): void {
assistantImageMetadataCache.clear();
assistantImageParseCache.clear();
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantImageSource } from "./assistant-image-source";
describe("resolveAssistantImageSource", () => {
it("passes through direct image URIs", () => {
expect(resolveAssistantImageSource({ source: "https://example.com/image.png" })).toEqual({
kind: "direct",
uri: "https://example.com/image.png",
});
expect(resolveAssistantImageSource({ source: "data:image/png;base64,abc" })).toEqual({
kind: "direct",
uri: "data:image/png;base64,abc",
});
});
it("uses the workspace root for relative paths", () => {
expect(
resolveAssistantImageSource({
source: "screenshots/output.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/Users/test/project",
path: "screenshots/output.png",
});
});
it("uses the workspace root for absolute paths inside the workspace", () => {
expect(
resolveAssistantImageSource({
source: "/Users/test/project/screenshots/output.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/Users/test/project",
path: "/Users/test/project/screenshots/output.png",
});
});
it("falls back to filesystem root for absolute paths outside the workspace", () => {
expect(
resolveAssistantImageSource({
source: "/tmp/paseo-codex-screenshot.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/",
path: "/tmp/paseo-codex-screenshot.png",
});
});
it("normalizes file URIs into file RPC requests", () => {
expect(
resolveAssistantImageSource({
source: "file:///tmp/paseo-codex-screenshot.png",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "file_rpc",
cwd: "/",
path: "/tmp/paseo-codex-screenshot.png",
});
});
it("falls back to the drive root for Windows absolute paths", () => {
expect(
resolveAssistantImageSource({
source: "C:/Users/test/Desktop/screenshot.png",
workspaceRoot: "D:/repo",
}),
).toEqual({
kind: "file_rpc",
cwd: "C:/",
path: "C:/Users/test/Desktop/screenshot.png",
});
});
});
@@ -0,0 +1,109 @@
import { fileUriToPath } from "@/attachments/utils";
import { isAbsolutePath } from "@/utils/path";
export type AssistantImageSourceResolution =
| { kind: "direct"; uri: string }
| { kind: "file_rpc"; cwd: string; path: string };
function trimTrailingSeparators(value: string): string {
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
return value.replace(/\\/g, "/");
}
return value.replace(/[\\/]+$/, "");
}
function normalizeForPathComparison(value: string): string {
const normalized = trimTrailingSeparators(value.replace(/\\/g, "/"));
if (/^[A-Za-z]:\//.test(normalized)) {
return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`;
}
return normalized;
}
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
const candidate = normalizeForPathComparison(candidatePath);
const root = normalizeForPathComparison(rootPath);
if (!candidate || !root) {
return false;
}
if (root === "/") {
return candidate.startsWith("/");
}
if (candidate === root) {
return true;
}
return candidate.startsWith(`${root}/`);
}
function deriveFallbackRootFromAbsolutePath(value: string): string | null {
if (value.startsWith("/")) {
return "/";
}
const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value);
if (driveMatch?.[1]) {
return `${driveMatch[1]}/`;
}
const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value);
if (uncMatch?.[1]) {
return uncMatch[1];
}
return null;
}
export function resolveAssistantImageSource(input: {
source: string;
workspaceRoot?: string;
}): AssistantImageSourceResolution | null {
const source = input.source.trim();
if (!source) {
return null;
}
if (/^(https?:|data:|blob:)/i.test(source)) {
return { kind: "direct", uri: source };
}
const sourcePath = source.startsWith("file://") ? fileUriToPath(source) : source;
if (!sourcePath) {
return null;
}
if (!isAbsolutePath(sourcePath)) {
const workspaceRoot = input.workspaceRoot?.trim();
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
return null;
}
return {
kind: "file_rpc",
cwd: workspaceRoot,
path: sourcePath,
};
}
const workspaceRoot = input.workspaceRoot?.trim();
if (
workspaceRoot &&
isAbsolutePath(workspaceRoot) &&
isPathWithinRoot(sourcePath, workspaceRoot)
) {
return {
kind: "file_rpc",
cwd: workspaceRoot,
path: sourcePath,
};
}
const fallbackRoot = deriveFallbackRootFromAbsolutePath(sourcePath);
if (!fallbackRoot) {
return null;
}
return {
kind: "file_rpc",
cwd: fallbackRoot,
path: sourcePath,
};
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { Alert, Platform } from "react-native"; import { Alert } from "react-native";
import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host"; import { getDesktopHost, type DesktopDialogAskOptions } from "@/desktop/host";
import { isNative } from "@/constants/platform";
export interface ConfirmDialogInput { export interface ConfirmDialogInput {
title: string; title: string;
@@ -49,7 +50,7 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
} }
function getDesktopApi() { function getDesktopApi() {
if (Platform.OS !== "web") { if (isNative) {
return null; return null;
} }
return getDesktopHost(); return getDesktopHost();
@@ -67,7 +68,7 @@ function buildDesktopAskOptions(input: ConfirmDialogInput): DesktopDialogAskOpti
} }
function blurActiveWebElement(): void { function blurActiveWebElement(): void {
if (Platform.OS !== "web") { if (isNative) {
return; return;
} }
const activeElement = (globalThis as { document?: Document }).document?.activeElement; const activeElement = (globalThis as { document?: Document }).document?.activeElement;
@@ -103,7 +104,7 @@ function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
} }
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> { export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
if (Platform.OS !== "web") { if (isNative) {
return showNativeConfirmDialog(input); return showNativeConfirmDialog(input);
} }
+2 -2
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { import {
getIsElectronRuntimeMac, getIsElectronRuntimeMac,
getIsElectronRuntime, getIsElectronRuntime,
@@ -10,6 +9,7 @@ import {
} from "@/constants/layout"; } from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window"; import { getDesktopWindow } from "@/desktop/electron/window";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { isNative } from "@/constants/platform";
type RawWindowControlsPadding = { type RawWindowControlsPadding = {
left: number; left: number;
@@ -23,7 +23,7 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web" || !getIsElectronRuntime()) return; if (isNative || !getIsElectronRuntime()) return;
let disposed = false; let disposed = false;
let cleanup: (() => void) | undefined; let cleanup: (() => void) | undefined;
+2 -2
View File
@@ -1,9 +1,9 @@
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { isWeb } from "@/constants/platform";
export async function openExternalUrl(url: string): Promise<void> { export async function openExternalUrl(url: string): Promise<void> {
if (Platform.OS === "web") { if (isWeb) {
const opener = getDesktopHost()?.opener?.openUrl; const opener = getDesktopHost()?.opener?.openUrl;
if (typeof opener === "function") { if (typeof opener === "function") {
await opener(url); await opener(url);
+3 -3
View File
@@ -1,7 +1,7 @@
import { Asset } from "expo-asset"; import { Asset } from "expo-asset";
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing"; import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
import { isNative } from "@/constants/platform";
type OsNotificationPayload = { type OsNotificationPayload = {
title: string; title: string;
@@ -80,7 +80,7 @@ async function ensureNotificationPermission(): Promise<boolean> {
} }
export async function ensureOsNotificationPermission(): Promise<boolean> { export async function ensureOsNotificationPermission(): Promise<boolean> {
if (Platform.OS !== "web") { if (isNative) {
return false; return false;
} }
return await ensureNotificationPermission(); return await ensureNotificationPermission();
@@ -154,7 +154,7 @@ function attachWebClickHandler(
export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> { export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> {
// Mobile/native notifications should be remote push only. // Mobile/native notifications should be remote push only.
if (Platform.OS !== "web") { if (isNative) {
return false; return false;
} }
@@ -0,0 +1,47 @@
import type { ProviderSnapshotEntry, AgentProvider } from "@server/server/agent/agent-sdk-types";
import {
type AgentModeColorTier,
type AgentModeIcon,
type AgentProviderDefinition,
type AgentProviderModeDefinition,
} from "@server/server/agent/provider-manifest";
function buildProviderModes(entry: ProviderSnapshotEntry): AgentProviderModeDefinition[] {
const entryModes = entry.modes ?? [];
return entryModes.map((mode) => ({
...mode,
icon: (mode.icon ?? "ShieldCheck") as AgentModeIcon,
colorTier: (mode.colorTier ?? "moderate") as AgentModeColorTier,
}));
}
export function buildProviderDefinitions(
snapshotEntries: ProviderSnapshotEntry[] | undefined,
): AgentProviderDefinition[] {
if (!snapshotEntries?.length) {
return [];
}
return snapshotEntries.map((entry) => ({
id: entry.provider,
label: entry.label ?? entry.provider,
description: entry.description ?? "",
defaultModeId: entry.defaultModeId ?? null,
modes: buildProviderModes(entry),
}));
}
export function resolveProviderLabel(
provider: string,
snapshotEntries: ProviderSnapshotEntry[] | undefined,
): string {
return snapshotEntries?.find((entry) => entry.provider === provider)?.label ?? provider;
}
export function resolveProviderDefinition(
provider: string,
snapshotEntries: ProviderSnapshotEntry[] | undefined,
): AgentProviderDefinition | undefined {
return buildProviderDefinitions(snapshotEntries).find((definition) => definition.id === provider);
}
@@ -1,4 +1,4 @@
import { Platform } from "react-native"; import { isWeb } from "@/constants/platform";
type ListenerStats = { type ListenerStats = {
adds: number; adds: number;
@@ -92,7 +92,7 @@ const SOURCE_LABEL = "[ScrollJankInvestigation]";
function shouldInstall(): boolean { function shouldInstall(): boolean {
const runtime = globalThis as ScrollInvestigationGlobal; const runtime = globalThis as ScrollInvestigationGlobal;
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
return Platform.OS === "web" && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__; return isWeb && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
} }
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean { function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
+2 -1
View File
@@ -1,9 +1,10 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsElectronRuntimeMac } from "@/constants/layout"; import { getIsElectronRuntimeMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut"; import type { ShortcutOs } from "@/utils/format-shortcut";
import { isNative } from "@/constants/platform";
export function getShortcutOs(): ShortcutOs { export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") { if (isNative) {
return Platform.OS === "ios" ? "mac" : "non-mac"; return Platform.OS === "ios" ? "mac" : "non-mac";
} }
if (getIsElectronRuntimeMac()) return "mac"; if (getIsElectronRuntimeMac()) return "mac";
@@ -57,7 +57,9 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({ shouldInterceptDomTerminalKey({
key: "Escape", key: "Escape",
ctrlKey: false, ctrlKey: false,
shiftKey: false,
altKey: false, altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false }, pendingModifiers: { ctrl: false, shift: false, alt: false },
}), }),
).toBe(false); ).toBe(false);
@@ -65,7 +67,9 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({ shouldInterceptDomTerminalKey({
key: "c", key: "c",
ctrlKey: true, ctrlKey: true,
shiftKey: false,
altKey: false, altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false }, pendingModifiers: { ctrl: false, shift: false, alt: false },
}), }),
).toBe(false); ).toBe(false);
@@ -73,7 +77,9 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({ shouldInterceptDomTerminalKey({
key: "c", key: "c",
ctrlKey: false, ctrlKey: false,
shiftKey: false,
altKey: false, altKey: false,
metaKey: false,
pendingModifiers: { ctrl: true, shift: false, alt: false }, pendingModifiers: { ctrl: true, shift: false, alt: false },
}), }),
).toBe(true); ).toBe(true);
@@ -81,12 +87,73 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({ shouldInterceptDomTerminalKey({
key: "Escape", key: "Escape",
ctrlKey: false, ctrlKey: false,
shiftKey: false,
altKey: false, altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: true }, pendingModifiers: { ctrl: false, shift: false, alt: true },
}), }),
).toBe(true); ).toBe(true);
}); });
it("intercepts Enter with DOM shift modifier for CSI u encoding", () => {
expect(
shouldInterceptDomTerminalKey({
key: "Enter",
ctrlKey: false,
shiftKey: true,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(true);
});
it("intercepts Enter with any DOM modifier for CSI u encoding", () => {
expect(
shouldInterceptDomTerminalKey({
key: "Enter",
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(true);
expect(
shouldInterceptDomTerminalKey({
key: "Enter",
ctrlKey: false,
shiftKey: false,
altKey: true,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(true);
expect(
shouldInterceptDomTerminalKey({
key: "Enter",
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: true,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(true);
});
it("does not intercept plain Enter without modifiers", () => {
expect(
shouldInterceptDomTerminalKey({
key: "Enter",
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(false);
});
it("detects pending modifier state", () => { it("detects pending modifier state", () => {
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false); expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true); expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
+12 -1
View File
@@ -85,10 +85,21 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
export function shouldInterceptDomTerminalKey(args: { export function shouldInterceptDomTerminalKey(args: {
key: string; key: string;
ctrlKey: boolean; ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean; altKey: boolean;
metaKey: boolean;
pendingModifiers: PendingTerminalModifiers; pendingModifiers: PendingTerminalModifiers;
}): boolean { }): boolean {
return hasPendingTerminalModifiers(args.pendingModifiers); if (hasPendingTerminalModifiers(args.pendingModifiers)) {
return true;
}
// xterm.js sends plain \r for Enter regardless of modifiers.
// Intercept modified Enter so it gets CSI u encoding (Kitty keyboard protocol),
// which Claude Code and other TUI apps use for Shift+Enter newlines.
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
return true;
}
return false;
} }
export function mergeTerminalModifiers(args: { export function mergeTerminalModifiers(args: {

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