Compare commits

...

96 Commits

Author SHA1 Message Date
Mohamed Boudra
41fd45588f chore(release): cut 0.1.57 2026-04-16 16:16:30 +07:00
Mohamed Boudra
54b8126deb docs: add 0.1.57 changelog 2026-04-16 16:15:38 +07:00
Mohamed Boudra
7c50b7a080 fix(server): harden Codex/Windows startup and provider resolution (#454)
* fix(server): harden Codex/Windows startup and provider resolution

Addresses #452, #443, #353, #418, #403, #221, #307, and locks in #284.

- Replace custom where.exe/which output parsing with npm `which@^5` plus a
  spawn probe. findExecutable now enumerates all PATH+PATHEXT candidates
  and returns the first invokable one. A WindowsApps-ACL'd codex.exe no
  longer wins over a working codex.cmd (#452).
- Make default provider isAvailable() check the binary instead of always
  returning true. Codex/Claude/OpenCode default isAvailable() now defer
  to isCommandAvailable() so missing CLIs surface as unavailable instead
  of throwing later from spawn (#221, #443).
- Gate AgentManager.resumeAgentFromPersistence on isAvailable() so a
  persisted agent record with a missing binary cannot reach provider
  spawn during rehydration. The daemon stays up and the agent reports
  unavailable (#443, #353, #418, #403).
- Drop --path-format=absolute from rev-parse callers and validate stdout
  through a shared parser that rejects multi-line output and unknown-flag
  echoes. --show-toplevel is absolute by default in modern Git;
  --git-common-dir is resolved against the command cwd. Fixes workspace
  registration on pre-2.31 Git that echoed the unknown flag and produced
  a two-line "path" (#307).

Tests:
- Real-FS executable.test.ts using temp PATH fixtures, covering the .cmd
  fallback after .exe pre-spawn failure (Windows-only) plus the
  null-on-no-invokable-candidate case.
- provider-availability.test.ts builds real provider clients against a
  temp-dir-only PATH for Codex/Claude/OpenCode.
- bootstrap-provider-availability.test.ts builds the daemon and triggers
  ensureAgentLoaded so it actually exercises resumeAgentFromPersistence.
- claude-agent.spawn.test.ts asserts shell: false reaches spawnProcess
  from the Claude SDK spawn override, locking in 39b56af4 for #284.
- checkout-git-rev-parse.test.ts covers nested-checkout resolution and
  the old-Git multi-line stdout case via a tightly-scoped runGitCommand
  fake.

CI:
- server-tests-windows now also runs the new and modified test files so
  the Windows behaviors are exercised on windows-latest.

* fix: update lockfile signatures and Nix hash

* fix(server): handle synchronous spawn UNKNOWN on Windows

On Windows, child_process.spawn() throws synchronously when invoked on
a corrupt or invalid .exe (e.g., a WindowsApps stub the current user
cannot execute, or a zero-byte file). The executable probe did not
guard the spawn call, so the synchronous throw rejected the probe
promise instead of resolving false, preventing findExecutable() from
trying the next candidate. This is the root cause of the daemon-crash
pattern in #452: codex.exe from WindowsApps would hard-fail before the
.cmd shim ever got a chance.

Wrap spawn() in try/catch and settle false on sync throw. The existing
error-event and exit-event handlers already cover async failure modes;
sync throw just needed one more guard.

Also adjust three Windows-only test comparisons that were asserting
platform-dependent string equality:
- executable.test: compare .cmd paths case-insensitively (which@5
  preserves PATHEXT casing, which is uppercase in production).
- workspace-registry-model.test: expect normalizeWorkspaceId(path),
  not the hardcoded POSIX form.
- checkout-git-rev-parse.test: normalize separators/case when
  comparing git's Windows forward-slash output against realpathSync.

* test(server): canonicalize repo root via git on Windows to fix short-name mismatch

realpathSync on Windows preserves 8.3 short names (e.g. RUNNER~1) while git's
rev-parse --show-toplevel always returns the long-name form (runneradmin).
Use git as the canonicalizer on both sides of the assertion so the comparison
holds regardless of how Windows exposes the temp directory path.

* fix(server): Claude is always available in default mode; SDK bundles cli.js

The Phase 2 change wrongly tied Claude's default-mode isAvailable() to
isCommandAvailable("claude"). Claude's default runtime does not use an
external `claude` binary — @anthropic-ai/claude-agent-sdk ships its own
cli.js and spawnClaudeCodeProcess runs it via process.execPath. The
previous `return true` was correct; restore it.

Update provider-availability.test.ts to assert the truthful behavior:
Claude reports available even when no `claude` binary is on PATH.

Codex and OpenCode genuinely require their binaries on PATH, so their
availability checks remain unchanged.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-16 16:36:13 +08:00
Mohamed Boudra
11b407c804 fix(app): hide branch icon on non-git workspace headers
Gate the GitBranch icon behind isGitCheckout, and keep the header
skeleton visible until the checkout status query resolves so the icon
doesn't pop in and cause layout shift.
2026-04-16 13:14:00 +07:00
Mohamed Boudra
814098852c feat: provider model freshness TTL and diagnostic UI (#426)
* feat: add server-side TTL for provider snapshots and model list UI

Provider snapshots cached forever, causing newly available models
(e.g. OpenCode Go minimax, glm) to never appear in the picker.

Server: ProviderSnapshotManager now returns stale data immediately
and kicks off a background re-warm when the snapshot is older than
5 minutes. Injectable TTL/clock for testing.

App: Provider settings rows show model count and are tappable to
open a diagnostic sheet with a read-only model list (label + ID).

* feat: add per-provider refresh for models and diagnostic

* refactor: await refresh completion and clean up diagnostic sheet state

- ProviderSnapshotManager.refresh is now async; session.ts awaits it so
  the RPC ACK reflects real completion instead of just queuing work.
- Preserve models/modes/fetchedAt on entries during targeted refresh so
  the list no longer flashes empty mid-refresh.
- Show "Updated Xs ago" next to the Models count, plus loading and
  error states for the list body.
- Split resetSnapshotToLoading into full vs targeted branches.
- Flatten nested ternary in model list rendering into renderModelsBody.
- Drop redundant local refreshing state and cargo useMemo wrappers.

* fix(app): widen isImeComposingKeyboardEvent type to accept optional fields

TextInputKeyPressEventData has isComposing and keyCode as optional, so
Pick<KeyboardEvent, ...> was too narrow and broke typecheck on main.
2026-04-16 13:14:02 +08:00
Li Mu Zhi
5c6f175db5 fix: make code file preview text selectable on iOS (#447)
Add `selectable` prop to the code line Text component so users can
long-press to select and copy text on iOS. On web/desktop this is
already the default behavior via CSS user-select.

Markdown file preview has a similar issue but requires a different
fix (custom rules for react-native-markdown-display), left for a
follow-up.

Refs #238
Related #21

Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
2026-04-16 11:06:16 +08:00
Mohamed Boudra
278acf5fe1 docs: credit external contributors with PR/GitHub links in changelog
- Add attribution format requiring PR link and contributor GitHub for each bullet
- Skip attribution for core team (@boudra); changelog highlights community work
- One bullet can reference multiple PRs and contributors; de-duplicate names
- Document ordering: user-facing features first, then QoL, then internal-with-user-benefit
2026-04-16 10:03:27 +07:00
Aaron Florey
165ae58f75 fix(server): Improve OpenCode permission prompt context (#398)
Surface structured permission detail for OpenCode requests so clients can show command intent and richer context instead of generic placeholders. Humanize permission titles and include scope/reason metadata to make approval decisions clearer.
2026-04-16 11:01:56 +08:00
amir
a1f3256923 style(app): theme native scrollbars across all web views (#399)
* style(app): theme native scrollbars in tool call detail views

Apply CSS scrollbar-color to all ScrollViews in ToolCallDetailsContent
and DiffViewer so the browser scrollbar matches the app's dark theme
instead of showing the default white native scrollbar.

* refactor: split useWebScrollbarStyle into .web.ts/.native.ts platform files

- Native shim returns undefined without calling useUnistyles
- Web variant uses properly typed WebScrollbarStyle interface instead of `as any`
- Add .d.ts for TypeScript module resolution

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-16 10:59:31 +08:00
Mohamed Boudra
86bb5cc827 fix: make MCP tools work for archived agents, matching CLI code paths (#423)
Extract shared functions from session.ts (toAgentPersistenceHandle,
buildStoredAgentPayload, ensureAgentLoaded) so both CLI/WebSocket
handlers and MCP tools use the same code paths for agent lookup.

Fix get_agent_status, get_agent_activity, and list_agents MCP tools
to fall back to persistent storage for archived agents. Add
includeArchived param to list_agents. Fix setupFinishNotification
to not wake archived callers. Delete dead agent-management-mcp.ts.
2026-04-16 10:44:57 +08:00
Mohamed Boudra
483312e1dc feat: add disallowedTools to provider config
Allow providers to specify tools that should be disabled via a
disallowedTools array in config.json. This is useful for providers
that extend "claude" but point to third-party API endpoints that
don't support Anthropic-only server-side tools like WebSearch.

Closes #390
2026-04-16 09:42:54 +07:00
Edvard Chen
0b3c29b2ec fix(desktop): allow localhost origins in dev (#419)
Desktop dev uses Electron pages on random localhost ports, so the daemon must accept those websocket origins during local development.
2026-04-16 10:34:30 +08:00
Aaron Florey
1885d8602f feat(app): Render markdown files in file pane (#427)
Detect .md and .markdown files in the shared file pane and render them as markdown instead of syntax highlighted code. This makes README-style files easier to read without changing the existing preview flow for other text files or .mdx.

Keep the change focused to the app renderer by reusing the existing markdown stack and adding a narrow extension check with targeted coverage.
2026-04-16 10:33:49 +08:00
Aaron Florey
57312d4f75 fix(server): Map OpenCode todo and compaction events (#429)
Translate OpenCode todo and compaction events into Paseo timeline items so issue #106 uses the existing todo list and compaction UI without changing the client model.

Keep session.status handling limited to existing terminal states and add focused translator coverage for each mapped event.

Fixes #106

Co-authored-by: OpenCode <noreply@openai.com>
2026-04-16 10:29:05 +08:00
Rui Fan
ffbb2ffd06 fix: retry file explorer init when client reconnects after page refresh (#442)
hasInitializedRef was set to true before confirming the directory
listing request actually succeeded. On page refresh the WebSocket
client is still reconnecting, so requestDirectoryListing returned
early with "Host is not connected" and the ref stayed true —
preventing any retry once the client became available.

Fix: make requestDirectoryListing return Promise<boolean> and reset
hasInitializedRef on failure so the init effect re-runs automatically
when requestDirectoryListing is recreated after the client reconnects.

Fixes #441

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:28:02 +08:00
Edvard Chen
45a5ba8637 Fix duplicate command args for generic ACP providers (#444)
* Fix duplicate command args for generic ACP providers

* Fix desktop IME enter handling
2026-04-16 10:23:05 +08:00
github-actions[bot]
b8ade39f73 fix: update lockfile signatures and Nix hash 2026-04-15 14:03:10 +00:00
Mohamed Boudra
deca82653e chore(release): cut 0.1.57-rc.1 2026-04-15 21:02:07 +07:00
Mohamed Boudra
4e9d80d118 fix: prevent model name truncation in combobox item rows 2026-04-15 19:25:24 +07:00
Li Mu Zhi
918949c7fa fix: file preview shows stale content when re-opening a file (#411)
* fix: file preview shows stale content when re-opening a file

The file content query inherits `refetchOnMount: false` and
`gcTime: Infinity` from the global QueryClient defaults, so
re-opening a previously viewed file never re-fetches its content.

Set `staleTime: 0` and `refetchOnMount: true` on the file preview
query so it always fetches the latest content when the component
mounts.

Fixes #351

* fix: keep original staleTime, only add refetchOnMount

staleTime: 5_000 is reasonable — avoids redundant fetches when
quickly toggling the same file. The actual fix only needs
refetchOnMount: true to override the global default of false.

---------

Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
2026-04-15 13:24:14 +08:00
José Albornoz
c1df523a77 fix: update lockfile signatures and Nix hash (#412)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 13:14:47 +08:00
Mohamed Boudra
7a8f65805d refactor: rename allowedHosts to hostnames (#413)
* refactor: rename allowedHosts to hostnames for DNS rebinding config

The old name confused users who thought it was related to CORS.
"hostnames" communicates that you're declaring the daemon's own
hostnames, not granting access to external parties.

Backward compatible: old config files (daemon.allowedHosts), env var
(PASEO_ALLOWED_HOSTS), CLI flag (--allowed-hosts), and Nix option
(allowedHosts) all still work as deprecated aliases.

* fix(nix): update npmDepsHash to match current package-lock.json
2026-04-15 12:47:49 +08:00
Mohamed Boudra
89b87c0235 docs: add local test suite execution rules 2026-04-15 10:40:24 +07:00
Mohamed Boudra
fc96b260d1 refactor: rename isInputActive to isPaneFocused for clarity
The prop represents whether the pane/panel is focused, not whether the
text input is focused. Rename across all 5 files to match the caller
names and reduce confusion.
2026-04-15 10:36:36 +07:00
Mohamed Boudra
3fe79535cc fix: only show ⌘L focus hint when agent panel is active 2026-04-15 10:33:42 +07:00
Mohamed Boudra
d40e4b6f0d feat: add ⌘L shortcut to focus message input with hint
Show a muted "⌘L to focus" hint in the top-right of the message input
when unfocused and empty on web. Registers Cmd+L (Mac) / Ctrl+L
(non-Mac) bindings for the existing message-input.focus action.
2026-04-15 09:57:05 +07:00
Aaron Florey
8f04e65cf1 fix(opencode): Wait for SSE after slash command timeouts (#407)
Treat OpenCode slash-command header timeouts as recoverable transport failures so Paseo waits for the SSE terminal event instead of failing the turn immediately.

Add a regression test for the timeout path to keep slash commands aligned with the existing event-stream completion flow.
2026-04-15 10:52:58 +08:00
Aaron Florey
c5295a4ab3 fix(server): archive OpenCode sessions on close (#408)
Reconcile OpenCode provider state when Paseo closes or archives an agent so upstream sessions do not remain active after local teardown.

Keep the close path idempotent by attempting an abort first and then marking the upstream session archived even when the run is already idle or missing.

Fixes #400

Co-authored-by: OpenCode <noreply@openai.com>
2026-04-15 10:52:15 +08:00
Mohamed Boudra
f59af4c810 fix: catch unhandled timeout rejection in attention clear
clearAgentAttention returns a promise that was never caught,
causing uncaught rejection errors on daemon timeout.
2026-04-15 09:16:09 +07:00
Mohamed Boudra
e012c8f52e feat: return agent snapshots from cancel and clear-attention RPCs
Convert cancel_agent_request and clear_agent_attention from
fire-and-forget to request/response RPCs that return the authoritative
agent snapshot. This enables client-side self-healing when agent_update
messages are missed (e.g. agent appears stuck running when it isn't).
2026-04-14 23:21:37 +07:00
Mohamed Boudra
312a6e22fe fix: flush head before applying canonical catch-up entries on mobile
On mobile, the server drops live stream events for backgrounded or
unfocused agents.  When the user returns, a seq gap triggers a
catch-up fetch whose canonical entries are appended to the tail —
but the head was never flushed.  Stale live items from before the
gap stayed in the head and rendered after the newer catch-up entries,
breaking chronological order.  Worse, subsequent live events of the
same kind (e.g. assistant_message) would append to the stale head
item, garbling message content.

Now the incremental path in processTimelineResponse flushes head →
tail before reducing canonical entries, keeping the timeline ordered
and the head clean for new live events.
2026-04-14 23:03:23 +07:00
Mohamed Boudra
7f92f7f51d fix: stabilize branch switcher layout on mobile
Always render the GitBranch icon regardless of branch availability so
the layout doesn't shift when the branch name loads. Only the chevron
remains dynamic. Also removes vertical padding on mobile to tighten
the gap between the branch row and project subtitle.
2026-04-14 22:27:06 +07:00
Mohamed Boudra
200453f032 docs: add 0.1.56 changelog 2026-04-14 21:17:12 +07:00
Mohamed Boudra
d4a4015804 chore(release): cut 0.1.56 2026-04-14 21:15:53 +07:00
github-actions[bot]
32b68cbcf4 fix: update lockfile signatures and Nix hash 2026-04-14 13:57:52 +00:00
Mohamed Boudra
c8cafbabff chore(release): cut 0.1.56-rc.1 2026-04-14 20:56:11 +07:00
Mohamed Boudra
f0ac632732 fix: isolate git snapshot errors per workspace in fetch_workspaces
A single workspace failing to load git data (e.g. empty repo, corrupt
git state) was crashing the entire fetch_workspaces response, leaving
all users with an unusable app. Now errors are caught per-workspace
and logged as warnings, returning the workspace without git data.
2026-04-14 20:54:11 +07:00
Mohamed Boudra
25fd93d7e2 fix: handle repos with no commits in getCurrentBranch
git rev-parse --abbrev-ref HEAD fails with exit code 128 on freshly
initialized repos with no commits. This error bubbled up through
workspace listing and showed a toast error on every app launch for
users with empty git repos.
2026-04-14 20:49:54 +07:00
github-actions[bot]
2090d685ed fix: update lockfile signatures and Nix hash 2026-04-14 13:16:33 +00:00
Mohamed Boudra
c70277f445 chore(release): cut 0.1.55 2026-04-14 20:15:14 +07:00
Mohamed Boudra
649eddeea8 docs: add 0.1.55 changelog 2026-04-14 20:12:12 +07:00
Mohamed Boudra
30c7729a7a docs: add custom provider configuration guide
Covers Z.AI (Zhipu), Alibaba Cloud (Qwen) coding plans, ACP providers
(Gemini CLI, Hermes), multiple profiles, custom binaries, and disabling
providers. All examples backed by official provider documentation.
2026-04-14 20:10:42 +07:00
Mohamed Boudra
8651c334c7 fix: keep worktree creation spinner visible while loading
The new-worktree button spinner was hidden when the cursor left the
project row. Force visibility when the mutation is pending so users
see the loading state throughout creation.
2026-04-14 19:43:08 +07:00
Mohamed Boudra
d1702e8a40 fix: defer worktree git watch subscription until after worktree exists
registerPendingWorktreeWorkspace was subscribing the WorkspaceGitService
before the worktree directory existed on disk, caching a stale isGit:false
snapshot. When background reconciliation ran, it consumed the stale cache,
overwrote the correct workspace record with a wrong projectId, and briefly
showed the worktree as a standalone project in the sidebar.

Move syncWorkspaceGitWatchTarget from registerPendingWorktreeWorkspace into
handleCreatePaseoWorktreeRequest, called after createAgentWorktree, so the
first snapshot sees the real worktree — one subscribe, one load, no stale
cache to race against.
2026-04-14 19:38:47 +07:00
Mohamed Boudra
80eb5dbe83 fix: make workspace-git-service tests platform-aware for Linux CI
The requestWorkingTreeWatch tests assumed macOS/Windows behavior where
recursive fs.watch is attempted. On Linux, the service uses per-directory
watchers instead, so recursive is never tried.
2026-04-14 19:20:59 +07:00
Mohamed Boudra
380c9927d7 fix: stop logging pairing offer during daemon startup 2026-04-14 19:13:50 +07:00
Mohamed Boudra
345729c588 refactor: eliminate getCheckoutStatusLite, use WorkspaceGitService
Route all git checkout queries through WorkspaceGitService instead of
shelling out to git on every call. This makes fetch_agents_request
instant when warm (cached in-memory snapshots) and deduplicates
concurrent cold-start refreshes via ensureWorkspaceTarget.

- buildProjectPlacementForCwd now uses workspaceGitService.getSnapshot()
- worktree-session call sites use WorkspaceGitService
- Deleted getCheckoutStatusLite and its 4 return types from checkout-git
- Fixed cold-start thundering herd: getSnapshot deduplicates via
  ensureWorkspaceTarget instead of spawning parallel git processes
- Extracted checkout-diff logic into WorkspaceGitService
2026-04-14 19:05:36 +07:00
Mohamed Boudra
3cdadd362c chore: remove noisy WorkspaceFetch debug logs 2026-04-14 19:05:36 +07:00
Qiao Wang
5a836eb170 Allow dev on windows machine (#357) 2026-04-14 19:26:47 +08:00
Mohamed Boudra
61c36b4e22 fix: use cached snapshot for checkout_pr_status_request
The handler was calling refresh() before every getSnapshot(), forcing
a full git + gh subprocess reload (~1.2s). The filesystem watcher
already keeps the snapshot current, so just read the cache directly.
2026-04-14 17:46:21 +07:00
Mohamed Boudra
df0ce60a35 Fix formatting in synced loader components 2026-04-14 17:10:54 +07:00
Mohamed Boudra
f21f861653 Fix CLI test import for resolveProviderAndModel
The function moved to utils/provider-model.ts but the test still
imported from commands/agent/run.ts.
2026-04-14 17:08:16 +07:00
github-actions[bot]
dbd1645dc9 fix: update lockfile signatures and Nix hash 2026-04-14 07:44:49 +00:00
Mohamed Boudra
dd99144587 chore(release): cut 0.1.55-rc.2 2026-04-14 14:43:38 +07:00
Mohamed Boudra
dd6a396dbf Add provider/model selection for schedules and workspace fetch debug logging
- Unify resolveProviderAndModel across CLI and server, supporting
  provider/model syntax (e.g. codex/gpt-5.4) for agent runs and schedules
- Add --provider flag to schedule create CLI and both MCP schedule tools
- Add structured debug logging to workspace fetch hydrate, sidebar
  refresh, and real-time update paths
- Update orchestrate skill references
2026-04-14 14:42:10 +07:00
Mohamed Boudra
202fa3e8f3 Improve synced loader visibility in light mode 2026-04-14 13:33:16 +07:00
Mohamed Boudra
8d854a6342 Fix desktop workspace header alignment 2026-04-14 12:23:56 +07:00
Mohamed Boudra
5329a12222 Fix compact status bar model overflow 2026-04-14 11:36:50 +07:00
Mohamed Boudra
9c0b5616fe Refine compact workspace header layout 2026-04-14 11:34:50 +07:00
Mohamed Boudra
c59a979446 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-14 10:38:29 +07:00
Mohamed Boudra
6ed166641f Fix mobile model selector overflow 2026-04-14 10:26:28 +07:00
Mohamed Boudra
be3d5ed78d fix: use cached git service snapshots for fetchWorkspaces instead of shelling out
describeWorkspaceRecordWithGitData was calling buildProjectPlacement → getCheckoutStatusLite
which spawned 3-4 sequential git child processes per workspace. With 8 active workspaces
and event loop pressure from running agents, this caused fetchWorkspaces to take 35-45s.

Now uses workspaceGitService.getSnapshot() which returns instantly from cache (warm path)
or awaits the git service's own concurrency-managed refresh (cold path).
2026-04-14 03:54:32 +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-store
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 Cui
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 Boudra
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
286 changed files with 16861 additions and 6518 deletions

View File

@@ -76,6 +76,44 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: >
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.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/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
app-tests:
runs-on: ubuntu-latest
steps:

View File

@@ -1,5 +1,72 @@
# Changelog
## 0.1.57 - 2026-04-16
### Added
- Markdown files render as formatted markdown in the file pane instead of raw source. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input, with a hint shown when the agent panel is active.
- Provider model lists refresh on a freshness TTL, and Settings surfaces when models were last updated along with any fetch errors inline. ([#426](https://github.com/getpaseo/paseo/pull/426))
- `disallowedTools` option in provider config to block specific tools from an agent.
### Improved
- Windows: Codex startup and provider resolution are significantly more resilient — handles quoted paths, `.cmd` shims, and missing binaries without crashing. ([#454](https://github.com/getpaseo/paseo/pull/454))
- OpenCode reliability — permission prompts now include the requesting tool's context, todo and compaction events render in the timeline, sessions archive cleanly on close, and slash commands recover from SSE timeouts. ([#398](https://github.com/getpaseo/paseo/pull/398), [#407](https://github.com/getpaseo/paseo/pull/407), [#408](https://github.com/getpaseo/paseo/pull/408), [#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
- Paseo MCP tools work against archived agents, matching the CLI's behaviour. ([#423](https://github.com/getpaseo/paseo/pull/423))
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
### Fixed
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
- Workspace headers no longer show a branch icon for non-git workspaces.
- Branch switcher layout is stable on mobile.
- Model names no longer truncate mid-word in the picker rows.
- Messages appear in the correct order after reconnecting on mobile.
- Clearing agent attention no longer throws on timeout.
## 0.1.56 - 2026-04-14
### Fixed
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
- A single problematic project can no longer prevent the rest of your workspaces from loading.
## 0.1.55 - 2026-04-14
### Added
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
- Choose provider and model when creating scheduled agents.
- Max reasoning effort option for Opus 4.6 models.
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
### Improved
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
- iPad and tablet layouts work correctly across all screen sizes.
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
### Fixed
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
- Workspace navigation updates correctly when switching between workspaces in the same project.
- Desktop workspace header alignment and model selector no longer overflow on narrow windows.
- Loading indicators are visible in light mode.
## 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
### Added

View File

@@ -1,19 +0,0 @@
# CI Test Status
Tracking progress toward all-green CI.
## CI Jobs
| Job | Status | Notes |
|-----|--------|-------|
| format | unknown | `npx biome format .` |
| typecheck | unknown | `npm run typecheck` |
| server-tests | unknown | unit + integration (vitest) |
| app-tests | unknown | unit tests (vitest) |
| playwright | unknown | E2E tests (playwright) |
| relay-tests | unknown | unit tests (vitest) |
| cli-tests | unknown | local tests |
## Log
- 2026-04-10: Branch created, automated agents begin iterating

View File

@@ -24,6 +24,7 @@ This is an npm workspace monorepo:
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
@@ -46,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
- Never run `npm run test` for an entire workspace unless explicitly asked.
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck after every change.**
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
@@ -55,6 +62,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- Never narrow a field's type (e.g. `string``enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Platform gating
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
### The four gates
| Gate | Type | When to use |
|---|---|---|
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
### Decision matrix
| I need to... | Use |
|---|---|
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
### Rules
- **Default is cross-platform.** Don't gate unless you have a specific reason.
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
```
hooks/
use-audio-recorder.web.ts ← uses Web Audio API
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
## Debugging

View File

@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
## Agent authentication

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: [],
hostnames: 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.

548
docs/CUSTOM-PROVIDERS.md Normal file
View File

@@ -0,0 +1,548 @@
# Custom Provider Configuration
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
All provider configuration lives under `agents.providers` in config.json:
```json
{
"version": 1,
"agents": {
"providers": {
"provider-id": { ... }
}
}
}
```
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
---
## Table of Contents
- [Extending a built-in provider](#extending-a-built-in-provider)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
- [Custom binary for a provider](#custom-binary-for-a-provider)
- [Disabling a provider](#disabling-a-provider)
- [ACP providers](#acp-providers)
- [Provider override reference](#provider-override-reference)
---
## Extending a built-in provider
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
```json
{
"agents": {
"providers": {
"my-claude": {
"extends": "claude",
"label": "My Claude",
"description": "Claude with custom API endpoint",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
}
}
}
}
}
```
Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
---
## Z.AI (Zhipu) coding plan
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
### Setup
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
2. Create an API key from the Z.AI dashboard
3. Add a provider entry in config.json:
```json
{
"agents": {
"providers": {
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
}
}
}
}
```
### Available models
| Model | Tier |
|---|---|
| `glm-5.1` | Advanced (flagship) |
| `glm-5-turbo` | Advanced |
| `glm-4.7` | Standard |
| `glm-4.5-air` | Lightweight |
### Notes
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Automated setup is also available: `npx @z_ai/coding-helper`
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
---
## Alibaba Cloud (Qwen) coding plan
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
### Setup
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
2. Subscribe to the Pro plan ($50/month)
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
4. Add a provider entry in config.json:
```json
{
"agents": {
"providers": {
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
]
}
}
}
}
```
### API endpoints
| Mode | Base URL |
|---|---|
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
### Available models
**Recommended for coding plan:**
| Model | Notes |
|---|---|
| `qwen3.5-plus` | Vision capable, recommended |
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
**Additional models (pay-as-you-go):**
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
### Notes
- API keys must be created in the **Singapore region**
- The coding plan is for personal use only in interactive coding tools
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
Example: two different Anthropic accounts as separate profiles:
```json
{
"agents": {
"providers": {
"claude-work": {
"extends": "claude",
"label": "Claude (Work)",
"description": "Work Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-work-..."
}
},
"claude-personal": {
"extends": "claude",
"label": "Claude (Personal)",
"description": "Personal Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
}
}
}
}
}
```
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
You can also combine profiles with model overrides to pin specific models per profile:
```json
{
"agents": {
"providers": {
"claude-fast": {
"extends": "claude",
"label": "Claude (Fast)",
"models": [
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
]
},
"claude-smart": {
"extends": "claude",
"label": "Claude (Smart)",
"models": [
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
]
}
}
}
}
```
---
## Custom binary for a provider
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
### Override a built-in provider's binary
```json
{
"agents": {
"providers": {
"claude": {
"command": ["/opt/claude-nightly/claude"]
}
}
}
}
```
### Use a custom wrapper script
```json
{
"agents": {
"providers": {
"claude": {
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
}
}
}
}
```
### Custom binary on a derived provider
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "Codex (Custom Build)",
"command": ["/home/user/codex-dev/target/release/codex"]
}
}
}
}
```
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
---
## Disabling a provider
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
```json
{
"agents": {
"providers": {
"copilot": { "enabled": false },
"codex": { "enabled": false }
}
}
}
```
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
---
## ACP providers
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent-binary", "--acp"],
"env": {
"MY_API_KEY": "..."
}
}
}
}
}
```
Required fields for ACP providers:
- `extends: "acp"`
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
### Example: Google Gemini CLI
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
2. Authenticate with Google (Gemini CLI handles its own auth)
3. Add to config.json:
```json
{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
}
}
}
}
```
Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md)
### Example: Hermes (Nous Research)
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`
2. Install ACP support: `pip install -e '.[acp]'`
3. Configure Hermes credentials in `~/.hermes/`
4. Add to config.json:
```json
{
"agents": {
"providers": {
"hermes": {
"extends": "acp",
"label": "Hermes",
"description": "Nous Research self-improving AI agent",
"command": ["hermes", "acp"]
}
}
}
}
```
Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp)
### How ACP providers work in Paseo
When you launch an agent with an ACP provider:
1. Paseo spawns the process using the configured `command`
2. Sends an `initialize` JSON-RPC request over stdin
3. The agent responds with its capabilities, available modes, and models
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"models": [
{ "id": "fast-model", "label": "Fast", "isDefault": true },
{ "id": "smart-model", "label": "Smart" }
]
}
}
}
}
```
Profile models (defined in config.json) completely replace runtime-discovered models when present.
---
## Provider override reference
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
|---|---|---|---|
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
Each entry in the `models` array:
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Model identifier sent to the provider |
| `label` | `string` | Yes | Display name in the UI |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default model selection |
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
### Thinking option
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Thinking option identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
Use `disallowedTools` to disable unsupported tools:
```json
{
"agents": {
"providers": {
"my-proxy": {
"extends": "claude",
"label": "My Proxy",
"env": {
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
},
"disallowedTools": ["WebSearch"]
}
}
}
}
```
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
Special value: `acp` — creates a generic ACP provider (requires `command`)
### Full example
A config.json with multiple custom providers:
```json
{
"version": 1,
"agents": {
"providers": {
"copilot": { "enabled": false },
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
},
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
]
},
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
},
"hermes": {
"extends": "acp",
"label": "Hermes",
"command": ["hermes", "acp"]
}
}
}
}
```

View File

@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
version: 1,
daemon: {
listen: "127.0.0.1:6767",
allowedHosts: true | string[],
hostnames: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }

View File

@@ -139,6 +139,33 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
```markdown
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
```
Rules:
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
## Changelog ordering
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.

View File

@@ -9,6 +9,10 @@ let
cfg = config.services.paseo;
in
{
imports = [
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
];
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
@@ -58,12 +62,12 @@ in
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
hostnames = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
@@ -141,10 +145,10 @@ in
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // lib.optionalAttrs (cfg.hostnames == true) {
PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // cfg.environment;
serviceConfig = {

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-0VEwgqUn0j2YWofW8IB5SNpo7Vp3Znfz1TMDAnZFkzs=";
npmDepsHash = "sha256-Wn7g+VXuUe0LI733MUj+WRX/fJQHOO/HFEkqyHrWTf0=";
# 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).

91
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.53",
"version": "0.1.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.53",
"version": "0.1.57",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34906,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.53",
"version": "0.1.57",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.53",
"@getpaseo/highlight": "0.1.53",
"@getpaseo/server": "0.1.53",
"@getpaseo/expo-two-way-audio": "0.1.57",
"@getpaseo/highlight": "0.1.57",
"@getpaseo/server": "0.1.57",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35056,11 +35056,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.53",
"version": "0.1.57",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.53",
"@getpaseo/server": "0.1.53",
"@getpaseo/relay": "0.1.57",
"@getpaseo/server": "0.1.57",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35101,11 +35101,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.53",
"version": "0.1.57",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.53",
"@getpaseo/server": "0.1.53",
"@getpaseo/cli": "0.1.57",
"@getpaseo/server": "0.1.57",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35139,7 +35139,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.53",
"version": "0.1.57",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35340,7 +35340,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.53",
"version": "0.1.57",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35366,7 +35366,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.53",
"version": "0.1.57",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35382,14 +35382,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.53",
"version": "0.1.57",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.53",
"@getpaseo/relay": "0.1.53",
"@getpaseo/highlight": "0.1.57",
"@getpaseo/relay": "0.1.57",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35406,6 +35406,7 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
@@ -35416,6 +35417,7 @@
"strip-ansi": "^7.1.2",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.1"
@@ -35639,6 +35641,15 @@
"node": ">= 0.8"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -35681,6 +35692,21 @@
"node": ">= 0.6"
}
},
"packages/server/node_modules/p-limit": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
@@ -35778,6 +35804,33 @@
"node": ">= 0.6"
}
},
"packages/server/node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -35789,7 +35842,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.53",
"version": "0.1.57",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.53",
"version": "0.1.57",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -14,6 +14,7 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
@@ -36,6 +37,7 @@
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -224,6 +224,51 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
return offer as OfferPayload;
}
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
const stdout = execFileSync(
process.execPath,
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
{
cwd: repoRoot,
env: {
...process.env,
PASEO_HOME: paseoHomePath,
},
encoding: "utf8",
},
);
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
throw new Error(`Unexpected daemon pair response: ${stdout}`);
}
return decodeOfferFromFragmentUrl(payload.url);
}
async function waitForPairingOfferFromCli(args: {
repoRoot: string;
paseoHome: string;
timeoutMs?: number;
}): Promise<OfferPayload> {
const timeoutMs = args.timeoutMs ?? 15000;
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
} catch (error) {
lastError = error;
await sleep(100);
}
}
throw new Error(
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
@@ -433,12 +478,6 @@ export default async function globalSetup() {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
const offerPromise = new Promise<void>((resolve) => {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
@@ -473,26 +512,6 @@ export default async function globalSetup() {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
if (!offerPayload) {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes("pairing_offer")) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
} catch {
// ignore parsing failures
}
}
}
}
console.log(`[daemon] ${trimmed}`);
}
});
@@ -523,17 +542,10 @@ export default async function globalSetup() {
}),
]);
// Wait for daemon to emit a pairing offer (includes relay session ID).
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
),
]);
if (!offerPayload) {
throw new Error("pairing_offer was not parsed from daemon logs");
}
const offer = offerPayload as OfferPayload;
const offer = await waitForPairingOfferFromCli({
repoRoot,
paseoHome,
});
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);

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 };
}

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();
}
});
});

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.53",
"version": "0.1.57",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.53",
"@getpaseo/highlight": "0.1.53",
"@getpaseo/server": "0.1.53",
"@getpaseo/expo-two-way-audio": "0.1.57",
"@getpaseo/highlight": "0.1.57",
"@getpaseo/server": "0.1.57",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -51,6 +51,17 @@
[contenteditable='true'] {
-webkit-app-region: no-drag !important;
}
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid #20744A;
outline-offset: 2px;
}
</style>
</head>
<body>

View File

@@ -81,6 +81,7 @@ import {
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto();
@@ -90,6 +91,22 @@ export type HostRuntimeBootstrapState = {
retry: () => void;
};
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const firstValue = value[0];
if (typeof firstValue !== "string") {
return undefined;
}
const trimmed = firstValue.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
return undefined;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
@@ -101,7 +118,7 @@ function PushNotificationRouter() {
const lastHandledIdRef = useRef<string | null>(null);
useEffect(() => {
if (Platform.OS === "web") {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
@@ -390,10 +407,10 @@ function AppContainer({
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
const windowW = isWeb ? window.innerWidth : undefined;
const windowH = isWeb ? window.innerHeight : undefined;
const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = isWeb ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
@@ -577,7 +594,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [settingsLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || Platform.OS !== "web") {
if (settingsLoading || isNative) {
return;
}
@@ -787,7 +804,14 @@ function RootStack() {
<Stack.Screen name="settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) => {
const serverId = getRouteParamValue(params?.serverId);
const workspaceId = getRouteParamValue(params?.workspaceId);
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
}}
/>
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { Platform } from "react-native";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
@@ -10,6 +9,7 @@ import {
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
@@ -88,7 +88,7 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
if (Platform.OS === "web" && typeof window !== "undefined") {
if (isWeb && typeof window !== "undefined") {
const url = new URL(window.location.href);
if (url.searchParams.has("open")) {
url.searchParams.delete("open");

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, Pressable, Text, View } from "react-native";
import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
}, [router, source, sourceServerId, targetServerId]);
useEffect(() => {
if (Platform.OS === "web") return;
if (isWeb) return;
if (permission && permission.granted) return;
void requestPermission().catch(() => undefined);
}, [permission, requestPermission]);
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
if (isWeb) {
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>

View File

@@ -1,11 +1,11 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isWeb) {
if (isElectronRuntime()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"

View File

@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -14,6 +14,36 @@ import {
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
function handleEscKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
const top = escStack[escStack.length - 1];
if (!top) return;
event.stopPropagation();
event.preventDefault();
top();
}
function pushEscHandler(handler: EscHandler): () => void {
escStack.push(handler);
if (!escListenerAttached && typeof window !== "undefined") {
window.addEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = true;
}
return () => {
const index = escStack.lastIndexOf(handler);
if (index !== -1) escStack.splice(index, 1);
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
window.removeEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = false;
}
};
}
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
@@ -47,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.surface2,
},
title: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginLeft: theme.spacing[3],
marginRight: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -82,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[6],
gap: theme.spacing[4],
},
bottomSheetStaticContent: {
flex: 1,
padding: theme.spacing[6],
gap: theme.spacing[4],
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
padding: theme.spacing[6],
gap: theme.spacing[4],
},
}));
function SheetBackground({ style }: BottomSheetBackgroundProps) {
@@ -105,9 +155,11 @@ export interface AdaptiveModalSheetProps {
visible: boolean;
onClose: () => void;
children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
scrollable?: boolean;
}
export function AdaptiveModalSheet({
@@ -115,9 +167,11 @@ export function AdaptiveModalSheet({
visible,
onClose,
children,
headerActions,
snapPoints,
stackBehavior,
testID,
scrollable = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
@@ -156,6 +210,11 @@ export function AdaptiveModalSheet({
[],
);
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
}, [visible, isMobile, onClose]);
if (isMobile) {
return (
<BottomSheetModal
@@ -173,18 +232,25 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
</BottomSheetModal>
);
}
@@ -198,25 +264,32 @@ export function AdaptiveModalSheet({
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
</View>
</View>
);
// On web, use portal to overlay root for consistent stacking with toasts
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot());
}

View File

@@ -1,8 +1,9 @@
import { useCallback } from "react";
import { Pressable, Text, View, Platform } from "react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
option: {
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
</View>
</Pressable>
{Platform.OS !== "web" ? (
{isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck,
@@ -168,7 +169,7 @@ export function SelectField({
const handleKeyDown = useCallback(
(event: unknown) => {
if (Platform.OS !== "web") return;
if (isNative) return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
const handleKeyDown = useCallback(
(event: unknown) => {
if (Platform.OS !== "web") return;
if (isNative) return;
const key = getWebKey(event);
if (key === "Enter" || key === " ") {
preventWebDefault(event);
@@ -556,7 +557,11 @@ export function AgentConfigRow({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
const selectedModeVisuals = getModeVisuals(
selectedProvider,
effectiveSelectedMode,
providerDefinitions,
);
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];

View File

@@ -1,4 +1,4 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
type QueuedMessage = {
id: string;
@@ -62,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
interface AgentInputAreaProps {
agentId: string;
serverId: string;
isInputActive: boolean;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
@@ -97,7 +98,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
agentId,
serverId,
isInputActive,
isPaneFocused,
onSubmitMessage,
isSubmitLoading = false,
blurOnSubmit = false,
@@ -118,7 +119,7 @@ export function AgentInputArea({
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -156,7 +157,7 @@ export function AgentInputArea({
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
const isDesktopWebBreakpoint = isWeb && !isMobile;
const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER;
@@ -217,7 +218,7 @@ export function AgentInputArea({
}, [addImages, onAddImages]);
const focusInput = useCallback(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
@@ -420,7 +421,7 @@ export function AgentInputArea({
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!isInputActive) {
if (!isPaneFocused) {
return false;
}
@@ -430,7 +431,7 @@ export function AgentInputArea({
case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus":
if (Platform.OS !== "web") {
if (isNative) {
messageInputRef.current?.focus();
return true;
}
@@ -460,7 +461,7 @@ export function AgentInputArea({
return false;
}
},
[isInputActive],
[isPaneFocused],
);
useKeyboardActionHandler({
@@ -474,9 +475,9 @@ export function AgentInputArea({
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isInputActive,
enabled: isPaneFocused,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isInputActive,
isActive: () => isPaneFocused,
handle: handleKeyboardAction,
});
@@ -735,7 +736,7 @@ export function AgentInputArea({
autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isInputActive={isInputActive}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}

View File

@@ -258,7 +258,8 @@ export function AgentList({
if (!actionAgent || !actionClient) {
return;
}
void actionClient.archiveAgent(actionAgent.id);
// Timeout errors are swallowed — the daemon will still process the archive
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
setActionAgent(null);
}, [actionAgent, actionClient]);

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { View, Text, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
@@ -40,7 +41,6 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
AGENT_PROVIDER_DEFINITIONS,
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
@@ -51,6 +51,7 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
type StatusOption = {
id: string;
@@ -59,10 +60,6 @@ type StatusOption = {
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
type ControlledAgentStatusBarProps = {
provider: string;
providerOptions?: StatusOption[];
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[];
providerDefinitions: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>;
@@ -222,7 +219,6 @@ function ControlledStatusBar({
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
@@ -252,7 +248,9 @@ function ControlledStatusBar({
thinkingOptions?.[0]?.label ?? "Unknown",
);
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const modeVisuals = selectedModeId
? getModeVisuals(provider, selectedModeId, providerDefinitions)
: undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider);
@@ -300,9 +298,7 @@ function ControlledStatusBar({
);
return map;
}, [modelOptions, provider]);
const effectiveProviderDefinitions =
providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveProviderDefinitions = providerDefinitions;
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
@@ -322,7 +318,7 @@ function ControlledStatusBar({
active: boolean;
onPress: () => void;
}) => {
const visuals = getModeVisuals(provider, option.id);
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<ComboboxItem
@@ -334,7 +330,7 @@ function ControlledStatusBar({
/>
);
},
[provider, theme.colors.foreground],
[provider, providerDefinitions, theme.colors.foreground],
);
const handleOpenChange = useCallback(
@@ -356,7 +352,7 @@ function ControlledStatusBar({
return (
<View style={styles.container}>
{isWeb ? (
{platformIsWeb ? (
<>
{providerOptions && providerOptions.length > 0 ? (
<>
@@ -745,7 +741,7 @@ function ControlledStatusBar({
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id);
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
@@ -896,9 +892,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const models = snapshotModels;
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
const definition = agent?.provider
? resolveProviderDefinition(agent.provider, snapshotEntries)
: undefined;
return definition ? [definition] : [];
}, [agent?.provider]);
}, [agent?.provider, snapshotEntries]);
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
@@ -1077,7 +1075,6 @@ export function DraftAgentStatusBar({
onModelSelectorOpen,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
@@ -1105,7 +1102,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) {
if (platformIsWeb) {
return (
<View style={styles.container}>
<CombinedModelSelector
@@ -1129,6 +1126,7 @@ export function DraftAgentStatusBar({
/>
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
@@ -1227,6 +1225,8 @@ const styles = StyleSheet.create((theme) => ({
},
prefsButton: {
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],

View File

@@ -73,6 +73,7 @@ import {
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({
tail: streamItems,
head: streamHead ?? [],
platform: Platform.OS === "web" ? "web" : "native",
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
@@ -331,6 +332,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
/>
);
case "thought": {

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
@@ -128,6 +132,25 @@ describe("estimateStreamItemHeight", () => {
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", () => {

View File

@@ -1,4 +1,5 @@
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_MOUNTED_RECENT_STREAM_ITEMS = 50;
@@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return 220;
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
case "tool_call":
return 136;
case "thought":

View File

@@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
export interface Artifact {
id: string;
@@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
const webScrollbarStyle = useWebScrollbarStyle();
if (!artifact) {
return null;
}
@@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
{/* Content */}
<ScrollView
style={styles.contentScroll}
style={[styles.contentScroll, webScrollbarStyle]}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
@@ -24,6 +25,7 @@ export function BranchSwitcher({
isGitCheckout,
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -41,12 +43,17 @@ export function BranchSwitcher({
queryClient,
});
if (!currentBranchName) {
return (
const titleContent = (
<>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
);
</>
);
if (!currentBranchName) {
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
}
return (
@@ -61,11 +68,8 @@ export function BranchSwitcher({
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
<GitBranch size={14} color={theme.colors.foregroundMuted} />
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
</Pressable>
<Combobox
options={branchOptions}
@@ -111,7 +115,14 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
marginLeft: {
xs: -theme.spacing[2],
md: 0,
},
paddingVertical: {
xs: 0,
md: theme.spacing[1],
},
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexShrink: 1,

View File

@@ -4,17 +4,17 @@ import {
Text,
TextInput,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = Platform.OS === "web";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
@@ -349,7 +349,7 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) {
if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
@@ -363,7 +363,7 @@ function ProviderSearchInput({
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -518,10 +518,9 @@ export function CombinedModelSelector({
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb);
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
@@ -591,7 +590,7 @@ export function CombinedModelSelector({
}, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => {
if (isWeb) {
if (platformIsWeb) {
return;
}
@@ -605,7 +604,7 @@ export function CombinedModelSelector({
});
return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]);
}, [isOpen, platformIsWeb]);
return (
<>
@@ -635,7 +634,9 @@ export function CombinedModelSelector({
) : (
<>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{triggerLabel}</Text>
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</>
)}
@@ -668,7 +669,7 @@ export function CombinedModelSelector({
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"}
autoFocus={platformIsWeb}
/>
</View>
) : undefined
@@ -705,6 +706,8 @@ export function CombinedModelSelector({
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
@@ -722,6 +725,8 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.5,
},
triggerText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,

View File

@@ -1,4 +1,4 @@
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
@@ -90,7 +91,7 @@ export function CommandCenter() {
}
}, [activeIndex, open]);
if (Platform.OS !== "web" || !open) return null;
if (isNative || !open) return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
/**
* VS Code-style titlebar drag region for Electron.
@@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
* Place as FIRST child of any positioned container that should be draggable.
*/
export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
if (isNative || !getIsElectronRuntime()) {
return null;
}

View File

@@ -1,4 +1,5 @@
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
interface DiffScrollProps {
children: React.ReactNode;
@@ -14,12 +15,14 @@ export function DiffScroll({
style,
contentContainerStyle,
}: DiffScrollProps) {
const webScrollbarStyle = useWebScrollbarStyle();
return (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={style}
style={[style, webScrollbarStyle]}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
>

View File

@@ -1,12 +1,14 @@
import React from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps {
diffLines: DiffLine[];
@@ -22,6 +24,7 @@ export function DiffViewer({
fillAvailableHeight = false,
}: DiffViewerProps) {
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const webScrollbarStyle = useWebScrollbarStyle();
if (!diffLines.length) {
return (
@@ -37,6 +40,7 @@ export function DiffViewer({
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.verticalContent}
nestedScrollEnabled
@@ -46,6 +50,7 @@ export function DiffViewer({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
>
@@ -130,7 +135,7 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
...(Platform.OS === "web"
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",

View File

@@ -3,7 +3,6 @@ import {
View,
Text,
Pressable,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
@@ -309,12 +309,7 @@ export function ExplorerSidebar({
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
</GestureDetector>
<SidebarContent

View File

@@ -1,10 +1,11 @@
import { View, Text, Platform } from "react-native";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {
children: React.ReactNode;
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
disabled?: boolean;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles();

View File

@@ -7,7 +7,6 @@ import {
Pressable,
Text,
View,
Platform,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -52,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -91,7 +91,7 @@ export function FileExplorerPane({
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(
@@ -167,23 +167,32 @@ export function FileExplorerPane({
if (hasInitializedRef.current) {
return;
}
// Mark initialized eagerly so concurrent effect re-runs don't double-fetch.
// If the root listing fails (e.g. client not yet connected), we reset the
// flag so the next time requestDirectoryListing is recreated (when client
// becomes available) this effect retries automatically.
hasInitializedRef.current = true;
void requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
const persistedPaths =
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}).then((succeeded) => {
if (!succeeded) {
hasInitializedRef.current = false;
return;
}
const persistedPaths =
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}
}
});
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
expect(isRenderedMarkdownFile("README.md")).toBe(true);
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
});
it("detects .markdown files", () => {
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
});
it("does not treat .mdx files as rendered markdown", () => {
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
});
it("does not treat other text files as rendered markdown", () => {
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
});
});

View File

@@ -0,0 +1,4 @@
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}

View File

@@ -1,18 +1,19 @@
import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import {
ActivityIndicator,
Image as RNImage,
ScrollView as RNScrollView,
Text,
View,
Platform,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
highlightCode,
darkHighlightColors,
@@ -21,6 +22,9 @@ import {
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
</View>
<Text style={codeLineStyles.lineText}>
<Text selectable style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
@@ -117,19 +121,23 @@ function FilePreviewBody({
const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
const previewScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]);
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
@@ -154,6 +162,28 @@ function FilePreviewBody({
}
if (preview.kind === "text") {
if (isMarkdownFile) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
@@ -188,6 +218,7 @@ function FilePreviewBody({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
@@ -243,7 +274,7 @@ export function FilePane({
filePath: string;
}) {
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
@@ -264,6 +295,7 @@ export function FilePane({
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
refetchOnMount: true,
});
return (
@@ -328,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
previewMarkdownScrollContent: {
padding: theme.spacing[4],
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],

View File

@@ -6,7 +6,6 @@ import {
ActivityIndicator,
Pressable,
FlatList,
Platform,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
@@ -79,6 +78,7 @@ import {
formatDiffGutterText,
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -99,7 +99,7 @@ type WrappedWebTextStyle = TextStyle & {
};
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") {
if (isNative) {
return undefined;
}
return wrapLines
@@ -453,7 +453,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
}}
onPressOut={(event) => {
if (
Platform.OS !== "web" &&
isNative &&
!pressHandledRef.current &&
layoutYRef.current === 0 &&
pressInRef.current
@@ -632,8 +632,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);

View File

@@ -1,16 +1,10 @@
import type { ReactElement, ReactNode } from "react";
import {
Platform,
Text,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
interface HeaderToggleButtonState {
hovered: boolean;
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
: undefined;
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
const ariaExpandedProps =
Platform.OS === "web" && typeof expandedState === "boolean"
isWeb && typeof expandedState === "boolean"
? ({ "aria-expanded": expandedState } as any)
: null;

View File

@@ -14,7 +14,6 @@ import {
View,
Pressable,
Text,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
@@ -59,6 +58,7 @@ import {
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
const MIN_CHAT_WIDTH = 400;
@@ -527,7 +527,7 @@ function MobileSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -833,12 +833,7 @@ function DesktopSidebar({
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
</GestureDetector>
</View>
</Animated.View>

View File

@@ -9,7 +9,6 @@ import {
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
Image,
Platform,
BackHandler,
} from "react-native";
import {
@@ -43,11 +42,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform";
export type ImageAttachment = AttachmentMetadata;
@@ -75,8 +78,8 @@ export interface MessageInputProps {
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
isInputActive?: boolean;
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
isPaneFocused?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
@@ -115,13 +118,15 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30;
const MAX_INPUT_HEIGHT = 160;
const IS_WEB = Platform.OS === "web";
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
}
>;
@@ -204,7 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
autoFocus = false,
autoFocusKey,
disabled = false,
isInputActive = true,
isPaneFocused = true,
leftContent,
beforeVoiceContent,
rightContent,
@@ -222,7 +227,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref,
) {
const { theme } = useUnistyles();
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg;
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
@@ -231,7 +236,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const queueKeys = useShortcutKeys("message-input-queue");
const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
@@ -293,7 +300,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return false;
},
getNativeElement: () => {
if (!IS_WEB) return null;
if (!isWeb) return null;
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null;
@@ -328,7 +335,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => {
if (!IS_WEB || !autoFocus) return;
if (!isWeb || !autoFocus) return;
return focusWithRetries({
focus: () => textInputRef.current?.focus(),
isFocused: () => {
@@ -371,7 +378,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText(nextValue);
}
if (IS_WEB && typeof requestAnimationFrame === "function") {
if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
measureWebInputHeight("dictation");
});
@@ -425,7 +432,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isInputActive },
autoStopWhenHidden: { isVisible: isPaneFocused },
enableDuration: true,
});
@@ -627,13 +634,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (IS_WEB) {
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
@@ -647,7 +654,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, []);
useEffect(() => {
if (!IS_WEB || !onAddImages) {
if (!isWeb || !onAddImages) {
return;
}
@@ -700,7 +707,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]);
useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === "undefined") {
if (!isWeb || typeof ResizeObserver === "undefined") {
return;
}
@@ -754,7 +761,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebElement, getWebTextArea]);
useEffect(() => {
if (!IS_WEB) {
if (!isWeb) {
return;
}
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
@@ -792,7 +799,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false;
if (!isWeb) return false;
const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0;
@@ -846,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
const contentHeight = event.nativeEvent.contentSize.height;
if (IS_WEB) {
if (isWeb) {
logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight,
});
@@ -866,7 +873,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
if (IS_WEB) {
if (isWeb) {
const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(),
@@ -880,12 +887,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChangeCallback?.({ start, end });
}
const shouldHandleDesktopSubmit = IS_WEB;
const shouldHandleDesktopSubmit = isWeb;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, "keyPress");
if (!shouldHandleDesktopSubmit) return;
// IME composition in progress (e.g. CJK input) — all key events belong to the
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
// cleared before the keydown fires.
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
const handled = onKeyPressCallback({
@@ -935,7 +947,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue);
if (IS_WEB) {
if (isWeb) {
logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length,
lineCount: nextValue.split("\n").length,
@@ -966,7 +978,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View
style={[
styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
(hovered || !isWeb) && styles.removeImageButtonVisible,
]}
>
<X size={theme.iconSize.md} color="white" />
@@ -990,15 +1002,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}}
style={[
styles.textInput,
IS_WEB
isWeb
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
@@ -1010,14 +1024,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
<Text style={styles.focusHintText} pointerEvents="none">
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
</Text>
) : null}
</View>
{/* Button row */}
@@ -1206,7 +1225,7 @@ const styles = StyleSheet.create(((theme: any) => ({
xs: theme.spacing[3],
md: theme.spacing[4],
},
...(IS_WEB
...(isWeb
? {
transitionProperty: "border-color",
transitionDuration: "200ms",
@@ -1225,7 +1244,7 @@ const styles = StyleSheet.create(((theme: any) => ({
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
...(IS_WEB
...(isWeb
? {
cursor: "pointer",
}
@@ -1250,7 +1269,7 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(IS_WEB
...(isWeb
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
@@ -1263,13 +1282,21 @@ const styles = StyleSheet.create(((theme: any) => ({
textInputScrollWrapper: {
position: "relative",
},
focusHintText: {
position: "absolute",
top: 0,
right: 0,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
opacity: 0.5,
},
textInput: {
width: "100%",
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4,
...(IS_WEB
...(isWeb
? {
outlineStyle: "none" as const,
outlineWidth: 0,
@@ -1283,14 +1310,18 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "space-between",
},
leftButtonGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: 1,
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
},
rightButtonGroup: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
},
attachButton: {
width: 28,

View File

@@ -7,7 +7,6 @@ import {
type LayoutChangeEvent,
StyleProp,
ViewStyle,
Platform,
} from "react-native";
import * as React from "react";
import {
@@ -25,6 +24,7 @@ import {
} from "react";
import type { ReactNode, ComponentType } from "react";
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 {
Circle,
@@ -74,11 +74,18 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
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";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
interface UserMessageProps {
message: string;
@@ -127,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") {
if (isNative) {
return;
}
if (typeof document === "undefined") {
@@ -334,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
return (
<View
@@ -354,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
onHoverIn={() => setMessageHovered(true)}
onHoverOut={() => setMessageHovered(false)}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -401,6 +409,8 @@ interface AssistantMessageProps {
timestamp: number;
onInlinePathPress?: (target: InlinePathTarget) => void;
workspaceRoot?: string;
serverId?: string;
client?: DaemonClient | null;
disableOuterSpacing?: boolean;
}
@@ -425,10 +435,217 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto",
userSelect: isWeb ? "text" : "auto",
},
imageFrame: {
width: "100%",
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({
href,
style,
@@ -441,7 +658,7 @@ function MarkdownLink({
children: ReactNode;
}) {
const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") {
if (isNative) {
return (
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children}
@@ -563,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return (
<Pressable
onPress={handleCopy}
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
onHoverIn={() => onHoverChange?.(true)}
onHoverOut={() => onHoverChange?.(false)}
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
@@ -741,6 +958,8 @@ export const AssistantMessage = memo(function AssistantMessage({
timestamp,
onInlinePathPress,
workspaceRoot,
serverId,
client,
disableOuterSpacing,
}: AssistantMessageProps) {
const { theme, rt } = useUnistyles();
@@ -777,7 +996,7 @@ export const AssistantMessage = memo(function AssistantMessage({
[onInlinePathPress, workspaceRoot],
);
const markdownRules = useMemo(() => {
const markdownRules = useMemo<RenderRules>(() => {
return {
text: (
node: any,
@@ -842,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={Platform.OS === "web" ? undefined : false}
selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
@@ -892,14 +1111,11 @@ export const AssistantMessage = memo(function AssistantMessage({
</View>
);
},
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
{children}
</View>
);
},
paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={[styles.paragraph, { marginBottom: 0 }]}>
{children}
</View>
),
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<MarkdownLink
key={node.key}
@@ -916,8 +1132,30 @@ export const AssistantMessage = memo(function AssistantMessage({
)}
</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]);
@@ -1416,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
);
const isWebShimmer = isLoading && Platform.OS === "web";
const isWebShimmer = isLoading && isWeb;
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
const shouldMeasureNativeShimmer = isLoading && isNative;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel
@@ -1495,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
if (isNative || !isExpanded || !hasDetailContent) {
return;
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
export function ProjectPickerModal() {
const { theme } = useUnistyles();
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
// Keyboard navigation
useEffect(() => {
if (!open || Platform.OS !== "web") return;
if (!open || isNative) return;
function handler(event: KeyboardEvent) {
const key = event.key;

View File

@@ -1,10 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { AlertCircle, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { formatTimeAgo } from "@/utils/time";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -21,83 +27,333 @@ export function ProviderDiagnosticSheet({
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const providerLabel =
AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const providerEntry = useMemo(
() => snapshotEntries?.find((entry) => entry.provider === provider),
[snapshotEntries, provider],
);
const models = providerEntry?.models ?? [];
const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage =
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
return () => clearInterval(id);
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
// clockTick triggers re-computation on timer
}, [providerEntry?.fetchedAt, clockTick]);
setLoading(true);
setDiagnostic(null);
const q = query.trim().toLowerCase();
const filteredModels = q
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
: models;
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
} finally {
setLoading(false);
}
}, [client, provider]);
const fetchDiagnostic = useCallback(
async (options?: { keepCurrent?: boolean }) => {
if (!client || !provider) return;
setLoading(true);
if (!options?.keepCurrent) {
setDiagnostic(null);
}
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
} finally {
setLoading(false);
}
},
[client, provider],
);
const handleRefresh = useCallback(() => {
void refresh([provider as AgentProvider]);
void fetchDiagnostic({ keepCurrent: true });
}, [fetchDiagnostic, provider, refresh]);
useEffect(() => {
if (visible) {
fetchDiagnostic();
} else {
setDiagnostic(null);
setQuery("");
}
}, [visible, fetchDiagnostic]);
function renderModelsBody() {
if (models.length === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models</Text>
</View>
);
}
if (models.length === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyState}>
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
</View>
);
}
if (models.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected.</Text>
</View>
);
}
if (filteredModels.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>No models match your search</Text>
</View>
);
}
return filteredModels.map((model: AgentModelDefinition, index) => (
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
));
}
return (
<AdaptiveModalSheet
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
>
{loading ? (
<View style={sheetStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.loadingText}>Fetching diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
horizontal
style={sheetStyles.scrollContainer}
contentContainerStyle={sheetStyles.scrollContent}
scrollable={false}
headerActions={
<Pressable
onPress={handleRefresh}
disabled={refreshInFlight}
hitSlop={8}
style={({ hovered, pressed }) => [
sheetStyles.iconButton,
(hovered || pressed) && sheetStyles.iconButtonHovered,
refreshInFlight ? sheetStyles.disabled : null,
]}
accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
<SpinningRefreshIcon
spinning={refreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
}
>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
{loading && !diagnostic ? (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Running diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
style={sheetStyles.codeScroll}
contentContainerStyle={sheetStyles.codeContent}
showsVerticalScrollIndicator={false}
>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Text style={sheetStyles.codeText} selectable>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
) : (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
</View>
)}
</View>
</View>
<View style={sheetStyles.modelsSection}>
<View style={sheetStyles.modelsHeader}>
<Text style={sheetStyles.sectionTitle}>Models</Text>
<View style={sheetStyles.modelsHeaderMeta}>
<Text style={sheetStyles.countText}>{models.length}</Text>
{fetchedAtLabel ? (
<>
<Text style={sheetStyles.metaDot}>·</Text>
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
</>
) : null}
</View>
</View>
{models.length > 0 ? (
<View style={sheetStyles.searchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
value={query}
onChangeText={setQuery}
placeholder="Search models"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
// @ts-expect-error - outlineStyle is web-only
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
/>
</View>
) : null}
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
) : null}
</View>
</AdaptiveModalSheet>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
loadingContainer: {
section: {
gap: theme.spacing[2],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
mutedText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
iconButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
},
codeBlock: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
overflow: "hidden",
maxHeight: 180,
},
codeScroll: {
maxHeight: 180,
},
codeContent: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
codeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
modelsSection: {
flex: 1,
minHeight: 0,
gap: theme.spacing[2],
},
modelsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
modelsHeaderMeta: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
metaDot: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
countText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
modelsScroll: {
flex: 1,
minHeight: 0,
},
modelsScrollContent: {
paddingBottom: theme.spacing[2],
},
modelRow: {
paddingVertical: theme.spacing[3],
},
modelRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
modelLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
modelId: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontFamily: Fonts.mono,
marginTop: 2,
},
emptyState: {
paddingVertical: theme.spacing[6],
alignItems: "center",
gap: theme.spacing[2],
},
loadingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
scrollContainer: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4],
},
diagnosticText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontFamily: "monospace",
lineHeight: theme.fontSize.sm * 1.6,
},
}));

View File

@@ -1,10 +1,11 @@
import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import { isWeb } from "@/constants/platform";
interface QuestionOption {
label: string;
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
isResponding: boolean;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();

View File

@@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -199,8 +200,8 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
>
<GitPullRequest size={12} color={iconColor} />
@@ -235,7 +236,14 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View>
);
}
@@ -339,7 +347,14 @@ function ProjectLeadingVisual({
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View>
);
}
@@ -543,7 +558,7 @@ function useLongPressDragInteraction(input: {
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || Platform.OS === "web") {
if (!input.menuController || platformIsWeb) {
return;
}
@@ -793,7 +808,12 @@ function ProjectHeaderRow({
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint}
visible={
isHovered ||
platformIsNative ||
isMobileBreakpoint ||
createWorktreeMutation.isPending
}
loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
@@ -801,8 +821,11 @@ function ProjectHeaderRow({
) : null}
{onRemoveProject ? (
<View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
style={
!(isHovered || platformIsNative || isMobileBreakpoint) &&
styles.projectKebabButtonHidden
}
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
>
<DropdownMenu>
<DropdownMenuTrigger
@@ -912,8 +935,8 @@ function WorkspaceRowInner({
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
@@ -978,7 +1001,7 @@ function WorkspaceRowInner({
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isTouchPlatform) ? (
{onArchive && (isHovered || platformIsNative || isCompact) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
@@ -1597,7 +1620,6 @@ export function SidebarWorkspaceList({
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -1831,7 +1853,7 @@ export function SidebarWorkspaceList({
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
useNestable={isNative}
useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds}
/>
);
@@ -1848,7 +1870,7 @@ export function SidebarWorkspaceList({
serverId,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
isNative,
platformIsNative,
creatingWorkspaceIds,
],
);
@@ -1872,7 +1894,7 @@ export function SidebarWorkspaceList({
onDragEnd={handleProjectDragEnd}
scrollEnabled={false}
useDragHandle
nestable={isNative}
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer}
/>
@@ -1883,7 +1905,7 @@ export function SidebarWorkspaceList({
return (
<View style={styles.container}>
{isNative ? (
{platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}

View File

@@ -0,0 +1,67 @@
import { useEffect } from "react";
import { RefreshCw } from "lucide-react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
interface SpinningRefreshIconProps {
spinning: boolean;
size: number;
color: string;
}
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
const rotation = useSharedValue(0);
useEffect(() => {
if (spinning) {
rotation.value = 0;
rotation.value = withRepeat(
withTiming(360, {
duration: 1000,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(rotation);
const remainder = rotation.value % 360;
if (Math.abs(remainder) < 0.001) {
rotation.value = 0;
return;
}
rotation.value = withTiming(360, {
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
easing: Easing.linear,
});
}, [rotation, spinning]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View
style={[
{
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
},
animatedStyle,
]}
>
<RefreshCw size={size} color={color} />
</Animated.View>
);
}

View File

@@ -27,7 +27,7 @@ import {
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { Platform, View, Text } from "react-native";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
@@ -69,6 +69,7 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps {
layout: WorkspaceLayout;
@@ -838,7 +839,7 @@ function SplitPaneView({
);
useEffect(() => {
if (Platform.OS !== "web") {
if (isNative) {
return;
}

View File

@@ -16,7 +16,7 @@ const DOT_COUNT = DOT_SEQUENCE.length;
const GRID_ROWS = 3;
const GRID_COLUMNS = 2;
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const;
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
const sharedStepProgress = makeMutable(0);
let sharedLoopStarted = false;

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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -234,8 +235,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss}
onPointerLeave={resumeDismiss}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
@@ -265,7 +266,7 @@ export function ToastViewport({
</View>
);
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}

View File

@@ -1,15 +1,17 @@
import React, { useMemo, ReactNode } from "react";
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
const ScrollView = isWeb ? RNScrollView : GHScrollView;
// ---- Content Component ----
@@ -29,6 +31,7 @@ export function ToolCallDetailsContent({
showLoadingSkeleton = false,
}: ToolCallDetailsContentProps) {
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
const webScrollbarStyle = useWebScrollbarStyle();
// Compute diff lines for edit type
const diffLines = useMemo(() => {
@@ -64,6 +67,7 @@ export function ToolCallDetailsContent({
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
@@ -73,6 +77,7 @@ export function ToolCallDetailsContent({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
@@ -98,6 +103,7 @@ export function ToolCallDetailsContent({
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
@@ -107,6 +113,7 @@ export function ToolCallDetailsContent({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
@@ -136,6 +143,7 @@ export function ToolCallDetailsContent({
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
@@ -145,6 +153,7 @@ export function ToolCallDetailsContent({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
@@ -180,12 +189,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -203,12 +218,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -235,12 +256,18 @@ export function ToolCallDetailsContent({
style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -285,12 +312,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
</Text>
@@ -355,7 +388,7 @@ export function ToolCallDetailsContent({
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
style={[styles.jsonScroll, webScrollbarStyle]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
@@ -377,7 +410,7 @@ export function ToolCallDetailsContent({
<ScrollView
horizontal
nestedScrollEnabled
style={[styles.jsonScroll, styles.jsonScrollError]}
style={[styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
@@ -511,7 +544,7 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",

View File

@@ -37,8 +37,9 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
export type ComboboxOption = ComboboxOptionModel;
@@ -107,6 +108,7 @@ export interface SearchInputProps {
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
useBottomSheetInput?: boolean;
}
export function SearchInput({
@@ -115,10 +117,11 @@ export function SearchInput({
onChangeText,
onSubmitEditing,
autoFocus = false,
useBottomSheetInput = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
@@ -264,9 +267,8 @@ export function Combobox({
}: ComboboxProps): ReactElement {
const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions();
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []);
@@ -274,11 +276,13 @@ export function Combobox({
null,
);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
@@ -322,8 +326,8 @@ export function Combobox({
const middleware = useMemo(
() => [
floatingOffset(Platform.OS === "web" ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
floatingOffset(isWeb ? 5 : 4),
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
@@ -336,6 +340,9 @@ export function Combobox({
});
setReferenceWidth((prev) => {
const next = rects.reference.width;
if (!(next > 0)) {
return prev;
}
if (prev === next) return prev;
return next;
});
@@ -346,7 +353,7 @@ export function Combobox({
);
const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
placement: isWeb ? desktopPlacement : "bottom-start",
middleware,
sameScrollView: false,
elements: {
@@ -357,15 +364,18 @@ export function Combobox({
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null);
setDesktopContentWidth(null);
setReferenceLeft(null);
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]);
}, [desktopPlacement, isMobile, isOpen, update]);
useEffect(() => {
if (!isOpen || isMobile) {
setReferenceLeft(null);
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
@@ -379,9 +389,16 @@ export function Combobox({
}
const measure = () => {
referenceEl.measureInWindow((x, y) => {
referenceEl.measureInWindow((x, y, width, height) => {
setReferenceLeft((prev) => (prev === x ? prev : x));
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
setReferenceWidth((prev) => {
if (!(width > 0)) {
return prev;
}
return prev === width ? prev : width;
});
});
};
@@ -396,32 +413,46 @@ export function Combobox({
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
// For top-placed popups: once position resolves, use bottom-based CSS positioning
// so height changes grow upward naturally without floating-ui needing to reposition.
const useStableBottom =
const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
const useMeasuredTopStartPosition =
!isDesktopAboveSearch &&
IS_WEB &&
!isMobile &&
hasResolvedDesktopPosition &&
desktopPlacement.startsWith("top") &&
referenceTop !== null;
desktopPlacement === "top-start" &&
referenceTop !== null &&
referenceLeft !== null &&
desktopContentWidth !== null;
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
? Math.max(
collisionPadding,
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
)
: null;
const measuredTopStartBottom = useMeasuredTopStartPosition
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
referenceWidth > 0 &&
(isDesktopAboveSearch
? floatingLeft !== null && desktopAboveSearchBottom !== null
: useMeasuredTopStartPosition
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
: floatingLeft !== null &&
floatingTop !== null &&
(hasNonZeroFloatingPosition || !referenceAtOrigin));
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: useStableBottom
: useMeasuredTopStartPosition
? {
left: floatingLeft ?? 0,
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
left: clampedMeasuredTopStartLeft ?? 0,
bottom: measuredTopStartBottom ?? 0,
}
: floatingStyles;
@@ -626,6 +657,7 @@ export function Combobox({
onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
useBottomSheetInput={isMobile}
/>
);
@@ -725,7 +757,13 @@ export function Combobox({
]}
ref={refs.setFloating}
collapsable={false}
onLayout={() => update()}
onLayout={(event) => {
const { width, height } = event.nativeEvent.layout;
setDesktopContentWidth((prev) => (prev === width ? prev : width));
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
void update();
}
}}
>
{children ? (
<>
@@ -852,6 +890,7 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
flexShrink: 0,
},
comboboxItemDescription: {
fontSize: theme.fontSize.xs,

View File

@@ -32,6 +32,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -254,8 +256,7 @@ export function ContextMenuTrigger({
>): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform =
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
@@ -294,7 +295,7 @@ export function ContextMenuTrigger({
disabled={disabled}
delayLongPress={longPressDelayMs}
onLongPress={(event) => {
if (Platform.OS === "web") {
if (isWeb) {
props.onLongPress?.(event);
return;
}
@@ -303,7 +304,7 @@ export function ContextMenuTrigger({
}}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => {
if (Platform.OS !== "web") {
if (isNative) {
return;
}
const e: any = event;
@@ -348,6 +349,7 @@ export function ContextMenuContent({
testID?: string;
}>): ReactElement | null {
const context = useContextMenuContext("ContextMenuContent");
const webScrollbarStyle = useWebScrollbarStyle();
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
@@ -537,6 +539,7 @@ export function ContextMenuContent({
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
>
{children}

View File

@@ -28,6 +28,7 @@ import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -267,6 +268,7 @@ export function DropdownMenuContent({
}>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const webScrollbarStyle = useWebScrollbarStyle();
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -411,6 +413,7 @@ export function DropdownMenuContent({
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
>
{children}

View File

@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -108,18 +110,6 @@ function measureElement(element: View): Promise<Rect> {
});
}
function isMobileTooltipEnvironment(): boolean {
if (Platform.OS !== "web") {
return true;
}
if (typeof navigator === "undefined") {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
}
function computePosition({
triggerRect,
contentSize,
@@ -227,8 +217,8 @@ export function Tooltip({
onOpenChange,
});
const isMobile = isMobileTooltipEnvironment();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
const isCompact = useIsCompactFormFactor();
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
@@ -236,10 +226,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isMobile,
openOnPress: isCompact,
delayDuration,
}),
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -354,7 +344,7 @@ export function TooltipTrigger({
onFocus: handleFocus,
onBlur: handleBlur,
onPress: handlePress,
...(Platform.OS === "web"
...(isWeb
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
@@ -473,7 +463,7 @@ export function TooltipContent({
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (Platform.OS === "web") {
if (isWeb) {
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import {
Platform,
type FlatList,
type LayoutChangeEvent,
type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
useWebDesktopScrollbarMetrics,
type ScrollbarMetrics,
} from "./web-desktop-scrollbar";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
contentRef?: RefObject<HTMLElement | null>;
},
): ReactNode {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const enabled = (options?.enabled ?? true) && platformIsWeb;
const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
@@ -125,8 +124,7 @@ export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean },
): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const enabled = (options?.enabled ?? true) && platformIsWeb;
const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback(

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
Platform,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
@@ -12,6 +11,7 @@ import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
maxScrollOffset: 0,
});
const onScrollToOffsetRef = useRef(onScrollToOffset);
const isWeb = Platform.OS === "web";
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
const normalizedOffset = inverted
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
);
const panResponder = useMemo(() => {
if (isWeb) {
if (platformIsWeb) {
return null;
}
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
setIsDragging(false);
},
});
}, [applyDragDelta, isWeb]);
}, [applyDragDelta, platformIsWeb]);
const startWebDrag = useCallback(
(event: any) => {
if (!isWeb) {
if (!platformIsWeb) {
return;
}
const clientY = readClientY(event);
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
dragStartClientYRef.current = clientY;
setIsDragging(true);
},
[isWeb],
[platformIsWeb],
);
const handleGrabHoverIn = useCallback(() => {
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
}, []);
useEffect(() => {
if (!isWeb || !isDragging) {
if (!platformIsWeb || !isDragging) {
return;
}
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [applyDragDelta, isDragging, isWeb]);
}, [applyDragDelta, isDragging, platformIsWeb]);
if (!enabled || !geometry.isVisible) {
return null;
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
isWeb &&
platformIsWeb &&
({
cursor: handleCursor,
touchAction: "none",
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
]}
pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})}
{...(isWeb
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn,
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
backgroundColor: handleColor,
opacity: handleOpacity,
},
isWeb &&
platformIsWeb &&
({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
@@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -255,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router],
);
const actions: WelcomeAction[] =
Platform.OS === "web"
? [
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
]
: [
{
key: "scan-qr",
label: "Scan QR code",
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
},
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
];
const actions: WelcomeAction[] = isWeb
? [
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
]
: [
{
key: "scan-qr",
label: "Scan QR code",
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
},
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
@@ -321,7 +321,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
{!showHostList && Platform.OS !== "web" && (
{!showHostList && isNative && (
<>
<Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first.

View File

@@ -1,6 +1,5 @@
import { Platform } from "react-native";
import { useUnistyles } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75;
@@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in the Electron desktop runtime (any OS)
function isElectronDesktopRuntime(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntime();
}
// Check if running in the Electron desktop runtime on macOS
function isElectronDesktopRuntimeMac(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntimeMac();
}
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isElectronRuntimeMacCached: boolean | null = null;
let _isElectronRuntimeCached: boolean | null = null;
export function getIsElectronRuntimeMac(): boolean {
if (_isElectronRuntimeMacCached === true) {
return true;
}
const result = isElectronDesktopRuntimeMac();
if (result) {
_isElectronRuntimeMacCached = true;
}
return result;
}
export function getIsElectronRuntime(): boolean {
if (_isElectronRuntimeCached === true) {
return true;
}
const result = isElectronDesktopRuntime();
if (result) {
_isElectronRuntimeCached = true;
}
return result;
}
export {
getIsElectron as getIsElectronRuntime,
getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform";
/**
* Reactive hook — re-renders the component when the breakpoint changes.
@@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean {
// Keep that capability distinct from desktop-width layout so touch tablets
// can use the desktop shell without entering web-only code paths.
export function supportsDesktopPaneSplits(): boolean {
return Platform.OS === "web";
return isWeb;
}

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;
}

View File

@@ -1,6 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Buffer } from "buffer";
import { AppState, Platform } from "react-native";
import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
@@ -52,6 +52,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { isNative } from "@/constants/platform";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
(awayMs: number) => {
scheduleAuthoritativeRevalidation();
if (Platform.OS !== "web") {
if (isNative) {
const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
@@ -1155,7 +1156,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
removeWorkspace(serverId, message.payload.id);
return;
}
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
const existingWorkspace = useSessionStore
.getState()
.sessions[serverId]?.workspaces.get(workspace.id);
mergeWorkspaces(serverId, [workspace]);
});
const unsubStatus = client.on("status", (message) => {

View File

@@ -1,7 +1,12 @@
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import {
applyStreamEvent,
flushHeadToTail,
hydrateStreamState,
reduceStreamUpdate,
} from "@/types/stream";
import {
classifySessionTimelineSeq,
type SessionTimelineSeqDecision,
@@ -214,12 +219,24 @@ export function processTimelineResponse(
}
if (acceptedUnits.length > 0) {
// Flush head to tail before appending canonical entries so that
// chronological ordering is preserved. This matters on mobile where
// the server drops live events for backgrounded/unfocused agents:
// when the client catches up, the head may still hold stale live
// items from before the gap that must be committed ahead of the
// canonical gap-fill entries.
const baseTail =
currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail;
if (currentHead.length > 0) {
nextHead = [];
}
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
baseTail,
);
}

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host";
import { isWeb, isNative } from "@/constants/platform";
export type DesktopPermissionKind = "notifications" | "microphone";
@@ -45,7 +45,7 @@ type NavigatorLike = {
};
export function shouldShowDesktopPermissionSection(): boolean {
return Platform.OS === "web" && getDesktopHost() !== null;
return isWeb && getDesktopHost() !== null;
}
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
}
function getWebNotificationConstructor(): NotificationConstructorLike | null {
if (Platform.OS !== "web") {
if (isNative) {
return null;
}
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
}
function getNavigatorLike(): NavigatorLike | null {
if (Platform.OS !== "web") {
if (isNative) {
return null;
}
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
}
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") {
if (isNative) {
return status({
state: "unavailable",
detail: "Desktop notification status is only available on web runtime.",
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
}
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") {
if (isNative) {
return status({
state: "unavailable",
detail: "Desktop microphone status is only available on web runtime.",
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
}
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") {
if (isNative) {
return status({
state: "unavailable",
detail: "Desktop notification requests are only available on web runtime.",
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
}
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
if (Platform.OS !== "web") {
if (isNative) {
return status({
state: "unavailable",
detail: "Desktop microphone requests are only available on web runtime.",

View File

@@ -1,6 +1,6 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import { isWeb } from "@/constants/platform";
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean;
@@ -50,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === "web" && isElectronRuntime();
return isWeb && isElectronRuntime();
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, Platform } from "react-native";
import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client";
import {
shouldClearAgentAttention,
type AgentAttentionClearTrigger,
} from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -58,7 +59,7 @@ export function useAgentAttentionClear({
return;
}
deferredFocusEntryClearRef.current = false;
client.clearAgentAttention(resolvedAgentId);
client.clearAgentAttention(resolvedAgentId).catch(() => {});
},
[agentId, attentionReason, client, isConnected, requiresAttention],
);
@@ -70,7 +71,7 @@ export function useAgentAttentionClear({
const appStateSubscription = AppState.addEventListener("change", updateVisibility);
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);

View File

@@ -1,12 +1,117 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
id: "codex",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
id: "claude",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
function makeProviderMap(
...definitions: AgentProviderDefinition[]
): Map<AgentProvider, AgentProviderDefinition> {
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
}
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
describe("useAgentFormState", () => {
describe("buildProviderDefinitions", () => {
it("returns empty array when snapshot data is unavailable", () => {
expect(buildProviderDefinitions(undefined)).toEqual([]);
expect(buildProviderDefinitions([])).toEqual([]);
});
it("builds custom provider definitions from snapshot metadata", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "zai",
status: "ready",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
provider: "claude",
status: "ready",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
},
];
const definitions = buildProviderDefinitions(entries);
expect(definitions).toEqual([
{
id: "zai",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
id: "claude",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Always Ask",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
]);
});
});
describe("__private__.combineInitialValues", () => {
it("returns undefined when no initial values and no initial server id", () => {
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
@@ -78,6 +183,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -106,6 +212,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -134,6 +241,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.thinkingOptionId).toBe("xhigh");
@@ -161,6 +269,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -188,6 +297,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -215,6 +325,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -256,6 +367,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
claudeProviderMap,
);
expect(resolved.model).toBe("default");
@@ -263,11 +375,6 @@ describe("useAgentFormState", () => {
});
it("resolves provider only from allowed provider map", () => {
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map(
(definition) => [definition.id as AgentProvider, definition],
),
);
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
@@ -289,7 +396,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
allowedProviderMap,
claudeProviderMap,
);
expect(resolved.provider).toBe("claude");

View File

@@ -1,8 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentMode,
AgentModelDefinition,
@@ -10,6 +7,7 @@ import type {
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
@@ -99,13 +97,8 @@ type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>;
};
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
allProviderDefinitions.map((definition) => [definition.id, definition]),
);
const fallbackDefinition = allProviderDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
const DEFAULT_PROVIDER: AgentProvider = "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
@@ -180,7 +173,7 @@ function resolveFormState(
userModified: UserModifiedFields,
currentState: FormState,
validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
): FormState {
// Start with current state - we only update non-user-modified fields
const result = { ...currentState };
@@ -376,10 +369,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
} = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(() => {
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
}, [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(
() => buildProviderDefinitions(snapshotEntries),
[snapshotEntries],
);
const snapshotProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
@@ -388,17 +381,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotProviderDefinitions],
);
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
if (!snapshotEntries?.length) {
return snapshotProviderDefinitionMap;
}
const readyProviders = new Set(
(snapshotEntries ?? [])
.filter((entry) => entry.status === "ready")
.map((entry) => entry.provider),
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
);
return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]),
);
}, [snapshotEntries, snapshotProviderDefinitions]);
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) {

View File

@@ -1,5 +1,4 @@
import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import type { DaemonClient } from "@server/client/daemon-client";
import {
@@ -10,13 +9,14 @@ import {
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
import { isWeb } from "@/constants/platform";
const INIT_TIMEOUT_MS = 5 * 60_000;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0;
function resolveInitialTimelineLimit(): number {
return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
}
export const __private__ = {

View File

@@ -1,6 +1,7 @@
import { useEffect, useSyncExternalStore } from "react";
import { AppState, Platform } from "react-native";
import { AppState } from "react-native";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
let current = getIsAppActivelyVisible();
const listeners = new Set<() => void>();
@@ -29,7 +30,7 @@ export function useAppVisible(): boolean {
useEffect(() => {
const appStateSubscription = AppState.addEventListener("change", notify);
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", notify);
window.addEventListener("focus", notify);
window.addEventListener("blur", notify);
@@ -37,7 +38,7 @@ export function useAppVisible(): boolean {
return () => {
appStateSubscription.remove();
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", notify);
window.removeEventListener("focus", notify);
window.removeEventListener("blur", notify);

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback } from "react";
import { AppState, Platform } from "react-native";
import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
@@ -32,7 +33,7 @@ export function useClientActivity({
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(0);
const deviceType = Platform.OS === "web" ? "web" : "mobile";
const deviceType = isWeb ? "web" : "mobile";
const recordUserActivity = useCallback(() => {
lastActivityAtRef.current = new Date();
@@ -96,7 +97,7 @@ export function useClientActivity({
// Track user activity on web for accurate staleness.
useEffect(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
if (typeof document === "undefined") return;
const handleUserActivity = () => {

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
@@ -9,6 +8,7 @@ import {
deriveMacDockBadgeCountFromWorkspaceStatuses,
type DesktopBadgeWorkspaceStatus,
} from "@/utils/desktop-badge-state";
import { isNative } from "@/constants/platform";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
@@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
}
function getSystemColorScheme(): ColorScheme {
if (
Platform.OS !== "web" ||
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
if (isNative || !getIsElectronRuntimeMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
@@ -119,7 +115,7 @@ export function useFaviconStatus() {
// Listen for system color scheme changes
useEffect(() => {
if (Platform.OS !== "web" || typeof window === "undefined") return;
if (isNative || typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
@@ -132,7 +128,7 @@ export function useFaviconStatus() {
// Update favicon when agents or color scheme changes
useEffect(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);

View File

@@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { isWeb } from "@/constants/platform";
interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void;
@@ -14,7 +14,7 @@ interface UseFileDropZoneReturn {
containerRef: React.RefObject<HTMLElement | null>;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",

View File

@@ -82,9 +82,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
);
const requestDirectoryListing = useCallback(
async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => {
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<boolean> => {
if (!workspaceStateKey) {
return;
return false;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
@@ -113,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: "Workspace is unavailable",
pendingRequest: null,
}));
return;
return false;
}
if (!client) {
@@ -123,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: "Host is not connected",
pendingRequest: null,
}));
return;
return false;
}
try {
@@ -150,6 +153,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return nextState;
});
return true;
} catch (error) {
updateExplorerState((state) => ({
...state,
@@ -157,6 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: error instanceof Error ? error.message : "Failed to list directory",
pendingRequest: null,
}));
return false;
}
},
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],

View File

@@ -8,6 +8,7 @@ import {
openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker";
import { isWeb } from "@/constants/platform";
interface UseImageAttachmentPickerResult {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
@@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isElectronRuntime()) {
if (isWeb && isElectronRuntime()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;

View File

@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildHostSettingsRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname,
@@ -26,6 +26,8 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
export function useKeyboardShortcuts({
enabled,
@@ -47,6 +49,7 @@ export function useKeyboardShortcuts({
cycleTheme?: () => void;
}) {
const pathname = usePathname();
const router = useRouter();
const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
@@ -65,7 +68,7 @@ export function useKeyboardShortcuts({
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isNative) return;
if (isMobile) return;
const isDesktopApp = getIsElectronRuntime();
@@ -244,6 +247,16 @@ export function useKeyboardShortcuts({
case "sidebar.toggle.left":
toggleAgentList();
return true;
case "settings.toggle":
if (pathname.endsWith("/settings")) {
router.back();
return true;
}
if (!activeServerId) {
return false;
}
router.push(buildHostSettingsRoute(activeServerId));
return true;
case "sidebar.toggle.both":
if (toggleBothSidebars) {
toggleBothSidebars();
@@ -311,6 +324,12 @@ export function useKeyboardShortcuts({
return;
}
// During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) {
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
@@ -14,9 +14,10 @@ interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean;
isFetching: boolean;
isRefreshing: boolean;
error: string | null;
supportsSnapshot: boolean;
refresh: () => void;
refresh: (providers?: AgentProvider[]) => Promise<void>;
invalidate: () => void;
}
@@ -43,6 +44,16 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
},
});
const refreshMutation = useMutation({
mutationFn: async (providers?: AgentProvider[]) => {
if (!client) {
return;
}
await client.refreshProvidersSnapshot({ providers });
},
});
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
useEffect(() => {
if (!supportsSnapshot || !client || !isConnected || !serverId) {
return;
@@ -60,12 +71,12 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
});
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
const refresh = useCallback(() => {
if (!client) {
return;
}
void client.refreshProvidersSnapshot();
}, [client]);
const refresh = useCallback(
async (providers?: AgentProvider[]) => {
await refreshSnapshot(providers);
},
[refreshSnapshot],
);
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
@@ -75,6 +86,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading,
isFetching: snapshotQuery.isFetching,
isRefreshing,
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot,
refresh,

View File

@@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb } from "@/constants/platform";
const STORAGE_PREFIX = "@paseo:expo-push-token:";
@@ -31,7 +32,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
const lastSentTokenRef = useRef<string | null>(null);
const registerIfPossible = useCallback(async () => {
if (Platform.OS === "web") return;
if (isWeb) return;
if (!client.isConnected) return;
const token = tokenRef.current;
if (!token) return;
@@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
}, [client]);
useEffect(() => {
if (Platform.OS === "web") return;
if (isWeb) return;
const storageKey = `${STORAGE_PREFIX}${serverId}`;
let cancelled = false;

View File

@@ -284,6 +284,12 @@ export function useSidebarWorkspacesList(options?: {
});
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
useEffect(() => {
if (!serverId) {
return;
}
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
useEffect(() => {
if (!serverId || projects.length === 0) {
return;
@@ -349,7 +355,12 @@ export function useSidebarWorkspacesList(options?: {
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch {
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
cursor,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();

View File

@@ -0,0 +1 @@
export * from "./use-web-scrollbar-style.web";

View File

@@ -0,0 +1,5 @@
import type { ViewStyle } from "react-native";
export function useWebScrollbarStyle(): ViewStyle | undefined {
return undefined;
}

View File

@@ -0,0 +1,21 @@
import { useMemo } from "react";
import type { ViewStyle } from "react-native";
import { useUnistyles } from "react-native-unistyles";
// CSS scrollbar properties are supported by React Native Web at runtime
// but are not included in React Native's ViewStyle type definition.
interface WebScrollbarStyle extends ViewStyle {
scrollbarColor: string;
scrollbarWidth: string;
}
export function useWebScrollbarStyle(): WebScrollbarStyle {
const { theme } = useUnistyles();
return useMemo(
(): WebScrollbarStyle => ({
scrollbarColor: `${theme.colors.scrollbarHandle} transparent`,
scrollbarWidth: "thin",
}),
[theme.colors.scrollbarHandle],
);
}

View File

@@ -37,6 +37,7 @@ export type KeyboardActionId =
| "sidebar.toggle.left"
| "sidebar.toggle.right"
| "sidebar.toggle.both"
| "settings.toggle"
| "command-center.toggle"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"

View File

@@ -712,6 +712,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Settings toggle ---
{
id: "settings-toggle-cmd-comma-mac",
action: "settings.toggle",
combo: "Cmd+,",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-settings",
section: "panels",
label: "Toggle settings",
keys: ["mod", ","],
},
},
{
id: "settings-toggle-ctrl-comma-non-mac",
action: "settings.toggle",
combo: "Ctrl+,",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-settings",
section: "panels",
label: "Toggle settings",
keys: ["mod", ","],
},
},
// --- Focus mode ---
{
id: "view-toggle-focus-cmd-shift-f-mac",
@@ -765,6 +791,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Message input ---
{
id: "message-input-focus-cmd-l-mac",
action: "message-input.action",
combo: "Cmd+L",
when: { mac: true, commandCenter: false },
payload: { type: "message-input", kind: "focus" },
help: {
id: "focus-message-input",
section: "agent-input",
label: "Focus message input",
keys: ["mod", "L"],
},
},
{
id: "message-input-focus-ctrl-l-non-mac",
action: "message-input.action",
combo: "Ctrl+L",
when: { mac: false, commandCenter: false, terminal: false },
payload: { type: "message-input", kind: "focus" },
help: {
id: "focus-message-input",
section: "agent-input",
label: "Focus message input",
keys: ["mod", "L"],
},
},
{
id: "message-input-voice-toggle-cmd-shift-d-mac",
action: "message-input.action",

View File

@@ -31,6 +31,7 @@ KEY_MAP["Digit"] = { code: "Digit" };
KEY_MAP["\\"] = { code: "Backslash" };
KEY_MAP["["] = { code: "BracketLeft", key: "[" };
KEY_MAP["]"] = { code: "BracketRight", key: "]" };
KEY_MAP[","] = { code: "Comma", key: "," };
KEY_MAP["."] = { code: "Period", key: "." };
KEY_MAP["`"] = { code: "Backquote", key: "`" };
KEY_MAP["/"] = { code: "Slash" };

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
@@ -47,6 +47,7 @@ import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { isNative } from "@/constants/platform";
function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) {
@@ -595,7 +596,7 @@ function AgentPanelBody({
if (!isConnected || !hasSession) {
return;
}
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web";
const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
if (!shouldSyncOnEntry) {
return;
}
@@ -761,7 +762,7 @@ function AgentPanelBody({
<AgentInputArea
agentId={agentId}
serverId={serverId}
isInputActive={isPaneFocused}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
images={agentInputDraft.images}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createNameId } from "mnemonic-id";
import type { ImageAttachment } from "@/components/message-input";
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native";
import { View, Text, Pressable, ScrollView, Keyboard } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -49,7 +49,6 @@ import type {
AgentCapabilityFlags,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -57,6 +56,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -67,10 +67,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsReasoningStream: false,
supportsToolInvocations: false,
};
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
function getParamValue(value: string | string[] | undefined) {
if (typeof value === "string") {
const trimmed = value.trim();
@@ -91,16 +87,14 @@ function getValidProvider(value: string | undefined) {
if (!value) {
return undefined;
}
return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined;
return value as AgentProvider;
}
function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
if (!provider || !value) {
return undefined;
}
const definition = PROVIDER_DEFINITION_MAP.get(provider);
const modes = definition?.modes ?? [];
return modes.some((mode) => mode.id === value) ? value : undefined;
return value;
}
type DraftAgentParams = {
@@ -850,7 +844,7 @@ function DraftAgentScreenContent({
},
onBeforeSubmit: () => {
void persistFormPreferences();
if (Platform.OS === "web") {
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
@@ -1238,7 +1232,7 @@ function DraftAgentScreenContent({
<AgentInputArea
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}
isInputActive={isFocused}
isPaneFocused={isFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject, ComponentType } from "react";
import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native";
import { View, Text, ScrollView, Alert, Pressable } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -68,10 +68,12 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useIsCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
import { StatusBadge } from "@/components/ui/status-badge";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { isWeb } from "@/constants/platform";
// ---------------------------------------------------------------------------
// Section definitions
@@ -520,8 +522,11 @@ interface ProvidersSectionProps {
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(routeServerId);
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
const providerDefinitions = buildProviderDefinitions(entries);
const providerRefreshInFlight =
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = routeServerId.length > 0;
@@ -532,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
{hasServer && isConnected ? (
<Pressable
onPress={refresh}
disabled={isFetching}
style={[settingsStyles.sectionHeaderLink, isFetching ? { opacity: 0.5 } : null]}
onPress={() => {
void refresh();
}}
disabled={providerRefreshInFlight}
hitSlop={8}
style={({ hovered, pressed }) => [
settingsStyles.sectionHeaderLink,
styles.providerRefreshButton,
(hovered || pressed) && styles.providerRefreshButtonHovered,
providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null,
]}
accessibilityRole="button"
accessibilityLabel="Refresh providers"
>
<Text
style={{
color: theme.colors.primary,
fontSize: theme.fontSize.xs,
}}
>
Refresh
</Text>
<SpinningRefreshIcon
spinning={providerRefreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
) : null}
</View>
@@ -557,7 +569,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
</View>
) : (
<View style={[settingsStyles.card, styles.audioCard]}>
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
{providerDefinitions.map((def) => {
const entry = entries?.find((e) => e.provider === def.id);
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
@@ -568,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
? entry.error.trim()
: null;
const modelCount = entry?.models?.length ?? 0;
return (
<View key={def.id} style={styles.audioRow}>
<Pressable
key={def.id}
style={styles.audioRow}
onPress={() => setDiagnosticProvider(def.id)}
accessibilityRole="button"
>
<View style={styles.audioRowContent}>
<View
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
@@ -582,31 +601,27 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
{providerError}
</Text>
) : null}
{status === "ready" && modelCount > 0 ? (
<Text style={styles.audioRowSubtitle}>
{modelCount === 1 ? "1 model" : `${modelCount} models`}
</Text>
) : null}
</View>
<View style={styles.providerActions}>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
<Button
variant="secondary"
size="sm"
onPress={() => setDiagnosticProvider(def.id)}
>
Diagnostic
</Button>
</View>
</View>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
</Pressable>
);
})}
</View>
@@ -1672,22 +1687,20 @@ function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) {
<View style={styles.hostHeaderRight}>
<View
style={[
Platform.OS === "web" ? styles.statusPill : styles.statusPillMobile,
isWeb ? styles.statusPill : styles.statusPillMobile,
{ backgroundColor: statusPillBg },
]}
>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
{Platform.OS === "web" ? (
{isWeb ? (
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
) : null}
</View>
{connectionBadge ? (
<View
style={Platform.OS === "web" ? styles.connectionPill : styles.connectionPillMobile}
>
<View style={isWeb ? styles.connectionPill : styles.connectionPillMobile}>
{connectionBadge.icon}
{Platform.OS === "web" ? (
{isWeb ? (
<Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text}
</Text>
@@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({
formButtonPrimaryText: {
color: theme.colors.palette.white,
},
providerRefreshButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
justifyContent: "center",
},
providerRefreshButtonHovered: {
backgroundColor: theme.colors.surface2,
},
providerRefreshButtonDisabled: {
opacity: 0.5,
},
// Audio settings card
audioCard: {
overflow: "hidden",
@@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
providerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { View, Text, Platform } from "react-native";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
@@ -20,6 +20,7 @@ import {
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { isNative } from "@/constants/platform";
function ShortcutSequence({
chord,
@@ -138,7 +139,7 @@ export function KeyboardShortcutsSection() {
}
useEffect(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
if (capturingBindingId === null) return;
function handleKeyDown(event: KeyboardEvent) {
@@ -173,7 +174,7 @@ export function KeyboardShortcutsSection() {
};
}, [setCapturingShortcut]);
if (Platform.OS !== "web") {
if (isNative) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
@@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
type StartupSplashScreenProps = {
bootstrapState?: {
@@ -42,7 +44,7 @@ const styles = StyleSheet.create((theme) => ({
},
errorScrollView: {
flex: 1,
...(Platform.OS === "web"
...(isWeb
? {
overflowX: "auto",
overflowY: "auto",
@@ -144,7 +146,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
@@ -160,6 +162,7 @@ const styles = StyleSheet.create((theme) => ({
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
@@ -281,7 +284,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.errorScreen}>
<TitlebarDragRegion />
<ScrollView
style={styles.errorScrollView}
style={[styles.errorScrollView, webScrollbarStyle]}
contentContainerStyle={styles.errorScrollContent}
showsVerticalScrollIndicator
>
@@ -302,7 +305,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
style={[styles.logsScroll, webScrollbarStyle]}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>

View File

@@ -9,7 +9,6 @@ import {
} from "react";
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
Text,
@@ -30,6 +29,7 @@ import {
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import { isNative, isWeb } from "@/constants/platform";
import {
ContextMenu,
ContextMenuContent,
@@ -113,6 +113,7 @@ function useMiddleClickClose(onClose: () => void) {
const ref = useRef<View>(null);
useEffect(() => {
if (isNative) return;
const node = ref.current as unknown as HTMLElement | null;
if (!node) return;
@@ -174,17 +175,16 @@ function TabChip({
);
const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered;
const closeButtonDragBlockers =
Platform.OS === "web"
? ({
onPointerDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
onMouseDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
} as const)
: undefined;
const closeButtonDragBlockers = isWeb
? ({
onPointerDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
onMouseDown: (event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
},
} as const)
: undefined;
return (
<View ref={middleClickRef}>
@@ -199,7 +199,7 @@ function TabChip({
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
isWeb && isDragging && ({ cursor: "grabbing" } as const),
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
import { Keyboard, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AgentInputArea } from "@/components/agent-input-area";
import { FileDropZone } from "@/components/file-drop-zone";
@@ -19,6 +19,7 @@ import type {
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
import { isWeb } from "@/constants/platform";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -155,7 +156,7 @@ export function WorkspaceDraftAgentTab({
},
onBeforeSubmit: () => {
void persistFormPreferences();
if (Platform.OS === "web") {
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
@@ -341,7 +342,7 @@ export function WorkspaceDraftAgentTab({
<AgentInputArea
agentId={tabId}
serverId={serverId}
isInputActive={isPaneFocused}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -15,6 +15,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
@@ -29,10 +30,7 @@ export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEd
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors =
Platform.OS === "web" &&
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],

View File

@@ -1,15 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import {
ActivityIndicator,
BackHandler,
Keyboard,
Platform,
Pressable,
Text,
View,
} from "react-native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import {
@@ -118,6 +110,7 @@ import {
} from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -148,6 +141,18 @@ function decodeSegment(value: string): string {
}
}
function areHeaderLabelsEquivalent(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
if (!normalizedA || !normalizedB) {
return false;
}
return normalizedA === normalizedB;
}
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
@@ -252,7 +257,7 @@ function WorkspaceDocumentTitleEffect({
titleState: "ready" | "loading";
}) {
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined") {
if (isNative || typeof document === "undefined") {
return;
}
const resolvedLabel = label.trim();
@@ -726,12 +731,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
};
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
const isCheckoutQueryEnabled =
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
isAbsolutePath(normalizedWorkspaceId);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
isAbsolutePath(normalizedWorkspaceId),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -740,6 +746,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
},
staleTime: 15_000,
});
const isCheckoutStatusLoading =
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
const workspaceDescriptor = useSessionStore(
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
@@ -753,7 +761,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const workspaceHeader = workspaceDescriptor
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null;
const isWorkspaceHeaderLoading = workspaceHeader === null;
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
workspaceHeaderTitle,
workspaceHeaderSubtitle,
);
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
const currentBranchName =
@@ -810,7 +824,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
});
useEffect(() => {
if (Platform.OS === "web" || !isExplorerOpen) {
if (isWeb || !isExplorerOpen) {
return;
}
@@ -1291,7 +1305,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
});
}
void archiveAgent({ serverId: normalizedServerId, agentId });
// Errors (e.g. timeout) are handled by the mutation's onSettled callback
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
});
},
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
@@ -1708,7 +1723,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
if (isNative || typeof document === "undefined" || activeTabDescriptor) {
return;
}
document.title = "Workspace";
@@ -1932,7 +1947,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? (
{isWeb && activeTabDescriptor ? (
<WorkspaceTabPresentationResolver
tab={activeTabDescriptor}
serverId={normalizedServerId}
@@ -1955,27 +1970,29 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
{isWorkspaceHeaderLoading ? (
<>
<View style={styles.headerTitleTextGroup}>
<View style={styles.headerTitleSkeleton} />
<View style={styles.headerProjectTitleSkeleton} />
</>
</View>
) : (
<>
<View style={styles.headerTitleTextGroup}>
<BranchSwitcher
currentBranchName={currentBranchName}
title={workspaceHeader.title}
title={workspaceHeaderTitle}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isGitCheckout={isGitCheckout}
/>
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{workspaceHeader.subtitle}
</Text>
</>
{shouldShowWorkspaceHeaderSubtitle ? (
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{workspaceHeaderSubtitle}
</Text>
) : null}
</View>
)}
<DropdownMenu>
<DropdownMenuTrigger
@@ -2297,15 +2314,40 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
},
headerTitleContainer: {
flex: 1,
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerTitleTextGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: {
xs: 1,
md: 0,
},
flexDirection: {
xs: "column",
md: "row",
},
alignItems: {
xs: "flex-start",
md: "center",
},
justifyContent: "flex-start",
gap: {
xs: 0,
md: theme.spacing[2],
},
},
headerProjectTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
flexShrink: 1,
},
headerTitleSkeleton: {

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