Compare commits

...

156 Commits

Author SHA1 Message Date
Mohamed Boudra
c4cccf5bd2 chore(release): cut 0.1.48 2026-04-05 15:40:54 +07:00
Mohamed Boudra
0130a637c8 docs: add 0.1.48 changelog 2026-04-05 15:39:55 +07:00
Mohamed Boudra
8c2ea33da8 fix(app): restore input focus for running agents and align mobile model selector
- Add onDropdownClose callback to AgentStatusBar and wire through ControlledStatusBar
  to CombinedModelSelector so running agents focus input after any dropdown closes
- Fix mobile misalignment in model selector by adding marginHorizontal: spacing[1]
  to sectionHeading, drillDownRow, backButton, and providerSearchContainer to match
  ComboboxItem's mobile margins
2026-04-05 15:38:22 +07:00
Mohamed Boudra
0d88ce2270 feat(app): provider system overhaul with model selector, diagnostics, and UX polish
- Add CombinedModelSelector with two-level drill-down (providers → models),
  favorites section, search, and single-provider auto-drill mode
- Add provider snapshot system (ProviderSnapshotManager) for cached provider/model state
- Add provider diagnostics with resolved binary paths and versions across all providers
- Add StatusBadge shared component for consistent status visual language
- Add ProviderDiagnosticSheet for detailed provider health inspection
- Add desktop auto-focus on input after any status bar interaction (model, mode,
  thinking, features) using focusWithRetries
- Fix Combobox flicker on height change by switching to bottom-based CSS positioning
  once initial floating-ui position resolves
- Add dynamic dropdown height: Level 1 fits content, Level 2 calculates from model count
- Remove "Other providers" section from model selector (only show available providers)
2026-04-05 15:32:13 +07:00
Mohamed Boudra
6907f6e71d fix(desktop): resolve login shell environment at Electron startup
On macOS, apps launched from Finder/Dock inherit a minimal environment
(PATH is just /usr/bin:/bin:/usr/sbin:/sbin). This caused two problems:
1. Agent binaries like codex were not detected (findExecutable failed)
2. Terminals spawned by Paseo had no access to user-installed tools
   (node, bun, direnv — all "command not found")

Fix: at Electron startup, spawn the user's login shell and capture its
full environment via JSON.stringify(process.env) using UUID markers.
This is the same battle-tested approach VS Code uses. The resolved
environment is merged into process.env before the daemon starts, so
all child processes — agents, terminals, git operations — inherit the
correct environment automatically.

This replaces the previous approach of invoking resolveShellEnv() (via
the shell-env npm package) at multiple scattered call sites. Now there
is a single source of truth: process.env is enriched once at startup.

Changes:
- New: packages/desktop/src/login-shell-env.ts (VS Code approach)
- Removed: resolveShellEnv(), shell-env dependency, $SHELL -lic probes
- Simplified: applyProviderEnv() and findExecutable() now trust process.env
2026-04-05 15:11:18 +07:00
Mohamed Boudra
84638ecd9c feat(app): submit question card answer on Enter key press 2026-04-05 14:54:03 +07:00
Mohamed Boudra
809e5fbbdc feat(app): Enter key confirms and sends dictation
When dictating and focused on an agent, pressing Enter now confirms
the dictation and sends the message, matching the behavior of clicking
the submit button.
2026-04-05 14:53:10 +07:00
Mohamed Boudra
a264058a30 Remove noisy agent toasts and debug logging
Remove the "Refreshing" and "Failed to refresh agent" toasts from the
agent panel — they fire frequently but are meaningless since sync
recovers transparently. The "Reconnecting..." toast for host disconnect
is preserved.

Strip debug console.log calls across the app: render tracking in
message/stream views, dependency change tracking in workspace screen,
terminal tab slot logging, and verbose audio engine/voice runtime
bridge stats logging. Legitimate console.error/warn in catch blocks
are kept.
2026-04-05 11:10:49 +07:00
Mohamed Boudra
702e2e7db9 Handle Codex request_user_input app-server questions
Fixes #195
2026-04-05 09:21:45 +07:00
Mohamed Boudra
c32bc847bf feat(app): restore reload action for agent tabs 2026-04-05 09:21:45 +07:00
github-actions[bot]
7a2d976081 fix: update lockfile signatures and Nix hash 2026-04-04 17:59:55 +00:00
Mohamed Boudra
2d9ca6983a chore(release): cut 0.1.47 2026-04-05 00:58:22 +07:00
Mohamed Boudra
e44e495481 Update CHANGELOG.md for 0.1.47 stable release 2026-04-05 00:57:03 +07:00
Mohamed Boudra
bedf792616 fix(voice): harden Electron speak TTS path 2026-04-05 00:53:50 +07:00
Mohamed Boudra
5db9942125 fix(desktop): restore daemon QR pairing output 2026-04-05 00:53:46 +07:00
Mohamed Boudra
a889dbcc28 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-05 00:14:41 +07:00
Mohamed Boudra
5c1e869bc5 fix: copy sherpa TTS samples to avoid external buffer crash
Same fix as the Silero VAD — sherpa-onnx returns Float32Arrays backed
by native memory which Node.js rejects as "External buffers are not
allowed". Copy into JS-managed memory before passing to audio pipeline.
2026-04-04 23:45:28 +07:00
Mohamed Boudra
a693ff560c fix: remove per-host "Add connection" button that blocked multi-host setups
Users connecting multiple daemons (e.g. local + remote via Tailscale) hit
a "belongs to X, not Y" error because the edit-host modal's Add Connection
button scoped new connections to the current host's server ID. Remove that
button and its supporting state so all connections go through the top-level
flow which has no server ID constraint.
2026-04-04 23:45:03 +07:00
github-actions[bot]
89baf7ff38 fix: update lockfile signatures and Nix hash 2026-04-04 16:00:01 +00:00
Mohamed Boudra
c65a851205 chore(release): cut 0.1.46 2026-04-04 22:57:53 +07:00
Mohamed Boudra
85acdbb05e Update CHANGELOG.md for 0.1.46 stable release 2026-04-04 22:57:45 +07:00
Mohamed Boudra
4e32f9b7bd fix: copy Silero VAD model out of asar so native code can read it
The bundled silero_vad.onnx lives inside Electron's app.asar which
native C++ (sherpa-onnx) cannot open via OS file I/O. On first voice
activation, copy the model to ~/.paseo/models/local-speech/silero-vad/
using Node.js fs (asar-aware) and point the native code there.
2026-04-04 22:11:54 +07:00
Mohamed Boudra
744ca7a2bc fix: send appVersion in probe client hello and update it on session resume
buildClientConfig in test-daemon-connection.ts never set appVersion, so
probe clients (which become the live client) sent hello without it. The
daemon's version gate then hid Pi/Copilot from all these sessions.

Also update appVersion on the Session when a client reconnects with a
newer version, so stale sessions don't stay gated forever.
2026-04-04 21:12:34 +07:00
Mohamed Boudra
3110bae209 fix(website): stack header vertically on mobile to prevent overflow 2026-04-04 21:06:35 +07:00
Mohamed Boudra
16efdb2c95 feat(website): add Copilot and Pi to homepage provider grid 2026-04-04 20:29:44 +07:00
Mohamed Boudra
1e9b7f1157 fix: make worktreeRoot backward-compatible for old clients/daemons
worktreeRoot was added as a required field in 9154f8fc, which breaks
old clients parsing new daemon responses and new clients parsing old
daemon responses. Made it .optional() with .transform() fallbacks.

Also added a critical rule to CLAUDE.md: schema changes must always
be backward-compatible in both directions.
2026-04-04 20:29:44 +07:00
Mohamed Boudra
018ebd5f29 Fix punycode deprecation warning in CLI and daemon entrypoints 2026-04-04 20:29:44 +07:00
github-actions[bot]
4706d9e3bd fix: update lockfile signatures and Nix hash 2026-04-04 12:22:20 +00:00
Mohamed Boudra
ba0c4a3fff chore(release): cut 0.1.45 2026-04-04 19:21:12 +07:00
Mohamed Boudra
2a8da5d4c1 Update CHANGELOG.md for 0.1.45 stable release 2026-04-04 19:21:12 +07:00
Mohamed Boudra
43542ac858 Fix CLI routing: single classifier, open-project for cold & hot start
- Add classify.ts as the single source of truth for CLI invocation
  routing (discriminated union, derives known commands from Commander)
- Remove all hardcoded command lists and duplicate path detection logic
- Simplify shell wrappers to dumb pipes (zero classification)
- Fix hot-start: use `open -n -g -a` (VS Code pattern) so second-instance
  event fires when app is already running
- Fix cold-start race: pull-based IPC (getPendingOpenProject) so renderer
  fetches the pending path after React mounts, instead of push event that
  arrived before the listener existed
- Fix asar read corruption: unpack node-entrypoint-runner.js from asar
  (Node.js v24 in Electron 41 can't parse package.json inside asar)
- Strip ELECTRON_RUN_AS_NODE from env when spawning desktop app from CLI
2026-04-04 19:21:12 +07:00
Mohamed Boudra
fefe260f0a Fix Silero VAD crash: disable external buffers in CircularBuffer.get()
Newer sherpa-onnx-node rejects external buffers by default, causing
"External buffers are not allowed" on every voice audio chunk.
2026-04-04 19:21:12 +07:00
Mohamed Boudra
a17f7d2d20 Fix forward-compatible provider handling for old app clients
AgentProviderSchema was z.enum() which caused old clients to reject
session messages containing unknown providers (pi, copilot), breaking
the entire session. Changed to z.string() for future clients.

For currently deployed clients (<0.1.45), the daemon now filters out
unknown providers based on the appVersion sent in the WebSocket hello
message. Clients that don't send appVersion only see claude/codex/opencode.
2026-04-04 19:21:12 +07:00
Mohamed Boudra
c5a69a1ad9 Update skill CLI examples to use --provider provider/model format
Always specify the model in paseo run examples (e.g. --provider codex/gpt-5.4
instead of --provider codex) to prevent agents from launching with wrong defaults.
2026-04-04 19:21:12 +07:00
github-actions[bot]
155c88254b fix: update lockfile signatures and Nix hash 2026-04-04 09:37:40 +00:00
Mohamed Boudra
22c83118d0 chore(release): cut 0.1.45-rc.4 2026-04-04 16:36:26 +07:00
Mohamed Boudra
7e99529bde Add Codex plan mode draft features 2026-04-04 16:35:31 +07:00
Mohamed Boudra
a6f6c169c8 Fix Pi thinking mode state mapping 2026-04-04 16:35:31 +07:00
Mohamed Boudra
d69fcb861d Fix CLI arg routing and desktop install links 2026-04-04 16:35:31 +07:00
github-actions[bot]
c3313ff82a fix: update lockfile signatures and Nix hash 2026-04-04 07:57:09 +00:00
Mohamed Boudra
1182092b94 chore(release): cut 0.1.45-rc.3 2026-04-04 14:55:59 +07:00
Mohamed Boudra
8d1fb45f73 fix(server): correct isCommandAvailable import path in pi-acp-agent 2026-04-04 14:50:40 +07:00
Mohamed Boudra
8f7de021f4 fix(app): move OpenProjectListener inside ToastProvider
OpenProjectListener uses useOpenProject which calls useToast, but it was
rendered in ProvidersWrapper above ToastProvider in the component tree.
2026-04-04 14:48:47 +07:00
Mohamed Boudra
65573499af feat(server): add Pi agent provider and re-enable Copilot (#191)
* fix(app): reorder settings sections for better grouping

* feat(app): add setup hint and paseo.sh link on mobile welcome screen

New app store users land on the welcome screen with no context. Show a
brief explanation that the desktop app or server is needed, plus a link
to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop.

* docs(release): add pre-release sanity check and clarify changelog scope

Add a Codex 5.4 review step before cutting releases to catch breaking
changes and backward-compatibility issues (mobile apps lag behind
desktop/daemon updates). Clarify that the changelog always covers the
delta from the previous stable release, not from the last RC.

* feat(server): add Pi agent provider and re-enable Copilot

Add Pi (pi.dev) as a new ACP-based agent provider with bundled pi-acp
adapter. Pi-acp reports thinking levels as ACP modes and bash tool calls
with kind "other", so the Pi provider uses generic ACP extension hooks
to remap these to the correct Paseo concepts:

- sessionResponseTransformer: remaps ACP modes → configOptions[thought_level]
- thinkingOptionWriter: writes thinking via setSessionMode instead of
  set_config_option (which pi-acp doesn't support)
- toolSnapshotTransformer: remaps bash tool kind from "other" to "execute"
- modelTransformer: cleans slash-prefixed model labels

Also re-enables the Copilot provider (disabled since 44da0c67) and
removes the claude-acp provider.

* fix: update lockfile signatures and Nix hash

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-04 15:42:24 +08:00
Mohamed Boudra
9154f8fc4d fix(server): deduplicate workspaces by git worktree root (#190)
* fix(server): deduplicate workspaces by resolving to git worktree root

Agents running in subdirectories of the same git repo were creating
separate workspace entries in the sidebar. Now workspace IDs resolve
to the git worktree root (via the already-computed `worktreeRoot` from
`inspectCheckoutContext`), so all agents in the same checkout share a
single workspace. Stale subdirectory workspace records are archived
during reconciliation.

* fix(server): fix 3 broken test files in server unit suite

- relay-reconnect: move speech mock to correct constructor position and
  rename getSpeechReadiness→getReadiness after SpeechService refactor
- commands-poc: add credential/CLI availability guards matching
  claude-agent.integration.test.ts pattern
- claude-sdk-behavior: pass pathToClaudeCodeExecutable via findExecutable
  and add credential guards matching real provider configuration

* fix(server): fix race condition in loop-service test mock

Replace setTimeout(..., 0) with queueMicrotask() in ScriptedAgentSession
so turn_completed events fire before the verify check runs. Fixes flaky
CI failure where the file write hadn't completed before verification.

* fix(server): use /bin/sh for loop verify checks instead of /bin/zsh

More portable across CI environments. Also use queueMicrotask in test
mock to fix race condition between event emission and waiter setup.

* fix(server): correct import paths for isCommandAvailable and findExecutable

Import from utils/executable.js (where they're exported) instead of
provider-launch-config.js (which only imports them internally).
2026-04-04 15:35:41 +08:00
Mohamed Boudra
3d3e327378 feat(cli): support paseo . to open desktop app with project (#189)
* fix(app): reorder settings sections for better grouping

* feat(app): add setup hint and paseo.sh link on mobile welcome screen

New app store users land on the welcome screen with no context. Show a
brief explanation that the desktop app or server is needed, plus a link
to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop.

* docs(release): add pre-release sanity check and clarify changelog scope

Add a Codex 5.4 review step before cutting releases to catch breaking
changes and backward-compatibility issues (mobile apps lag behind
desktop/daemon updates). Clarify that the changelog always covers the
delta from the previous stable release, not from the last RC.

* feat(cli): support `paseo .` to open desktop app with project directory

Similar to VS Code's `code .`, users can now type `paseo .` or
`paseo <path>` to open the Paseo desktop app with that directory as
the active project.

- Desktop shims detect path-like first args and launch Electron in GUI
  mode with --open-project instead of CLI passthrough mode
- Electron main process parses --open-project, sends IPC event to
  renderer, and forwards via second-instance for the already-running case
- Renderer OpenProjectListener reuses existing openProject() RPC flow
- Standalone CLI discovers the desktop app per platform and spawns it
2026-04-04 15:30:25 +08:00
Mohamed Boudra
42cfed514c feat: add provider-declared features system with Codex fast mode (#186)
Introduce a generic feature system where providers declare dynamic
features (toggles/selects) and the app renders controls automatically.
One message pair (set_agent_feature_request/response) handles all
feature mutations. Feature values persist and restore on agent resume.

First consumer: Codex fast mode (service_tier) — gated to supported
model families, with proper cleanup on model switch.
2026-04-04 15:25:20 +08:00
Mohamed Boudra
99114ddd11 fix(server): resolve gh executable via login shell for desktop users
Electron apps on macOS inherit a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin)
that doesn't include Homebrew or other user-installed tools. This caused
GitHub features (PR status, Create PR) to show as unavailable in the
desktop app even when gh CLI was installed and authenticated.

Extract findExecutable, resolveShellEnv, and related utilities from
provider-launch-config.ts into a shared utils/executable.ts module.
Use findExecutable("gh") with lazy caching so the login-shell resolution
happens once per daemon lifetime. Pass resolveShellEnv() as the process
env for all gh subprocess calls.

Update all consumers to import directly from utils/executable.ts —
no re-exports through provider-launch-config.ts.
2026-04-04 14:24:06 +07:00
Mohamed Boudra
e73c332e4f feat(desktop): add Integrations settings to install CLI and orchestration skills
Add a new Integrations section in desktop settings that lets users
install the Paseo CLI and orchestration skills directly from the app.

CLI install symlinks the bundled shim to ~/.local/bin and updates the
user's shell rc file if needed. Skills install copies SKILL.md files to
~/.agents/skills/, symlinks into ~/.claude/skills/ for Claude Code, and
copies to ~/.codex/skills/ for Codex. Both use a single status check
code path for install verification and on-load detection.

Also adds a /docs/skills page documenting all six orchestration skills,
extracts shared settings styles (section headers, rows) used across
the daemon, integrations, and shortcuts sections, and reorders the
settings sidebar to group integrations and daemon together.
2026-04-04 13:21:32 +07:00
Mohamed Boudra
1594e41602 fix(app): vertically center open-project screen content
Add paddingBottom to the content area equal to the header height so the
visual center accounts for the header above, keeping TitlebarDragRegion
scoped to the content area for Electron window dragging.
2026-04-04 13:21:31 +07:00
Mohamed Boudra
4896cfe970 fix(app): fix sidebar crash when switching iOS theme
Unistyles' StyleSheet.create((theme) => ...) wraps styled components in
<UnistylesComponent> and patches native view properties via C++. When
these dynamic styles are applied to Reanimated's Animated.View, a theme
change causes both systems to fight over the same native node, crashing
with "Unable to find node on an unmounted component."

Fix: use plain React Native StyleSheet for static positioning on
Animated.View, and pass theme-dependent values (backgroundColor) as
inline styles from useUnistyles() instead of Unistyles dynamic sheets.

Also:
- Add gestureAnimatingRef to prevent double withTiming during gesture opens
- Remove all debug instrumentation added during investigation
- Add Maestro flow and bash script for automated theme-toggle verification
- Add docs/MOBILE_TESTING.md covering Maestro patterns, self-verification
  loops, the Unistyles+Reanimated pitfall, and simulator commands
2026-04-04 13:21:31 +07:00
Mohamed Boudra
dcffe46f90 fix(server): increase agent creation timeout to 60s
Under contention, agent creation can be slow enough to exceed the
previous 15s timeout even though the agent is successfully created.
2026-04-04 13:21:31 +07:00
Mohamed Boudra
7b4b42068c docs(release): add pre-release sanity check and clarify changelog scope
Add a Codex 5.4 review step before cutting releases to catch breaking
changes and backward-compatibility issues (mobile apps lag behind
desktop/daemon updates). Clarify that the changelog always covers the
delta from the previous stable release, not from the last RC.
2026-04-04 13:21:31 +07:00
Mohamed Boudra
100502ae1e feat(app): add setup hint and paseo.sh link on mobile welcome screen
New app store users land on the welcome screen with no context. Show a
brief explanation that the desktop app or server is needed, plus a link
to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop.
2026-04-04 13:21:30 +07:00
Mohamed Boudra
178b4cd618 fix(app): reorder settings sections for better grouping 2026-04-04 13:21:30 +07:00
github-actions[bot]
9a6a8ce497 fix: update lockfile signatures and Nix hash 2026-04-03 16:50:58 +00:00
Mohamed Boudra
325ab500b3 chore(release): cut 0.1.45-rc.2 2026-04-03 23:49:49 +07:00
Mohamed Boudra
60bbfdde25 fix(opencode): prevent event stream starvation for slash commands 2026-04-03 23:47:37 +07:00
Mohamed Boudra
71ce95de90 fix(desktop): race existing connections against bootstrap for faster startup
The Electron startup blocked on a ~2.6s synchronous CLI status check before
showing the app. Now the host runtime controllers (which probe persisted
connections) race against the bootstrap path — if the daemon is already
running, the UI comes online in milliseconds.

Also converts the desktop CLI spawn calls from spawnSync to async spawn
so the Electron main thread is no longer blocked during daemon status checks.
2026-04-03 23:20:56 +07:00
Mohamed Boudra
255c2665cc feat(opencode): support custom agents and slash commands
Remove the hardcoded mode whitelist so user-defined agents (from
opencode.json or global config) appear in the mode picker. Add a
listProviderModes API (client → session → provider) so the draft
agent form fetches dynamic modes instead of relying on the static
manifest. Fix slash command dispatch to accept optional arguments.

Closes #185
2026-04-03 22:15:44 +07:00
Mohamed Boudra
176942bbf1 fix(sidebar): persist projects/workspaces and add remove project menu
Stop auto-archiving workspaces when all their agents are archived —
only archive when the directory no longer exists on disk. This prevents
projects from disappearing when users archive their last agent.

Add a kebab dropdown menu to all project rows (desktop) with a
"Remove project" action that archives all workspaces in the project.
2026-04-03 21:33:05 +07:00
Mohamed Boudra
1ae2f9280e fix(website): fetch release version at runtime with asset validation
Move release version resolution from build-time (vite.config.ts) to
runtime (server function on Cloudflare Workers). The server function
fetches GitHub releases, filters out prereleases/drafts, and only
returns a version that has all required assets (Mac DMG, Linux AppImage,
Windows exe). Uses Cloudflare's cf.cacheEverything with a 60s TTL to
avoid hitting GitHub on every request.

This fixes a race condition where the website would deploy and show
download links for a new release before the desktop build assets were
actually uploaded, resulting in 404s for users.
2026-04-03 20:41:26 +07:00
github-actions[bot]
49402b854f fix: update lockfile signatures and Nix hash 2026-04-03 13:00:59 +00:00
Mohamed Boudra
9476b3fc1d chore(release): cut 0.1.45-rc.1 2026-04-03 19:59:54 +07:00
Mohamed Boudra
29037abd54 feat(desktop): auto-restart desktop-managed daemon on version mismatch
When the desktop app starts and finds a running daemon with a different
version, it now restarts it automatically. Only applies to daemons the
desktop app itself started, identified by a PASEO_DESKTOP_MANAGED flag
written into the PID lock file.

Also rewrites resolveStatus() to use the CLI daemon status command
instead of manually parsing the PID file, and surfaces daemonVersion
in the CLI status output from the WebSocket hello message.
2026-04-03 19:43:56 +07:00
Mohamed Boudra
23ffec5072 fix(app): prevent tab pruning from closing pinned archived agents
The tab sync effect pruned archived agent tabs without checking if they
were pinned, so clicking an archived agent from the sessions page would
open and immediately close the tab.
2026-04-03 19:02:18 +07:00
Mohamed Boudra
1e64dd5509 fix(app): add Ctrl+C/V clipboard support for Windows/Linux terminal
On non-Mac platforms, Ctrl+C now copies selected text to clipboard
(falling through to SIGINT when nothing is selected) and Ctrl+V
pastes from clipboard into the terminal.
2026-04-03 18:58:10 +07:00
Mohamed Boudra
cacbbf4053 fix(app): remove input event listener from scrollbar hook that raced with RNW controlled input
The scrollbar hook's raw DOM `input` listener on the textarea called
setState before React Native Web's delegated handler could process the
same event, causing a re-render with the stale controlled value and
swallowing the edit. Scroll and ResizeObserver already cover all
scrollbar metric updates.
2026-04-03 18:49:59 +07:00
Mohamed Boudra
4ca04fc661 feat(desktop): add daemon status dialog to built-in daemon settings
Add a "Full status" button in the desktop daemon section that runs
`paseo daemon status` via Electron IPC and displays the raw output
in a modal.

Also fixes Commander.js argv parsing when the CLI is invoked via
Electron's ELECTRON_RUN_AS_NODE — Commander auto-detects the Electron
environment and skips only 1 argv element instead of 2, causing
"unknown command" errors. Fixed by explicitly passing { from: "node" }.
2026-04-03 18:49:59 +07:00
Mohamed Boudra
f924d33076 Fix bulk close archiving for stored agents 2026-04-03 18:49:59 +07:00
github-actions[bot]
763cfa9333 fix: update lockfile signatures and Nix hash 2026-04-03 09:56:55 +00:00
Mohamed Boudra
fd894dc3d7 chore(release): cut 0.1.44 2026-04-03 16:55:14 +07:00
Mohamed Boudra
9ea181a072 docs: add 0.1.44 changelog entry 2026-04-03 16:55:05 +07:00
Mohamed Boudra
a96f2d7652 fix(desktop): stop daemon before auto-update restart
The daemon is a detached process that survives Electron restarts,
so after an auto-update the old daemon version would keep running.
Now we stop it before quitAndInstall so the new app instance
starts a fresh daemon with the updated binary.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
44da0c67b2 fix(server): disable claude-acp and copilot providers from registry
These providers cause old mobile clients (<=0.1.40) to fail parsing
the list_available_providers_response because their AgentProviderSchema
enum rejects unknown provider IDs, dropping the entire message and
leaving the model picker empty.

The provider implementations remain in the codebase — only the manifest
entries and factory registrations are removed until the updated mobile
app (0.1.43) is live in the App Store.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
55c4e58aa3 fix(desktop): disable npmRebuild in electron-builder 2026-04-03 16:53:14 +07:00
Mohamed Boudra
a2b1498c3f fix(app): broaden keyboard focus scope resolution to check multiple candidates
Check target, parentElement, and document.activeElement as focus
candidates instead of relying solely on the direct event target.
Fixes scope misdetection when the keyboard event target is a text
node or non-element.
2026-04-03 16:53:14 +07:00
github-actions[bot]
df617a4c8f fix: update lockfile signatures and Nix hash 2026-04-03 07:39:11 +00:00
Mohamed Boudra
a64292f2b0 fix: use cross-env for cross-platform NODE_ENV in server scripts 2026-04-03 14:38:07 +07:00
Mohamed Boudra
7ff5933b08 chore: update og-image.png 2026-04-03 11:16:57 +07:00
Mohamed Boudra
bb9ef76017 Fix OpenCode interrupt tool-call terminal state parity 2026-04-03 11:15:02 +07:00
Mohamed Boudra
d6413404e0 ci(desktop): add checkout step before running release tag script 2026-04-03 09:46:57 +07:00
Mohamed Boudra
8a585e60f2 fix(security): shell injection, symlink escape, remove /pairing endpoint, harden defaults
- Replace all execAsync shell-interpolated git calls with execFileAsync + array args in session.ts
- Add symlink resolution in file-explorer resolveScopedPath to prevent workspace escape
- Remove /pairing HTTP endpoint from server; desktop now uses `paseo daemon pair --json` via CLI
- Disable MCP HTTP endpoint by default (opt-in via config)
- Correct SECURITY.md: fix cipher name (XSalsa20-Poly1305), accurate replay resistance claims, add local daemon trust boundary docs
2026-04-02 23:58:38 +07:00
github-actions[bot]
1d795f6c32 fix: update lockfile signatures and Nix hash 2026-04-02 16:14:40 +00:00
Mohamed Boudra
9bd5f852e7 chore(release): cut 0.1.43 2026-04-02 23:13:07 +07:00
Mohamed Boudra
48516f0b9c docs(changelog): add 0.1.43 release notes 2026-04-02 23:12:26 +07:00
Mohamed Boudra
0bf8e8b5b2 Refine model selector UX and mobile sheet behavior
Closes #173
2026-04-02 22:58:59 +07:00
Mohamed Boudra
994ee488b9 Increase workspace status emphasis and use amber alert for needs input 2026-04-02 22:53:52 +07:00
Mohamed Boudra
a854096c35 feat: ACP base provider, Copilot integration, eliminate hardcoded provider unions (#170)
* feat(server): add ACP base provider with Claude ACP integration

Implement a generic Agent Client Protocol (ACP) base provider that any
ACP-compatible agent can extend with minimal code. Includes a concrete
Claude ACP implementation via @agentclientprotocol/claude-agent-acp
with full parity to the existing Claude Code provider.

The base handles subprocess lifecycle, streaming translation, permission
bridging, terminal/fs callbacks, listModels, loadSession/resume, and
mode/model management. The concrete class only specifies the command,
modes, and availability check.

* fix: update lockfile signatures and Nix hash

* feat: add Copilot ACP provider, eliminate hardcoded provider unions, fix ACP streaming bugs

Add Copilot as an ACP provider (copilot --acp), with real modes and models
discovered from the ACP server. Fix two ACP base bugs: duplicate assistant
text (emit deltas not cumulative) and idle→running→stuck (fire-and-return
startTurn). Replace all hardcoded provider string unions with string/manifest-
derived values so adding a provider only requires: impl class, manifest entry,
registry factory, icon, and E2E config. Add provider docs and Copilot icon.

* fix: update lockfile signatures and Nix hash

* feat(app): add OpenCode provider icon

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-02 23:20:01 +08:00
Mohamed Boudra
5d89f9444a Merge branch 'main' of github.com:getpaseo/paseo 2026-04-02 21:49:21 +07:00
thatdaveguy1
63905950cc feat(app): add searchable model favorites (#172)
Improve draft and live model selectors with search, favorites, and clearer provider context. Keep live agents honest by showing other provider catalogs as browse-only until provider switching exists.

Co-authored-by: David Longman <dlongman@tokentradegames.com>
2026-04-02 22:35:07 +08:00
Mohamed Boudra
ffd07ec17c fix(server): hardcode Claude models with 1M context support
The SDK's supportedModels() API hides the 1M context variant behind a
"default" alias and doesn't expose it as a selectable model. Replace
dynamic SDK discovery with a hardcoded catalog that exposes both
claude-opus-4-6[1m] (1M context) and claude-opus-4-6 (200k) as
distinct models. Delete sdk-model-resolver which parsed SDK descriptions.
2026-04-02 18:02:57 +07:00
Mohamed Boudra
2d63bc3893 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-02 14:39:25 +07:00
Mohamed Boudra
55acb8a539 fix(terminal): handle Ctrl+C/V for copy & paste on Windows/Linux
On non-Mac platforms, let xterm.js ClipboardAddon handle Ctrl+C (copy
when text is selected) and Ctrl+V (paste) instead of sending control
codes to the PTY.

Closes #175
2026-04-02 14:09:52 +07:00
Mohamed Boudra
4c52f272fd fix(server): implement slash command support for OpenCode harness (#169)
Add listCommands() to OpenCodeAgentSession via the OpenCode SDK's
command.list() API, and route recognized /commands through
session.command() instead of promptAsync(). This fixes issue #168
where slash commands didn't load when OpenCode was the selected harness.
2026-04-02 12:05:05 +08:00
Mohamed Boudra
a91f79053c fix: themed scrollbar on message input and reusable scrollbar hooks
Add useWebElementScrollbar (for DOM elements) and useWebScrollViewScrollbar
(for RN ScrollView/FlatList) hooks that return renderable overlays, replacing
manual WebDesktopScrollbarOverlay wiring across all consumers. Apply the
themed scrollbar to the message input textarea. Tint the dark-mode scrollbar
handle to match the teal-tinted dark theme.

Closes #174
2026-04-02 10:52:14 +07:00
github-actions[bot]
7b4db04a81 fix: update lockfile signatures and Nix hash 2026-04-02 02:45:23 +00:00
Mohamed Boudra
ac9c2c5642 chore(release): cut 0.1.43-rc.1 2026-04-02 09:44:03 +07:00
Mohamed Boudra
99200eabba fix(windows): quote shell args with spaces 2026-04-02 09:44:03 +07:00
github-actions[bot]
5f2bb87a17 fix: update lockfile signatures and Nix hash 2026-04-01 16:59:45 +00:00
Mohamed Boudra
897c18dd5f chore(release): cut 0.1.42 2026-04-01 23:58:00 +07:00
Mohamed Boudra
51a865cd24 docs(changelog): add 0.1.42 release notes 2026-04-01 23:58:00 +07:00
Mohamed Boudra
9dc3d116b4 fix(windows): quote command paths with spaces when spawning with shell
shell: true passes commands to cmd.exe /d /s /c which strips outer
quotes, causing paths like C:\Program Files\... to split at the space.
2026-04-01 23:58:00 +07:00
Mohamed Boudra
a4326ec5c0 Fix Claude bypass mode after query restarts
Closes #127
2026-04-01 23:58:00 +07:00
github-actions[bot]
cc09b61b19 fix: update lockfile signatures and Nix hash 2026-04-01 12:25:53 +00:00
Mohamed Boudra
26d69e2006 chore(release): cut 0.1.41 2026-04-01 19:23:40 +07:00
Mohamed Boudra
4b7c623592 docs(changelog): add 0.1.41 release notes 2026-04-01 19:21:48 +07:00
Mohamed Boudra
b91884bc54 fix(desktop): enable default context menu for copy/paste across the app 2026-04-01 19:19:40 +07:00
Mohamed Boudra
963c79265a fix(desktop): eliminate white flash on window resize in dark mode
Set native window backgroundColor to match the app's surface0 color so
the backing layer is dark during resize repaints. Extend the existing
updateWindowControls IPC to also call setBackgroundColor on all platforms
(including macOS), keeping the renderer as the single source of truth
for theme resolution. Add a prefers-color-scheme CSS rule in index.html
to cover the HTML-to-React mount gap.
2026-04-01 18:33:17 +07:00
Mohamed Boudra
ade1e338ea fix: show modifier keys and missing keys during shortcut rebinding
Add Tab, F1-F12, Delete, Home, End, PageUp, PageDown, Insert to the
key map so they can be captured during rebinding. Show held modifier
keys (Ctrl, Alt, Shift, Cmd) as live feedback matching VS Code's
keydown-only approach — modifiers persist after release until the
next keypress. Cancel capture when navigating away from settings.
2026-04-01 18:32:31 +07:00
Mohamed Boudra
c13972c835 fix: replace Unix path assumptions with cross-platform isAbsolutePath
Windows daemon paths like C:\Users\... don't start with "/", breaking
the explorer sidebar, checkout status, terminals, and file attachments
when connected to a Windows daemon. Consolidate three private
isAbsolutePath implementations into a shared utility and use it
everywhere, with correct file URI conversion for Windows and UNC paths.
2026-04-01 18:20:02 +07:00
Mohamed Boudra
d8d04c545e docs(release): clarify retry tag rebuilds 2026-04-01 17:40:23 +07:00
Mohamed Boudra
613450bac8 fix: remove 40-item cap on activity curator timeline output
`paseo logs` was only showing the last 40 collapsed timeline items
due to DEFAULT_MAX_ITEMS. Setting to 0 disables the cap so the full
timeline is shown by default. --tail still works for limiting output.
2026-04-01 17:38:32 +07:00
Mohamed Boudra
cafff08a30 fix: rewrite titlebar drag to match VS Code's static pattern
Replace the stateful TitlebarDragRegion (hooks, ResizeObserver, IPC,
fullscreen tracking) with a pure static component — matching VS Code's
titlebar-drag-region exactly: position absolute, full size, no z-index,
no pointer-events, no state, no event listeners.

Remove TitlebarNoDragContent entirely — VS Code doesn't wrap content in
no-drag; interactive elements get no-drag from the global CSS backstop
in index.html.

Add drag regions to all header surfaces:
- ScreenHeader (sessions, workspace header)
- Left sidebar (traffic light area + header)
- Split container pane tabs
- Explorer sidebar header (Changes/Files tabs)

Fix workspace header empty space not draggable by changing
headerTitleContainer from flex: 1 to flexShrink: 1.
2026-04-01 17:36:26 +07:00
Mohamed Boudra
cb60f2a596 Fix Windows default shell for terminal creation 2026-04-01 17:25:41 +07:00
Mohamed Boudra
58d72bea87 refactor: migrate to VS Code titlebar drag region pattern 2026-04-01 16:00:33 +07:00
Mohamed Boudra
953f7898e7 docs(release): clarify workflow dispatch retries 2026-04-01 15:43:24 +07:00
Mohamed Boudra
fd06e109be fix(release): use bash for release env steps 2026-04-01 15:40:34 +07:00
Mohamed Boudra
ba1bb1646e docs(release): clarify stable vs rc flow 2026-04-01 15:37:07 +07:00
github-actions[bot]
3ee11efcd0 fix: update lockfile signatures and Nix hash 2026-04-01 08:36:00 +00:00
Mohamed Boudra
1930a8a2f2 chore(release): cut 0.1.41-rc.1 2026-04-01 15:33:41 +07:00
Mohamed Boudra
f7fd41a5f8 chore(release): add rc prerelease flow 2026-04-01 15:33:27 +07:00
Mohamed Boudra
3af5b0f031 Merge remote-tracking branch 'origin/main' into codex/rc-release-0.1.41 2026-04-01 15:21:26 +07:00
Mohamed Boudra
cce8dee21c fix(server): use shell on Windows for all provider spawns
Windows npm shims (e.g. C:\nvm4w\nodejs\codex) fail with ENOENT when
spawned without shell. Use `shell: true` on win32 for all provider
launches instead of the overly complex shouldUseWindowsShell function.
2026-04-01 15:19:28 +07:00
Mohamed Boudra
2d02db6ae0 fix(app): improve light mode theming
- Make PaseoLogo theme-aware (uses foreground color instead of hardcoded white)
- Add shadow tokens (sm/md/lg) to theme with per-scheme opacity, radius, and offset
- Replace all 16 hardcoded shadow instances with spreadable theme.shadow tokens
- Fix button icon color for default variant (use accentForeground, not foreground)
- Fix dark background flash on startup (root layout used hardcoded darkTheme)
- Add theme.colorScheme to replace fragile hex-string dark mode detection
- Add scrollbarHandle and surfaceWorkspace tokens to eliminate isDark branching
2026-04-01 14:24:17 +07:00
github-actions[bot]
66732a2f48 fix: update lockfile signatures and Nix hash 2026-04-01 06:43:36 +00:00
Mohamed Boudra
30ebd4b777 chore(release): cut 0.1.40 2026-04-01 13:42:02 +07:00
Mohamed Boudra
e95554d782 docs: add 0.1.40 changelog entry 2026-04-01 13:40:59 +07:00
Mohamed Boudra
5cf8b7549d fix(opencode): prevent reasoning content from duplicating as assistant text
OpenCode's message.part.delta events use field="text" for all parts
including reasoning, because "text" is the property name being updated.
Track part types from message.part.updated events and use them to
correctly classify deltas for known reasoning parts.

Also set PASEO_SUPERVISED=0 in vitest setup to prevent process.send()
conflicts with vitest's fork pool.
2026-04-01 13:37:51 +07:00
Mohamed Boudra
9c4dee5364 fix(server): handle codex spawn errors to prevent daemon crash
Spawning a missing/broken codex binary emits an async "error" event on
the child process. Without a listener, Node.js crashes the daemon with
no log entry. Add child.on("error") in CodexAppServerClient and global
uncaughtException/unhandledRejection handlers that log via pino before
exiting.
2026-04-01 13:37:51 +07:00
Mohamed Boudra
c635eabff3 Cache provider models by server and provider 2026-04-01 13:37:51 +07:00
Mohamed Boudra
51411182fe fix(app): support ipad desktop layout safely 2026-04-01 13:37:51 +07:00
Mohamed Boudra
f53c770a71 [codex] add batch close rpc for workspace tabs (#165)
* style: add subtle teal tint to dark mode surfaces

Replace neutral gray surfaces with a teal-tinted palette across
the app and website, giving Paseo a warmer, more recognizable feel.
App uses a restrained tint (G-R ~3), website is slightly stronger
(G-R ~6) as a brand showcase.

* fix(opencode): handle message.part.delta events for assistant text streaming

OpenCode v2 streams assistant text via `message.part.delta` events (with
field "text" or "reasoning"), but the translator only handled
`message.part.updated`. This caused assistant messages to be silently
dropped during live streaming.

* feat: show agent short ID in tab context menu and tooltip (#161)

Add agent short ID to the "Copy agent id" menu item as trailing hint
text and to the tab tooltip next to the title. Add leading icons to
all workspace tab context menu items.

* add batch close rpc for workspace tabs
2026-04-01 14:31:38 +08:00
Mohamed Boudra
8d55764313 fix: correct checkout-diff-manager test file contents
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 04:49:03 +00:00
Mohamed Boudra
4b12ebd5c0 fix: Linux checkout diff watchers using non-recursive directory watching
Use non-recursive directory watchers on Linux since recursive fs.watch
is not supported. Dynamically discover and watch subdirectories, refreshing
the watcher tree on changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 04:46:07 +00:00
Mohamed Boudra
27c8cfbd4b Revert "fix: Linux checkout diff watchers and rewind command ordering"
This reverts commit 07b077f1a2.
2026-04-01 04:44:29 +00:00
Mohamed Boudra
07b077f1a2 fix: Linux checkout diff watchers and rewind command ordering
Use non-recursive directory watchers on Linux since recursive fs.watch
is not supported. Dynamically discover and watch subdirectories, refreshing
the watcher tree on changes. Also pin rewind command first in the slash
command list and remove stale .gitignore entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 04:38:50 +00:00
Mohamed Boudra
ee50d3b8d0 WIP: fix archive tab reconciliation (#158)
* WIP: fix archive tab reconciliation

* fix: converge archive into AgentManager for cross-session propagation

CLI archive left agent tabs visible in passive app clients because
Session.archiveAgentState only notified the archiving session's own
subscription. LoopService had the same gap via AgentManager.archiveAgent.

Converge on AgentManager.archiveAgent as the single canonical archive
path: persist updatedAt, call notifyAgentState before closeAgent so all
sessions receive the archived snapshot. Delete Session.archiveAgentState
and make handleArchiveAgentRequest a thin wrapper.
2026-03-31 16:03:36 +07:00
Mohamed Boudra
4b93f990d2 fix(ci): merge macOS update manifests from parallel arch builds
electron-builder overwrites latest-mac.yml when parallel arm64/x64
builds publish independently — whichever finishes last wins, leaving
the other architecture's users downloading the wrong binary.

Add a finalize-mac-manifest job that runs after both macOS builds
complete, merges their per-arch manifest artifacts into a single
latest-mac.yml containing all files, and uploads it to the release.
2026-03-30 20:30:10 +07:00
github-actions[bot]
87f297e755 fix: update lockfile signatures and Nix hash 2026-03-30 10:29:37 +00:00
Mohamed Boudra
b50b065bcc chore(release): cut 0.1.39 2026-03-30 17:27:44 +07:00
Mohamed Boudra
4e8e2589bf docs: add 0.1.39 changelog entry 2026-03-30 17:27:34 +07:00
github-actions[bot]
8eddb2ee18 fix: update lockfile signatures and Nix hash 2026-03-30 10:06:33 +00:00
Mohamed Boudra
0ba2f6b194 feat(cli): add paseo terminal command group for managing workspace terminals
New CLI commands: ls, create, kill, capture (tmux capture-pane style with
line range slicing and ANSI stripping), and send-keys (with special token
interpretation). Server-side adds capture_terminal_request RPC and
list-all-terminals support (optional CWD). Includes e2e tests and skill docs.
2026-03-30 17:05:14 +07:00
Mohamed Boudra
5b22aedaff feat(app): polish file explorer tree with material icons and visual refinements
Replace lucide file icons with colored SVGs from material-icon-theme covering
53 language/filetype-specific icons. Add chevron expand indicators for
directories, indent guide lines, rounded rows, and restyle the header toolbar
to match the git diff pane pattern.
2026-03-30 17:05:14 +07:00
Mohamed Boudra
e3b6e2dcfa Create FUNDING.yml 2026-03-30 17:29:31 +08:00
Mohamed Boudra
0d9bda12e6 fix(app): centralize window controls padding into useWindowControlsPadding hook
Replaces useTrafficLightPadding with a role-based useWindowControlsPadding
hook that absorbs all layout-conditional logic (sidebar state, focus mode,
explorer state). Consumers no longer make platform or layout decisions —
they just apply the resolved padding values.

Fixes Windows title bar overlay buttons overlapping source control icons
when the left sidebar is open, and right padding not shifting to the
explorer sidebar header when it's open.

Also fixes borderless header using borderBottomWidth:0 (layout shift)
instead of transparent, and double-click-to-maximize bounce on the
open project screen caused by nested drag handlers.
2026-03-30 15:17:27 +07:00
Mohamed Boudra
a94bc0720c refactor(server): rename worktree destroy → teardown, add port to env, extract session code
- Rename `worktree.destroy` config field to `worktree.teardown` (breaking)
- Rename getWorktreeDestroyCommands/runWorktreeDestroyCommands → teardown
- Add PASEO_WORKTREE_PORT to teardown env from persisted metadata (omit if missing)
- Extract worktree orchestration from session.ts into worktree-session.ts
- Document teardown lifecycle hooks in worktree docs
2026-03-30 14:53:27 +07:00
Mohamed Boudra
7504d20b67 refactor(server): extract SpeechService and defer init until after listen
Speech runtime was leaking 8+ individual resolvers into bootstrap and
blocking server startup for ~3s with synchronous Sherpa native model
loading. Refactor into a self-contained SpeechService that owns its
full lifecycle (init, download, monitor, cleanup) and defer start()
until after httpServer.listen() so native loading doesn't block
accepting connections. Server startup drops from ~3.2s to ~0.5s.

Also removes unused client-triggered speech model download/list path
(CLI commands, session handlers, message types) — the service manages
its own models.
2026-03-30 14:07:55 +07:00
Mohamed Boudra
db39417be9 fix(app): remove redundant overflow hidden causing iOS sidebar scroll flicker
The inner sidebarContent had overflow: "hidden" while the parent
mobileSidebar already clips. On iOS, nested overflow clipping on
Animated.View with transforms and scroll containers causes rendering
artifacts during scroll.
2026-03-30 13:45:26 +07:00
Mohamed Boudra
e0e4f228eb fix(server): guard paseo:ready IPC behind PASEO_SUPERVISED env var
The process.send check alone was insufficient because vitest also runs
workers with IPC channels. Now the supervisor sets PASEO_SUPERVISED=1
on the worker env, and bootstrap checks for it before sending.
2026-03-30 12:44:05 +07:00
github-actions[bot]
f6630e16ff fix: update lockfile signatures and Nix hash 2026-03-30 05:40:49 +00:00
Mohamed Boudra
82a235dada chore(release): cut 0.1.38 2026-03-30 12:39:23 +07:00
Mohamed Boudra
3784357b7e docs: add 0.1.38 changelog entry 2026-03-30 12:39:12 +07:00
Mohamed Boudra
8d0a9f9224 fix(server): fix daemon startup race and reduce log bloat
The desktop app could time out connecting to the daemon on first launch
because the PID file advertised a listen address before the HTTP server
was actually listening. The supervisor now writes the PID lock with
listen: null and updates it only after the worker sends a paseo:ready
IPC message confirming the server is listening.

- Rename daemon-runner.ts to supervisor-entrypoint.ts
- PID lock acquired with listen: null, updated via paseo:ready IPC
- Worker sends paseo:ready after httpServer.listen() resolves
- Desktop polls until listen is non-null before returning to the app
- Remove PASEO_PID_LOCK_MODE and external lock mode
- Remove unnecessary env overrides (PASEO_HOME, PASEO_CORS_ORIGINS) from
  desktop daemon spawn
- Reduce trace log bloat: inbound/outbound WebSocket messages now log
  only message type and payload size instead of full payloads
- Supervisor restarts worker on SIGKILL (covers OOM)
2026-03-30 12:37:07 +07:00
Mohamed Boudra
e758734807 feat(website): update marketing copy and section styling
Rework multi-provider and self-hosted sections with clearer copy.
Scale up self-hosted diagram cards to match multi-provider sizing.
2026-03-30 12:31:42 +07:00
359 changed files with 23891 additions and 6465 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [boudra]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -34,15 +34,33 @@ jobs:
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[2]}"
else
release_tag="$source_tag"
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--generate-notes
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -107,24 +125,6 @@ jobs:
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Wait for GitHub release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 90); do
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Found release for tag $RELEASE_TAG"
exit 0
fi
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
sleep 20
done
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
exit 1
- name: Upload APK to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,7 +4,9 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- 'app-v*'
- '!app-v*-rc.*'
workflow_dispatch:
jobs:

View File

@@ -15,6 +15,7 @@ on:
jobs:
deploy:
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
runs-on: ubuntu-latest
steps:

View File

@@ -58,24 +58,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -110,24 +93,6 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -149,6 +114,110 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload manifest artifact
if: env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
@@ -164,24 +233,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -216,24 +268,6 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -265,24 +299,7 @@ jobs:
- name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -325,24 +342,6 @@ jobs:
npx expo export --platform web
working-directory: packages/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:

View File

@@ -19,11 +19,6 @@ on:
required: false
default: false
type: boolean
draft:
description: "Create missing release as draft."
required: false
default: false
type: boolean
concurrency:
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
@@ -48,7 +43,6 @@ jobs:
REF: ${{ github.ref }}
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
INPUT_DRAFT: ${{ github.event.inputs.draft }}
shell: bash
run: |
set -euo pipefail
@@ -70,8 +64,4 @@ jobs:
args+=(--create-if-missing)
fi
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
args+=(--draft)
fi
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"

View File

@@ -1,5 +1,152 @@
# Changelog
## 0.1.48 - 2026-04-05
### Added
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
### Improved
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
- Removed noisy agent lifecycle toasts that fired on every state change.
### Fixed
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
- Input field on running agent screens now correctly receives keyboard focus.
- Mobile model selector alignment and sizing.
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
- Daemon status dialog in desktop settings for quick health checks.
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
- Pinned archived agents are no longer pruned when closing tabs.
- OpenCode event stream starvation during slash command execution.
- Duplicate workspaces when multiple git worktrees share the same root.
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
- Forward-compatible provider handling so older app clients don't break on new provider types.
- Input event listener race condition in the web scrollbar hook.
- Open-project screen content now vertically centered.
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
- Shell injection, symlink escape, and pairing endpoint security hardening.
## 0.1.43 - 2026-04-02
### Added
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
- Searchable model favorites — quickly find and pin preferred models.
- Slash command support for OpenCode agents.
### Improved
- Refined model selector UX with better mobile sheet behavior.
- Workspace status now uses amber alert styling for "needs input" state.
- Themed scrollbar on message input for consistent styling.
### Fixed
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
- Shell arguments with spaces are now properly quoted on Windows.
- Claude models with 1M context support are now correctly reported.
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
- Fixed context menu for copy/paste across the desktop app.
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1F12).
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
- File diff tracking in workspaces now works correctly on Linux.
- iPad layout now renders correctly in desktop mode.
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added

View File

@@ -45,6 +45,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **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.
- **Always run typecheck after every change.**
- **NEVER make breaking changes to WebSocket or message schemas.** The mobile app in the App Store always lags behind the daemon, and daemons in the wild lag behind new app releases. Both directions must work. Every schema change MUST be backward-compatible:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- 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?"
## Debugging

View File

@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
- **Replay old messages** — Each session derives fresh encryption keys
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
### Trust model
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
## DNS rebinding protection
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).

View File

@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
## Cloud build + submit (EAS)
Tag pushes like `v0.1.0` trigger:
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands
```bash

109
docs/FILE_ICONS.md Normal file
View File

@@ -0,0 +1,109 @@
# File Icons
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |

206
docs/MOBILE_TESTING.md Normal file
View File

@@ -0,0 +1,206 @@
# Mobile Testing
## Maestro
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
Run a flow:
```bash
maestro test packages/app/maestro/my-flow.yaml
```
### Screenshots
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
```bash
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
mkdir -p /tmp/maestro-out
cd /tmp/maestro-out && maestro test "$FLOW"
```
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
### Element targeting
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
```tsx
// Component
<Pressable testID="sidebar-sessions" onPress={onPress}>
```
```yaml
# Flow
- tapOn:
id: "sidebar-sessions"
- assertVisible:
id: "sidebar-sessions"
```
### Conditional steps
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
```yaml
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
```
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
### Don't use launchApp against a running dev app
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
### Swipe gestures
Use `start`/`end` with percentage coordinates for precise control:
```yaml
# Edge swipe from left to open sidebar
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
```
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
### Assertions
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
For async elements, use `extendedWaitUntil`:
```yaml
- extendedWaitUntil:
visible: ".*Online.*"
timeout: 90000
```
### Dev client handling
Two reusable flows handle Expo dev client screens after launch:
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
- `flows/dev-client.yaml` — same but without asserting a particular app route
## Self-verification loops
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
This pattern also lets agents self-verify fixes without manual user testing.
### Pattern
1. Run baseline Maestro flow (confirm feature works)
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
3. Re-run Maestro flow (confirm feature still works)
4. Repeat N iterations to catch intermittent failures
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
```bash
bash packages/app/maestro/test-sidebar-theme.sh 6 1
# Args: iterations=6, wait_seconds=1 between toggle and test
```
Key elements of the script pattern:
```bash
set -euo pipefail
ITERATIONS="${1:-3}"
for i in $(seq 1 "$ITERATIONS"); do
# Toggle system state
xcrun simctl ui booted appearance light
# Wait for change to propagate
sleep 1
# Run Maestro flow and capture result
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
echo "PASS"
else
echo "FAIL"
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
fi
done
```
## Unistyles + Reanimated
### The crash
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
### The fix
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
```tsx
// BAD: Unistyles dynamic style on Animated.View
const styles = StyleSheet.create((theme) => ({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
overflow: "hidden",
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />
```
```tsx
// GOOD: static stylesheet + inline theme values
import { StyleSheet as RNStyleSheet } from "react-native";
const staticStyles = RNStyleSheet.create({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
overflow: "hidden",
},
});
const { theme } = useUnistyles();
<Animated.View
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>
```
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## iOS Simulator
```bash
# Screenshot
xcrun simctl io booted screenshot /tmp/screenshot.png
# Dark/light mode
xcrun simctl ui booted appearance # check current
xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).

359
docs/PROVIDERS.md Normal file
View File

@@ -0,0 +1,359 @@
# Adding a New Provider to Paseo
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Existing ACP providers: `claude-acp`, `copilot`.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude`, `codex`, `opencode`.
---
## ACP Provider Checklist
### 1. Create the provider class
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
```ts
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const MY_PROVIDER_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
},
// Add more modes as needed
];
type MyProviderClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class MyProviderACPAgentClient extends ACPAgentClient {
constructor(options: MyProviderClientOptions) {
super({
provider: "my-provider", // Must match the ID used everywhere else
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultModes: MY_PROVIDER_MODES,
capabilities: MY_PROVIDER_CAPABILITIES,
});
}
// Override isAvailable() if the provider needs specific auth/env vars
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false; // Binary not found
}
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
}
}
```
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
```ts
export class CopilotACPAgentClient extends ACPAgentClient {
constructor(options: CopilotACPAgentClientOptions) {
super({
provider: "copilot",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
capabilities: COPILOT_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
return super.isAvailable();
}
}
```
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
```ts
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "autonomous",
label: "Autonomous",
description: "Runs without prompting",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
```
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
```ts
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
// ... existing providers ...
{
id: "my-provider",
label: "My Provider",
description: "Short description of the provider",
defaultModeId: "default",
modes: MY_PROVIDER_MODES,
// Optional: enable voice
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "some-model",
},
},
];
```
### 3. Add the factory to the provider registry
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
```ts
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
// ... existing factories ...
"my-provider": (logger, runtimeSettings) =>
new MyProviderACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["my-provider"],
}),
};
```
### 4. Add a provider icon (app)
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
```tsx
import Svg, { Path } from "react-native-svg";
interface MyProviderIconProps {
size?: number;
color?: string;
}
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="..." />
</Svg>
);
}
```
Then register it in `packages/app/src/components/provider-icons.ts`:
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
"my-provider": MyProviderIcon as unknown as typeof Bot,
};
```
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
### 5. Add E2E test config
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
```ts
export const agentConfigs = {
// ... existing configs ...
"my-provider": {
provider: "my-provider",
model: "default-model-id",
modes: {
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
},
},
} as const satisfies Record<string, AgentTestConfig>;
```
Add an availability check in `isProviderAvailable()`:
```ts
case "my-provider":
return (
isCommandAvailable("my-agent-binary") &&
Boolean(process.env.MY_PROVIDER_API_KEY)
);
```
Add to the `allProviders` array:
```ts
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"my-provider",
];
```
### 6. Run typecheck
```bash
npm run typecheck
```
This is required after every change per project rules.
---
## Direct Provider Checklist
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
### Interfaces to implement
**`AgentClient`** -- factory for sessions and model listing:
```ts
interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
```
**`AgentSession`** -- a running agent conversation:
```ts
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
2. Add to the provider manifest (same as ACP step 2 above)
3. Add factory to the registry (same as ACP step 3 above)
4. Add icon (same as ACP step 4 above)
5. Add E2E config (same as ACP step 5 above)
6. Run typecheck
---
## Testing
### Manual testing with the CLI
Start the daemon if not already running, then:
```bash
# Launch an agent with your provider
paseo run --provider my-provider
# Launch with a specific model and mode
paseo run --provider my-provider --model some-model --mode default
# List running agents
paseo ls -a -g
# Check if the provider reports models
paseo models --provider my-provider
```
### E2E test patterns
The E2E configs in `agent-configs.ts` expose two helpers:
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
---
## Gotchas
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.

View File

@@ -2,6 +2,13 @@
All workspaces share one version and release together.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
```bash
@@ -12,6 +19,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
## Manual step-by-step
```bash
@@ -21,27 +30,44 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Draft release flow
## Release candidate flow
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
```
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
Use the RC path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
```bash
# Desktop (all platforms)
@@ -54,21 +80,29 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
@@ -76,11 +110,49 @@ The website depends on the changelog to determine the latest download version. T
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## 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.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
## Completion checklist
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green

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-ebbF7pug19PBgJQahUCTlvhcLS/LF8Nv1UIIG31LRmQ=";
npmDepsHash = "sha256-ZLwOacIYQ6rUT2sYs3vNvOh7wTkwQ92cpOJDm29GLWs=";
# 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).
@@ -114,7 +114,7 @@ buildNpmPackage rec {
fi
done
# Copy server scripts (daemon-runner, supervisor) needed by CLI
# Copy server scripts (including supervisor-entrypoint) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/

343
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.37",
"version": "0.1.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.37",
"version": "0.1.48",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -48,6 +48,15 @@
}
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz",
"integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz",
@@ -3510,6 +3519,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -14831,6 +14847,13 @@
"node": ">=10"
}
},
"node_modules/chroma-js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz",
"integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==",
"dev": true,
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/chrome-launcher": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -15418,6 +15441,24 @@
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
@@ -15855,6 +15896,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/deep-rename-keys": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz",
"integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"kind-of": "^3.0.2",
"rename-keys": "^1.1.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-rename-keys/node_modules/kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-buffer": "^1.1.5"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -15864,18 +15932,6 @@
"node": ">=0.10.0"
}
},
"node_modules/default-shell": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@@ -18423,6 +18479,13 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"dev": true,
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -18470,35 +18533,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/expect": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
@@ -21784,18 +21818,6 @@
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-symbol-description": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@@ -22507,15 +22529,6 @@
"node": ">= 6"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
@@ -23244,6 +23257,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -25292,6 +25306,25 @@
"node": ">=10"
}
},
"node_modules/material-icon-theme": {
"version": "5.32.0",
"resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.32.0.tgz",
"integrity": "sha512-SxJxCcnk6cJIbd+AxmoeghXJ24joXGmUzjiGci16sX4mXZdXprGEzM6ZZ0VHGAofxNlMqznEbExINwFLsxf8eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chroma-js": "^3.1.2",
"events": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"svgson": "^5.3.1"
},
"engines": {
"vscode": "^1.55.0"
},
"funding": {
"url": "https://github.com/sponsors/material-extensions"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -26464,6 +26497,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -27370,18 +27404,6 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -27607,6 +27629,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
@@ -28170,6 +28193,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/pi-acp": {
"version": "0.0.24",
"resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.24.tgz",
"integrity": "sha512-iFoQLH9nd3e2fpvemFV/0SUPeT9ecGHyhBiAe1JW7kHBWfmBQREeRn7O+hQyWA7heMVv+C9CObk4HDLVRy7JaQ==",
"license": "MIT",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"zod": "^3.25.0"
},
"bin": {
"pi-acp": "dist/index.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pi-acp/node_modules/@agentclientprotocol/sdk": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/pi-acp/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -30156,6 +30213,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/rename-keys": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz",
"integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -30986,50 +31053,6 @@
"node": ">=8"
}
},
"node_modules/shell-env": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz",
"integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==",
"license": "MIT",
"dependencies": {
"default-shell": "^2.0.0",
"execa": "^5.1.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/shell-env/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/shell-env/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
@@ -31962,15 +31985,6 @@
"node": ">=4"
}
},
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/strip-indent": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
@@ -32144,6 +32158,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svgson": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz",
"integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-rename-keys": "^0.2.1",
"xml-reader": "2.4.3"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -34550,6 +34575,16 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/xml-lexer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz",
"integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@@ -34560,6 +34595,17 @@
"node": ">=12"
}
},
"node_modules/xml-reader": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz",
"integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0",
"xml-lexer": "^0.2.2"
}
},
"node_modules/xml2js": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
@@ -34860,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.37",
"version": "0.1.48",
"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.37",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/expo-two-way-audio": "0.1.48",
"@getpaseo/highlight": "0.1.48",
"@getpaseo/server": "0.1.48",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34947,6 +34993,7 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
@@ -34985,11 +35032,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.37",
"version": "0.1.48",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/relay": "0.1.48",
"@getpaseo/server": "0.1.48",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35030,11 +35077,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.37",
"version": "0.1.48",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/cli": "0.1.48",
"@getpaseo/server": "0.1.48",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35068,7 +35115,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.37",
"version": "0.1.48",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35269,7 +35316,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.37",
"version": "0.1.48",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35295,7 +35342,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.37",
"version": "0.1.48",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35311,13 +35358,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.37",
"version": "0.1.48",
"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.37",
"@getpaseo/relay": "0.1.37",
"@getpaseo/highlight": "0.1.48",
"@getpaseo/relay": "0.1.48",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35333,11 +35381,11 @@
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"pi-acp": "^0.0.24",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"shell-env": "^4.0.3",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
@@ -35355,6 +35403,7 @@
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"playwright": "^1.56.1",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
@@ -35715,7 +35764,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.37",
"version": "0.1.48",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.37",
"version": "0.1.48",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -41,18 +41,23 @@
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
- "!v*-rc.*"
workflow_dispatch: {}
jobs:

View File

@@ -0,0 +1,106 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
connectArchiveTabDaemonClient,
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
await client?.close();
await tempRepo?.cleanup();
});
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
});

View File

@@ -0,0 +1,257 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
title: string;
cwd: string;
};
type ArchiveTabDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: 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 getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "codex",
model: "gpt-5.1-codex-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
}
return {
id: created.id,
title: input.title,
cwd: input.cwd,
};
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon, preferences, seedNonce },
);
await page.goto("/");
}
export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.goto("/");
}
export async function openWorkspaceWithAgents(
page: Page,
agents: [ArchiveTabAgent, ArchiveTabAgent],
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function expectWorkspaceArchiveOutcome(
page: Page,
input: { archivedAgentId: string; survivingAgentId: string },
): Promise<void> {
await expectWorkspaceTabHidden(page, input.archivedAgentId);
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
await waitForWorkspaceTabsVisible(page);
}
export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
function getSessionRowByTitle(page: Page, title: string) {
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
}
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
): Promise<void> {
const row = getSessionRowByTitle(page, input.title);
await expect(row).toBeVisible({ timeout: 30_000 });
const box = await row.boundingBox();
if (!box) {
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
await expectSessionRowArchived(page, input.title);
}

2
packages/app/maestro/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Maestro takeScreenshot artifacts
*.png

View File

@@ -0,0 +1,31 @@
appId: sh.paseo
---
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
# Small pause for close animation
- takeScreenshot: 00-sidebar-closed
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
# Verify sidebar opened
- assertVisible:
id: "sidebar-sessions"
- takeScreenshot: 01-sidebar-opened
# Close sidebar via swipe left
- swipe:
direction: LEFT
duration: 300
- takeScreenshot: 02-sidebar-closed-again

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Verification loop for sidebar theme bug.
#
# Maestro can't toggle iOS appearance, so this script bridges the gap:
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
# still works. Runs N iterations to catch intermittent failures.
#
# Usage:
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
ITERATIONS="${1:-3}"
WAIT_SECS="${2:-1}"
mkdir -p "$OUT_DIR"
echo "=== Sidebar Theme Bug Verification ==="
echo "Output dir: $OUT_DIR"
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
FAILURES=0
for i in $(seq 1 "$ITERATIONS"); do
echo ""
echo "========== Iteration $i / $ITERATIONS =========="
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
echo "Current appearance: $CURRENT"
if [ "$CURRENT" = "dark" ]; then
xcrun simctl ui booted appearance light
echo "Switched to light mode"
else
xcrun simctl ui booted appearance dark
echo "Switched to dark mode"
fi
echo "Waiting ${WAIT_SECS}s..."
sleep "$WAIT_SECS"
ITER_DIR="$OUT_DIR/iter-$i"
mkdir -p "$ITER_DIR"
# Run maestro from the output dir so takeScreenshot artifacts land there
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
echo " -> PASS (iteration $i)"
else
echo " -> FAIL (iteration $i) — bug reproduced!"
FAILURES=$((FAILURES + 1))
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
fi
done
# Restore to dark mode
xcrun simctl ui booted appearance dark
echo ""
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
echo "Output: $OUT_DIR"

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.37",
"version": "0.1.48",
"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.37",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/expo-two-way-audio": "0.1.48",
"@getpaseo/highlight": "0.1.48",
"@getpaseo/server": "0.1.48",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -111,6 +111,7 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",

View File

@@ -19,6 +19,12 @@
body {
overflow: hidden;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
html, body {
background-color: #181B1A;
}
}
/* These styles make the root element full-height */
#root {
display: flex;

View File

@@ -17,7 +17,6 @@ import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
import {
getHostRuntimeStore,
@@ -28,6 +27,7 @@ import {
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -57,7 +57,7 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsDesktop } from "@/constants/layout";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
@@ -68,8 +68,9 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -103,7 +104,7 @@ function PushNotificationRouter() {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsDesktop()) {
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
@@ -231,6 +232,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
@@ -241,28 +243,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
return;
}
})();
if (cancelled) {
return;
}
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
if (!cancelled) {
setPhase("online");
setError(null);
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
@@ -289,6 +316,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
cancelAnyOnline?.();
};
}, [retryToken]);
@@ -342,12 +370,12 @@ function AppContainer({
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isCompactLayout = isCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile,
isMobile: isCompactLayout,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
@@ -363,10 +391,12 @@ function AppContainer({
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
<View style={flexStyle}>{children}</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
@@ -375,7 +405,7 @@ function AppContainer({
</View>
);
if (!isMobile) {
if (!isCompactLayout) {
return content;
}
@@ -392,14 +422,28 @@ function MobileGestureWrapper({
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useSidebarAnimation();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
openAgentList();
}, [openAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
.failOffsetY([-10, 10])
@@ -442,7 +486,7 @@ function MobileGestureWrapper({
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
@@ -457,8 +501,9 @@ function MobileGestureWrapper({
backdropOpacity,
animateToOpen,
animateToClose,
openAgentList,
handleGestureOpen,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
],
@@ -475,6 +520,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const { theme } = useUnistyles();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
@@ -493,10 +539,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
void updateDesktopWindowControls({
backgroundColor: theme.colors.surface0,
foregroundColor: theme.colors.foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [settingsLoading, resolvedTheme]);
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
return (
<VoiceProvider>
@@ -550,6 +599,84 @@ function OfferLinkListener({
return null;
}
interface OpenProjectEventPayload {
path?: unknown;
}
function OpenProjectListener() {
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
}
void openProject(pathToOpen).catch(() => undefined);
};
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
}
})
.catch(() => undefined);
// Listen for hot-start paths relayed via the second-instance event.
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
if (disposed) {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
maybeOpenProject(nextPath);
})
.then((dispose) => {
if (disposed) {
dispose();
return;
}
unlisten = dispose;
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [client, openProject, serverId]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
@@ -601,6 +728,7 @@ function FaviconStatusSync() {
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
return (
<Stack
@@ -608,7 +736,7 @@ function RootStack() {
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
backgroundColor: theme.colors.surface0,
},
}}
>
@@ -652,8 +780,10 @@ function NavigationActiveWorkspaceObserver() {
}
export default function RootLayout() {
const { theme } = useUnistyles();
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
@@ -666,6 +796,7 @@ export default function RootLayout() {
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>

View File

@@ -1,4 +1,5 @@
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
@@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
}
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
if (isAbsolutePath(attachment.storageKey)) {
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
// UNC paths: \\server\share -> file://server/share
if (attachment.storageKey.startsWith("\\\\")) {
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
}
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
}
return attachment.storageKey;
},

View File

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

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
});
it("converts Windows drive-letter paths to file URIs", () => {
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
});
it("converts UNC paths to host-based file URIs", () => {
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
});
it("passes through file URIs unchanged", () => {
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
});
it("passes through relative paths unchanged", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path");
});
});

View File

@@ -1,4 +1,5 @@
import { generateMessageId } from "@/types/stream";
import { isAbsolutePath } from "@/utils/path";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
@@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
if (!isAbsolutePath(path)) {
return path;
}
if (path.startsWith("/")) {
return `file://${path}`;
}
return path;
// UNC paths: \\server\share -> file://server/share
if (path.startsWith("\\\\")) {
return `file:${path.replace(/\\/g, "/")}`;
}
return `file:///${path.replace(/\\/g, "/")}`;
}
export function fileUriToPath(uri: string): string {

View File

@@ -1444,11 +1444,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
maxHeight: 400,
overflow: "hidden",
},

View File

@@ -74,6 +74,8 @@ interface AgentInputAreaProps {
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
@@ -103,6 +105,7 @@ export function AgentInputArea({
clearDraft,
autoFocus = false,
onAddImages,
onFocusInput,
commandDraftConfig,
onMessageSent,
onComposerHeightChange,
@@ -208,6 +211,21 @@ export function AgentInputArea({
onAddImages?.(addImages);
}, [addImages, onAddImages]);
const focusInput = useCallback(() => {
if (Platform.OS !== "web") return;
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null;
return el != null && document.activeElement === el;
},
});
}, []);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, images?: ImageAttachment[]) => {
onMessageSent?.();
@@ -402,6 +420,10 @@ export function AgentInputArea({
}
switch (action.id) {
case "message-input.send":
return messageInputRef.current?.runKeyboardAction("send") ?? false;
case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus":
if (Platform.OS !== "web") {
messageInputRef.current?.focus();
@@ -440,8 +462,10 @@ export function AgentInputArea({
handlerId: keyboardHandlerIdRef.current,
actions: [
"message-input.focus",
"message-input.send",
"message-input.dictation-toggle",
"message-input.dictation-cancel",
"message-input.dictation-confirm",
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
@@ -616,7 +640,7 @@ export function AgentInputArea({
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} />
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
);
return (

View File

@@ -0,0 +1,31 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
describe("isProviderModelsQueryLoading", () => {
it("does not treat a disabled pending query as loading", () => {
const queryClient = new QueryClient();
const observer = new QueryObserver(queryClient, {
queryKey: ["providerModels", "server-1", "__missing_provider__"],
enabled: false,
queryFn: async () => [],
});
const result = observer.getCurrentResult();
expect(result.isPending).toBe(true);
expect(result.isLoading).toBe(false);
expect(result.isFetching).toBe(false);
expect(isProviderModelsQueryLoading(result)).toBe(false);
});
it("treats an active fetch as loading", () => {
expect(
isProviderModelsQueryLoading({
isLoading: false,
isFetching: true,
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,8 @@
interface ProviderModelsQueryState {
isFetching: boolean;
isLoading: boolean;
}
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
return input.isLoading || input.isFetching;
}

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
@@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => {
});
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
});
describe("normalizeModelId", () => {
it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull();

View File

@@ -3,12 +3,26 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import {
Brain,
ChevronDown,
ListTodo,
Settings2,
ShieldAlert,
ShieldCheck,
ShieldOff,
Zap,
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
toggleFavoriteModel,
useFormPreferences,
} from "@/hooks/use-form-preferences";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,27 +33,36 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentProvider,
} 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,
} from "@server/server/agent/provider-manifest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = {
id: string;
label: string;
};
type StatusSelector = "provider" | "mode" | "model" | "thinking";
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;
@@ -57,6 +80,14 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
};
export interface DraftAgentStatusBarProps {
@@ -76,12 +107,16 @@ export interface DraftAgentStatusBarProps {
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
disabled?: boolean;
}
interface AgentStatusBarProps {
agentId: string;
serverId: string;
onDropdownClose?: () => void;
}
function findOptionLabel(
@@ -96,6 +131,38 @@ function findOptionLabel(
return selected?.label ?? fallback;
}
const FEATURE_ICONS: Record<string, typeof Zap> = {
"list-todo": ListTodo,
zap: Zap,
};
function getFeatureIcon(icon?: string) {
return (icon && FEATURE_ICONS[icon]) || Settings2;
}
function getFeatureIconColor(
featureId: string,
enabled: boolean,
palette: {
blue: { 400: string };
yellow: { 400: string };
},
foregroundMuted: string,
): string {
if (!enabled) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(featureId)) {
case "blue":
return palette.blue[400];
case "yellow":
return palette.yellow[400];
default:
return foregroundMuted;
}
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
@@ -141,6 +208,14 @@ function ControlledStatusBar({
onSelectThinkingOption,
disabled = false,
isModelLoading = false,
providerDefinitions,
allProviderModels,
canSelectModelProvider,
favoriteKeys = new Set<string>(),
onToggleFavoriteModel,
features,
onSetFeature,
onDropdownClose,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
@@ -182,7 +257,8 @@ function ControlledStatusBar({
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length);
Boolean(thinkingOptions?.length) ||
Boolean(features?.length);
if (!hasAnyControl) {
return null;
@@ -204,6 +280,26 @@ function ControlledStatusBar({
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions],
);
const fallbackAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (!modelOptions || modelOptions.length === 0) {
return map;
}
map.set(
provider,
modelOptions.map((option) => ({
provider: provider as AgentProvider,
id: option.id,
label: option.label,
})),
);
return map;
}, [modelOptions, provider]);
const effectiveProviderDefinitions = providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions],
@@ -239,8 +335,11 @@ function ControlledStatusBar({
const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null);
if (!nextOpen) {
onDropdownClose?.();
}
},
[],
[onDropdownClose],
);
const handleSelectorPress = useCallback(
@@ -288,49 +387,37 @@ function ControlledStatusBar({
) : null}
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modelAnchorRef}
collapsable={false}
<Tooltip
key={`model-${displayModel}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<View>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
}
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
</>
onClose={onDropdownClose}
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
@@ -427,6 +514,105 @@ function ControlledStatusBar({
/>
</>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === `feature-${feature.id}`) &&
styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</>
) : (
<>
@@ -453,73 +639,39 @@ function ControlledStatusBar({
stackBehavior="replace"
testID="agent-preferences-sheet"
>
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-preferences-provider"
>
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => (
<View
style={[
styles.sheetSelect,
modelDisabled && styles.disabledSheetSelect,
]}
pointerEvents="none"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
)}
/>
</View>
) : null}
@@ -600,6 +752,83 @@ function ControlledStatusBar({
</DropdownMenu>
</View>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
</AdaptiveModalSheet>
</>
)}
@@ -609,7 +838,7 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => {
@@ -621,6 +850,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
currentModeId: currentAgent.currentModeId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId,
}
: null;
@@ -634,12 +864,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: [
"providerModels",
serverId,
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
@@ -654,6 +879,35 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
},
});
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : [];
}, [agent?.provider]);
const agentProviderModelQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
enabled: Boolean(client && agent?.cwd && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && agentProviderModelQuery.data) {
map.set(agent.provider, agentProviderModelQuery.data);
}
return map;
}, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null;
const displayMode =
@@ -678,6 +932,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const favoriteKeys = useMemo(
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
[preferences.favoriteModels],
);
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
@@ -697,6 +955,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions}
allProviderModels={agentProviderModels}
onSelectMode={(modeId) => {
if (!client) {
return;
@@ -726,6 +986,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[AgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
@@ -753,7 +1019,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
features={agent.features}
onSetFeature={(featureId, value) => {
if (!client) {
return;
}
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error);
});
}}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
onDropdownClose={onDropdownClose}
disabled={!client}
/>
);
@@ -776,9 +1052,13 @@ export function DraftAgentStatusBar({
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
features,
onSetFeature,
onDropdownClose,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
@@ -793,6 +1073,10 @@ export function DraftAgentStatusBar({
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const favoriteKeys = useMemo(
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
[preferences.favoriteModels],
);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
@@ -807,8 +1091,15 @@ export function DraftAgentStatusBar({
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
isLoading={isAllModelsLoading}
disabled={disabled}
onClose={onDropdownClose}
/>
<ControlledStatusBar
provider={selectedProvider}
@@ -818,40 +1109,47 @@ export function DraftAgentStatusBar({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
disabled={disabled}
/>
</View>
);
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
const modelOptions: StatusOption[] = models.map((model) => ({
id: model.id,
label: model.label,
}));
const modelOptions: StatusOption[] = [];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label });
}
return (
<ControlledStatusBar
provider={selectedProvider}
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
isModelLoading={isModelLoading}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
<>
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={(modelId) => onSelectModel(modelId)}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
disabled={disabled}
/>
</>
);
}

View File

@@ -1,6 +1,7 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case "fast_mode":
return "yellow";
case "plan_mode":
return "blue";
default:
return "default";
}
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;

View File

@@ -9,7 +9,6 @@ import {
useState,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -36,6 +35,7 @@ import {
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
@@ -59,9 +59,7 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -252,10 +250,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
@@ -269,44 +264,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[looseGap, tightGap],
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -368,7 +325,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
/>
);
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
@@ -755,90 +711,6 @@ function PermissionRequestCard({
return undefined;
}, [request]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}, []);
const permissionMutation = useMutation({
mutationFn: async (input: {
agentId: string;
@@ -891,50 +763,8 @@ function PermissionRequestCard({
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
{!isPlanRequest ? (
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
Proposed plan
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
const footer = (
<>
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
@@ -1007,6 +837,55 @@ function PermissionRequestCard({
)}
</Pressable>
</View>
</>
);
if (isPlanRequest && planMarkdown) {
return (
<PlanCard
title={title}
description={description}
text={planMarkdown}
footer={footer}
disableOuterSpacing
/>
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
{footer}
</View>
);
}
@@ -1127,14 +1006,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
...theme.shadow.sm,
},
scrollToBottomIcon: {
color: theme.colors.foreground,

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
resolveProviderLabel,
} from "./combined-model-selector.utils";
describe("combined model selector helpers", () => {
const providerDefinitions = [
{
id: "claude",
label: "Claude",
description: "Claude provider",
defaultModeId: "default",
modes: [],
},
{
id: "codex",
label: "Codex",
description: "Codex provider",
defaultModeId: "auto",
modes: [],
},
];
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "sonnet-4.6",
label: "Sonnet 4.6",
},
];
const codexModels: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
];
it("keeps enough data to search by model and provider name", async () => {
const rows = buildModelRows(providerDefinitions, new Map([
["claude", claudeModels],
["codex", codexModels],
]));
expect(rows).toEqual([
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
]);
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
});
it("builds an explicit trigger label for the selected provider and model", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
export type SelectorModelRow = FavoriteModelRow;
export function resolveProviderLabel(
providerDefinitions: AgentProviderDefinition[],
providerId: string,
): string {
return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
}
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
return modelLabel;
}
export function buildModelRows(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map<string, AgentModelDefinition[]>,
): SelectorModelRow[] {
const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
const rows: SelectorModelRow[] = [];
for (const definition of providerDefinitions) {
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
for (const model of allProviderModels.get(definition.id) ?? []) {
rows.push({
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
provider: definition.id,
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description,
});
}
}
return rows;
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
if (!normalizedQuery) {
return true;
}
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
}

View File

@@ -268,10 +268,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],

View File

@@ -0,0 +1,57 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout";
/**
* VS Code-style titlebar drag region for Electron.
*
* Copied from VS Code at commit daa0a70:
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
*
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
* no state, no event listeners. Interactive elements get no-drag from their own
* CSS (global backstop in index.html). The drag region never re-renders.
*
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
* On macOS, Electron handles edge resize natively.
*/
/**
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
* Place as FIRST child of any positioned container that should be draggable.
*/
export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
return null;
}
return (
<>
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
<div
style={{
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
}}
/>
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
<div
style={{
position: "absolute",
top: 0,
width: "100%",
height: 4,
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "no-drag",
}}
/>
</>
);
}

View File

@@ -108,11 +108,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
textContainer: {
flex: 1,

View File

@@ -20,7 +20,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -133,8 +133,11 @@ export function DraggableList<T>({
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(null);
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -177,7 +180,6 @@ export function DraggableList<T>({
);
const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const wrapperStyle = [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
@@ -193,12 +195,10 @@ export function DraggableList<T>({
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
onLayout={scrollbar.onLayout}
onContentSizeChange={scrollbar.onContentSizeChange}
onScroll={scrollbar.onScroll}
scrollEventThrottle={16}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
@@ -259,13 +259,7 @@ export function DraggableList<T>({
{ListFooterComponent}
</>
)}
<WebDesktopScrollbarOverlay
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
{scrollbar.overlay}
</View>
);
}

View File

@@ -1,10 +1,17 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import {
View,
Text,
Pressable,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import {
usePanelStore,
@@ -13,10 +20,12 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
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";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -39,7 +48,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -79,6 +88,7 @@ export function ExplorerSidebar({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useExplorerSidebarAnimation();
@@ -99,6 +109,11 @@ export function ExplorerSidebar({
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent, gestureAnimatingRef]);
const enableSidebarCloseGesture = isMobile && isOpen;
const handleTabPress = useCallback(
@@ -173,7 +188,7 @@ export function ExplorerSidebar({
});
if (shouldClose) {
animateToClose();
runOnJS(handleClose)("swipe-close-gesture");
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
@@ -188,7 +203,7 @@ export function ExplorerSidebar({
backdropOpacity,
animateToOpen,
animateToClose,
handleClose,
handleCloseFromGesture,
isGesturing,
closeGestureRef,
closeTouchStartX,
@@ -249,18 +264,13 @@ export function ExplorerSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable
style={styles.backdropPressable}
onPress={() => handleClose("backdrop-press")}
/>
</Animated.View>
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top },
explorerStaticStyles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
]}
@@ -289,25 +299,27 @@ export function ExplorerSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
<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)]}
/>
</GestureDetector>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
);
}
@@ -336,12 +348,14 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const padding = useWindowControlsPadding("explorerSidebar");
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
@@ -396,24 +410,28 @@ function SidebarContent({
);
}
const styles = StyleSheet.create((theme) => ({
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute",
position: "absolute" as const,
top: 0,
right: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden",
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative",
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
desktopSidebarBorder: {
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
@@ -432,6 +450,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
header: {
position: "relative",
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",

View File

@@ -4,9 +4,6 @@ import {
ActivityIndicator,
FlatList,
ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
Text,
View,
@@ -26,18 +23,17 @@ import Animated, {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
File,
FileText,
Folder,
FolderOpen,
Image as ImageIcon,
MoreVertical,
RotateCw,
X,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -54,10 +50,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -65,7 +58,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "size", label: "Size" },
];
const INDENT_PER_LEVEL = 12;
const INDENT_PER_LEVEL = 16;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
@@ -153,7 +146,9 @@ export function FileExplorerPane({
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
});
const hasInitializedRef = useRef(false);
@@ -384,7 +379,6 @@ export function FileExplorerPane({
({ item }: ListRenderItemInfo<TreeRow>) => {
const entry = item.entry;
const depth = item.depth;
const displayKind = getEntryDisplayKind(entry);
const isDirectory = entry.kind === "directory";
const isExpanded = isDirectory && expandedPaths.has(entry.path);
const isSelected = selectedEntryPath === entry.path;
@@ -399,16 +393,30 @@ export function FileExplorerPane({
(hovered || pressed || isSelected) && styles.entryRowActive,
]}
>
{depth > 0 &&
Array.from({ length: depth }, (_, i) => (
<View
key={i}
style={[
styles.indentGuide,
{
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
},
]}
/>
))}
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{loading ? (
<ActivityIndicator size="small" />
{isDirectory ? (
loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
) : (
renderEntryIcon(isDirectory ? "directory" : displayKind, {
foreground: theme.colors.foregroundMuted,
primary: theme.colors.primary,
directoryOpen: isExpanded,
})
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
@@ -490,24 +498,6 @@ export function FileExplorerPane({
});
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
(event: LayoutChangeEvent) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -552,27 +542,31 @@ export function FileExplorerPane({
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
<Pressable
onPress={handleSortCycle}
style={({ hovered, pressed }) => [
styles.sortTrigger,
(hovered || pressed) && styles.sortTriggerHovered,
]}
>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
</View>
<FlatList
ref={treeListRef}
@@ -582,126 +576,22 @@ export function FileExplorerPane({
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
onContentSizeChange={
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
{scrollbar.overlay}
</View>
)}
</View>
);
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
"md",
"markdown",
"ts",
"tsx",
"js",
"jsx",
"json",
"yml",
"yaml",
"toml",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"c",
"cpp",
"cc",
"h",
"hpp",
"cs",
"swift",
"php",
"html",
"css",
"scss",
"less",
"xml",
"sh",
"bash",
"zsh",
"ini",
"cfg",
"conf",
]);
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return colors.directoryOpen ? (
<FolderOpen size={18} color={colors.primary} />
) : (
<Folder size={18} color={colors.primary} />
);
case "image":
return <ImageIcon size={18} color={color} />;
case "text":
return <FileText size={18} color={color} />;
default:
return <File size={18} color={color} />;
}
}
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
if (entry.kind === "directory") {
return "directory";
}
const extension = getExtension(entry.name);
if (extension === null) {
return "other";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (TEXT_EXTENSIONS.has(extension)) {
return "text";
}
return "other";
}
function getExtension(name: string): string | null {
const index = name.lastIndexOf(".");
if (index === -1 || index === name.length - 1) {
return null;
}
return name.slice(index + 1).toLowerCase();
}
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
@@ -842,50 +732,38 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
paddingHorizontal: theme.spacing[3],
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
paneHeaderLeft: {
flex: 1,
minWidth: 0,
},
paneHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
previewHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
sortButton: {
height: 28,
sortTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
gap: theme.spacing[1],
marginLeft: theme.spacing[3] - theme.spacing[1],
paddingHorizontal: theme.spacing[1],
height: 24,
borderRadius: theme.borderRadius.base,
},
sortButtonText: {
color: theme.colors.foregroundMuted,
sortTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
sortTriggerText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
treeList: {
flex: 1,
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
centerState: {
@@ -936,8 +814,16 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surface1,
},
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
entryInfo: {
@@ -947,6 +833,16 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minWidth: 0,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
entryIcon: {
flexShrink: 0,
},

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from "react";
import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
@@ -7,17 +7,11 @@ import {
Text,
View,
Platform,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import {
highlightCode,
darkHighlightColors,
@@ -119,13 +113,14 @@ function FilePreviewBody({
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
@@ -140,24 +135,6 @@ function FilePreviewBody({
return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
@@ -197,13 +174,11 @@ function FilePreviewBody({
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
@@ -218,13 +193,7 @@ function FilePreviewBody({
</RNScrollView>
)}
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
{scrollbar.overlay}
</View>
);
}
@@ -236,13 +205,11 @@ function FilePreviewBody({
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={{
@@ -252,13 +219,7 @@ function FilePreviewBody({
resizeMode="contain"
/>
</RNScrollView>
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
{scrollbar.overlay}
</View>
);
}

View File

@@ -62,10 +62,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
@@ -86,9 +83,8 @@ interface HighlightedTextProps {
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const isDark = theme.colorScheme === "dark";
// Get color for a highlight style
const getTokenColor = (style: HighlightStyle | null): string => {
const baseColor = isDark ? "#c9d1d9" : "#24292f";
if (!style) return baseColor;
@@ -430,7 +426,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
@@ -516,11 +514,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onScroll(event);
}
scrollbar.onScroll(event);
},
[diffScrollbarMetrics, showDesktopWebScrollbar],
[scrollbar.onScroll],
);
const handleDiffListLayout = useCallback(
@@ -530,11 +526,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return;
}
diffListViewportHeightRef.current = height;
if (showDesktopWebScrollbar) {
diffScrollbarMetrics.onLayout(event);
}
scrollbar.onLayout(event);
},
[diffScrollbarMetrics, showDesktopWebScrollbar],
[scrollbar.onLayout],
);
const computeHeaderOffset = useCallback(
@@ -845,9 +839,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
testID="git-diff-scroll"
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
onContentSizeChange={
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh}
@@ -1086,16 +1078,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
<View style={styles.diffContainer}>
{bodyContent}
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar && hasChanges}
metrics={diffScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
diffListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
{hasChanges ? scrollbar.overlay : null}
</View>
</View>
);

View File

@@ -1,10 +1,11 @@
import type { ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { isCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
@@ -43,7 +44,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);

View File

@@ -1,14 +1,15 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
interface ScreenHeaderProps {
left?: ReactNode;
@@ -25,18 +26,11 @@ interface ScreenHeaderProps {
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const trafficLightPadding = useTrafficLightPadding();
const isMobile = isCompactFormFactor();
const padding = useWindowControlsPadding("header");
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers();
return (
<View style={styles.header}>
@@ -45,13 +39,13 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
style={[
styles.row,
{
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + padding.right,
},
borderless && styles.borderless,
]}
{...dragHandlers}
>
<TitlebarDragRegion />
<View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
</View>
@@ -66,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({
},
inner: {},
row: {
position: "relative",
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
@@ -90,6 +85,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
borderless: {
borderBottomWidth: 0,
borderBottomColor: "transparent",
},
}));

View File

@@ -0,0 +1,18 @@
import Svg, { Path } from "react-native-svg";
interface CopilotIconProps {
size?: number;
color?: string;
}
export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
<Path
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
fillRule="nonzero"
/>
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
</Svg>
);
}

View File

@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
interface OpenCodeIconProps {
size?: number;
color?: string;
}
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
return (
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
<Path d="M320 224V352H192V224H320Z" opacity={0.4} />
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
/>
</Svg>
);
}

View File

@@ -1,16 +1,20 @@
import Svg, { Path } from "react-native-svg";
import { useUnistyles } from "react-native-unistyles";
interface PaseoLogoProps {
size?: number;
color?: string;
}
export function PaseoLogo({ size = 64, color = "white" }: PaseoLogoProps) {
export function PaseoLogo({ size = 64, color }: PaseoLogoProps) {
const { theme } = useUnistyles();
const fill = color ?? theme.colors.foreground;
return (
<Svg width={size} height={size} viewBox="0 0 700 700" fill="none">
<Path
d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z"
fill={color}
fill={fill}
/>
</Svg>
);

View File

@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
interface PiIconProps {
size?: number;
color?: string;
}
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}
fillRule="evenodd"
/>
<Path d="M517.36 400 H634.72 V634.72 H517.36 Z" fill={color} />
</Svg>
);
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsDesktop } from "@/constants/layout";
import { getIsElectronRuntime } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -13,7 +13,7 @@ export function KeyboardShortcutsDialog() {
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsDesktop();
const isDesktopApp = getIsElectronRuntime();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac],

View File

@@ -10,7 +10,14 @@ import {
type RefObject,
type SetStateAction,
} from "react";
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
import {
View,
Pressable,
Text,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
@@ -20,7 +27,7 @@ import Animated, {
useSharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -35,11 +42,16 @@ import {
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
@@ -94,6 +106,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
isOpen: boolean;
handleViewMore: () => void;
}
@@ -105,7 +118,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isCompactLayout = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -175,7 +188,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
@@ -265,7 +278,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleHostSelect,
};
if (isMobile) {
if (isCompactLayout) {
return (
<MobileSidebar
{...sharedProps}
@@ -283,6 +296,7 @@ export const LeftSidebar = memo(function LeftSidebar({
return (
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleSettings={handleSettingsDesktop}
@@ -363,14 +377,16 @@ function MobileSidebar({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => {
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent]);
}, [closeToAgent, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
@@ -445,7 +461,7 @@ function MobileSidebar({
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
@@ -464,7 +480,7 @@ function MobileSidebar({
backdropOpacity,
animateToClose,
animateToOpen,
handleClose,
handleCloseFromGesture,
],
);
@@ -491,13 +507,11 @@ function MobileSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -519,7 +533,7 @@ function MobileSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onWorkspacePress={() => closeToAgent()}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
@@ -627,12 +641,12 @@ function DesktopSidebar({
handleHostSelect,
handleOpenProject,
handleSettings,
insetsTop,
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
@@ -681,13 +695,15 @@ function DesktopSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.side === 'left' ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
<Animated.View style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
</View>
@@ -788,33 +804,37 @@ function DesktopSidebar({
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create((theme) => ({
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const staticStyles = RNStyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute",
position: "absolute" as const,
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden",
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
desktopSidebar: {
position: "relative",
desktopSidebarBorder: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
@@ -827,6 +847,9 @@ const styles = StyleSheet.create((theme) => ({
width: 10,
zIndex: 10,
},
sidebarDragArea: {
position: "relative",
},
sidebarHeader: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,

View File

@@ -0,0 +1,141 @@
// Auto-generated from material-icon-theme. Do not edit manually.
const SVG_ICONS: Record<string, string> = {
"_default": `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
"astro": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
"c": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
"clojure": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
"console": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
"cpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"csharp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"css": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
"dart": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
"database": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
"document": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
"elixir": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
"erlang": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
"go": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
"gradle": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
"graphql": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
"groovy": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
"h": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"haskell": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
"hcl": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
"hpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"html": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
"image": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
"java": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
"javascript": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"json": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
"kotlin": `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
"less": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
"lock": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
"lua": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
"markdown": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
"nix": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
"ocaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
"php": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
"python": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
"r": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
"react": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"react_ts": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"ruby": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
"rust": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
"sass": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
"scala": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
"settings": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
"svelte": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
"svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
"swift": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
"terraform": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
"toml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
"typescript": `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"vue": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
"webassembly": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
"yaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
"zig": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
};
const EXTENSION_TO_ICON: Record<string, string> = {
"astro": "astro",
"bash": "console",
"c": "c",
"cfg": "settings",
"clj": "clojure",
"conf": "settings",
"cpp": "cpp",
"cs": "csharp",
"css": "css",
"dart": "dart",
"erl": "erlang",
"ex": "elixir",
"exs": "elixir",
"gif": "image",
"go": "go",
"gql": "graphql",
"gradle": "gradle",
"graphql": "graphql",
"groovy": "groovy",
"h": "h",
"hcl": "hcl",
"hpp": "hpp",
"hs": "haskell",
"html": "html",
"ico": "image",
"ini": "settings",
"java": "java",
"jpeg": "image",
"jpg": "image",
"js": "javascript",
"json": "json",
"jsx": "react",
"kt": "kotlin",
"less": "less",
"lock": "lock",
"lua": "lua",
"markdown": "markdown",
"md": "markdown",
"ml": "ocaml",
"nix": "nix",
"php": "php",
"png": "image",
"py": "python",
"r": "r",
"rb": "ruby",
"rs": "rust",
"scala": "scala",
"scss": "sass",
"sh": "console",
"sql": "database",
"svelte": "svelte",
"svg": "svg",
"swift": "swift",
"tf": "terraform",
"toml": "toml",
"ts": "typescript",
"tsx": "react_ts",
"txt": "document",
"vue": "vue",
"wasm": "webassembly",
"webp": "image",
"xml": "xml",
"yaml": "yaml",
"yml": "yaml",
"zig": "zig",
};
export function getFileIconSvg(fileName: string): string {
const ext = getExtension(fileName);
if (ext) {
const iconName = EXTENSION_TO_ICON[ext];
if (iconName && SVG_ICONS[iconName]) return SVG_ICONS[iconName];
}
return SVG_ICONS["_default"];
}
function getExtension(name: string): string | null {
const idx = name.lastIndexOf(".");
if (idx === -1 || idx === name.length - 1) return null;
return name.slice(idx + 1).toLowerCase();
}

View File

@@ -12,7 +12,15 @@ import {
Platform,
BackHandler,
} from "react-native";
import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react";
import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
useImperativeHandle,
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
@@ -33,6 +41,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { focusWithRetries } from "@/utils/web-focus";
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 type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import {
@@ -91,7 +100,7 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native.
@@ -233,26 +242,35 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
runKeyboardAction: (action) => {
if (action === "focus") {
textInputRef.current?.focus();
return;
return true;
}
if (action === "send" || action === "dictation-confirm") {
if (isDictatingRef.current) {
sendAfterTranscriptRef.current = true;
confirmDictation();
return true;
}
return false;
}
if (action === "voice-toggle") {
handleToggleRealtimeVoiceShortcut();
return;
return true;
}
if (action === "voice-mute-toggle") {
if (isRealtimeVoiceForCurrentAgent) {
voice?.toggleMute();
}
return;
return true;
}
if (action === "dictation-cancel") {
if (isDictatingRef.current) {
cancelDictation();
}
return;
return true;
}
if (action === "dictation-toggle") {
@@ -262,7 +280,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
} else {
void startDictationIfAvailable();
}
return true;
}
return false;
},
getNativeElement: () => {
if (!IS_WEB) return null;
@@ -570,6 +591,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return null;
}, []);
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (IS_WEB) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
const ref = target === "root" ? rootRef.current : inputWrapperRef.current;
if (!ref) return null;
@@ -911,42 +944,45 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
)}
{/* Text input */}
<TextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
onFocusChange?.(false);
}}
style={[
styles.textInput,
IS_WEB
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
}
: {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
/>
<View style={styles.textInputScrollWrapper}>
<TextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
onFocusChange?.(false);
}}
style={[
styles.textInput,
IS_WEB
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
}
: {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
/>
{inputScrollbar}
</View>
{/* Button row */}
<View style={styles.buttonRow}>
@@ -1187,6 +1223,9 @@ const styles = StyleSheet.create(((theme: any) => ({
removeImageButtonVisible: {
opacity: 1,
},
textInputScrollWrapper: {
position: "relative",
},
textInput: {
width: "100%",
color: theme.colors.foreground,

View File

@@ -71,6 +71,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
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";
@@ -716,13 +717,6 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
console.log("[AssistantMessage] render", {
messageLength: message?.length,
timestamp,
hasOnInlinePathPress: !!onInlinePathPress,
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
@@ -1764,9 +1758,6 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
}: ToolCallProps) {
// DEBUG: log when ToolCall actually renders (inside memo boundary)
console.log("[ToolCall] render", { toolName, status });
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -1884,6 +1875,12 @@ export const ToolCall = memo(function ToolCall({
);
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
if (effectiveDetail?.type === "plan") {
return (
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
);
}
return (
<ExpandableBadge
testID="tool-call-badge"

View File

@@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
function createPlanMarkdownRules() {
return {
text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}
export function PlanCard({
title = "Plan",
description,
text,
footer,
disableOuterSpacing = false,
}: {
title?: string;
description?: string;
text: string;
footer?: ReactNode;
disableOuterSpacing?: boolean;
}) {
const { theme } = useUnistyles();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
return (
<View
style={[
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}
</Markdown>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
},
containerCompact: {
marginVertical: 0,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
footer: {
gap: theme.spacing[2],
},
}));

View File

@@ -269,10 +269,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],

View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { 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";
interface ProviderDiagnosticSheetProps {
provider: string;
visible: boolean;
onClose: () => void;
serverId: string;
}
export function ProviderDiagnosticSheet({
provider,
visible,
onClose,
serverId,
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
setLoading(true);
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]);
useEffect(() => {
if (visible) {
fetchDiagnostic();
} else {
setDiagnostic(null);
}
}, [visible, fetchDiagnostic]);
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}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
</ScrollView>
) : null}
</AdaptiveModalSheet>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
loadingContainer: {
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,16 @@
import { Bot } from "lucide-react-native";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { PiIcon } from "@/components/icons/pi-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
copilot: CopilotIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
pi: PiIcon as unknown as typeof Bot,
};
export function getProviderIcon(provider: string): typeof Bot {

View File

@@ -119,6 +119,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
});
function handleSubmit() {
if (!allAnswered || isResponding) return;
setRespondingAction("submit");
const answers: Record<string, string> = {};
for (let i = 0; i < questions!.length; i++) {
@@ -228,7 +229,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
placeholderTextColor={theme.colors.foregroundMuted}
value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)}
onSubmitEditing={handleSubmit}
editable={!isResponding}
blurOnSubmit={false}
/>
</View>
);

View File

@@ -22,11 +22,12 @@ import {
} from "react";
import { router, usePathname } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
Archive,
CircleAlert,
ChevronDown,
ChevronRight,
Copy,
@@ -37,12 +38,13 @@ import {
Monitor,
MoreVertical,
Plus,
Trash2,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { getIsDesktop } from "@/constants/layout";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -52,13 +54,7 @@ import {
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
useContextMenu,
} from "@/components/ui/context-menu";
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -73,7 +69,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -100,11 +96,20 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
const EMPTY_WORKSPACES = new Map();
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
function getWorkspacePrIconColor(theme: ReturnType<typeof useUnistyles>["theme"], state: PrHint["state"]) {
switch (state) {
case "merged":
return theme.colors.palette.purple[500];
case "open":
return theme.colors.palette.green[500];
case "closed":
return theme.colors.palette.red[500];
}
}
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
@@ -140,6 +145,8 @@ interface ProjectHeaderRowProps {
isDragging: boolean;
isArchiving?: boolean;
menuController: ReturnType<typeof useContextMenu> | null;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
dragHandleProps?: DraggableListDragHandleProps;
}
@@ -167,7 +174,8 @@ interface WorkspaceRowInnerProps {
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const textColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
@@ -195,17 +203,17 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
pressed && styles.workspacePrBadgePressed,
]}
>
<GitPullRequest size={12} color={activeColor} />
<GitPullRequest size={12} color={iconColor} />
<Text
style={[
styles.workspacePrBadgeText,
{ color: activeColor },
{ color: textColor },
]}
numberOfLines={1}
>
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
#{hint.number}
</Text>
{isHovered && <ExternalLink size={10} color={activeColor} />}
{isHovered && <ExternalLink size={10} color={textColor} />}
</Pressable>
);
}
@@ -238,6 +246,14 @@ function WorkspaceStatusIndicator({
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
@@ -247,6 +263,13 @@ function WorkspaceStatusIndicator({
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot}>
@@ -258,6 +281,10 @@ function WorkspaceStatusIndicator({
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -327,11 +354,26 @@ function ProjectLeadingVisual({
);
}
if (activeWorkspace.statusBucket === "needs_input") {
return (
<View style={styles.projectLeadingVisualSlot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.projectLeadingVisualSlot}>
@@ -343,6 +385,10 @@ function ProjectLeadingVisual({
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -661,11 +707,13 @@ function ProjectHeaderRow({
isDragging,
isArchiving = false,
menuController,
onRemoveProject,
removeProjectStatus = "idle",
dragHandleProps,
}: ProjectHeaderRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobileBreakpoint = isCompactFormFactor();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast();
@@ -750,16 +798,55 @@ function ProjectHeaderRow({
</Text>
</View>
</View>
{canCreateWorktree ? (
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
) : null}
<View style={styles.projectTrailingActions}>
{canCreateWorktree ? (
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
) : null}
{onRemoveProject ? (
<View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
>
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.projectKebabButton,
hovered && styles.projectKebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${project.projectKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${project.projectKey}`}
leading={<Trash2 size={14} color={theme.colors.foregroundMuted} />}
status={removeProjectStatus}
pendingLabel="Removing..."
onSelect={onRemoveProject}
>
Remove project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
</View>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
@@ -836,7 +923,7 @@ function WorkspaceRowInner({
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobile = Platform.OS !== "web";
const isTouchPlatform = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
@@ -901,7 +988,7 @@ function WorkspaceRowInner({
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? (
{onArchive && (isHovered || isTouchPlatform) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
@@ -1167,160 +1254,6 @@ function WorkspaceRowWithMenu({
);
}
function NonGitProjectRowWithMenuContent({
project,
displayName,
iconDataUri,
workspace,
selected,
onPress,
shortcutNumber,
showShortcutBadge,
drag,
isDragging,
dragHandleProps,
}: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry;
selected: boolean;
onPress: () => void;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return;
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any,
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
redirectAfterArchive();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
return (
<>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
chevron={null}
onPress={onPress}
serverId={null}
canCreateWorktree={false}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
isArchiving={isArchivingWorkspace}
menuController={contextMenu}
dragHandleProps={dragHandleProps}
/>
<ContextMenuContent
align="start"
width={220}
mobileMode="sheet"
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isArchivingWorkspace ? "pending" : "idle"}
pendingLabel="Hiding..."
destructive
onSelect={handleArchiveWorkspace}
>
Hide from sidebar
</ContextMenuItem>
</ContextMenuContent>
</>
);
}
function NonGitProjectRowWithMenu(props: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry;
selected: boolean;
onPress: () => void;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
return (
<ContextMenu>
<NonGitProjectRowWithMenuContent {...props} />
</ContextMenu>
);
}
function FlattenedProjectRow({
project,
displayName,
@@ -1336,6 +1269,8 @@ function FlattenedProjectRow({
isDragging,
dragHandleProps,
isProjectActive = false,
onRemoveProject,
removeProjectStatus,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -1351,25 +1286,9 @@ function FlattenedProjectRow({
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
isProjectActive?: boolean;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
}) {
if (project.projectKind === "non_git") {
return (
<NonGitProjectRowWithMenu
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={rowModel.workspace}
selected={rowModel.selected}
onPress={onPress}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
}
return (
<ProjectHeaderRow
project={project}
@@ -1389,6 +1308,8 @@ function FlattenedProjectRow({
drag={drag}
isDragging={isDragging}
menuController={null}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
dragHandleProps={dragHandleProps}
/>
);
@@ -1559,6 +1480,48 @@ function ProjectBlock({
[onWorkspaceReorder, project.projectKey],
);
const toast = useToast();
const [isRemovingProject, setIsRemovingProject] = useState(false);
const handleRemoveProject = useCallback(() => {
if (isRemovingProject || !serverId) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Remove project?",
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Remove",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsRemovingProject(true);
try {
for (const ws of project.workspaces) {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to remove project");
} finally {
setIsRemovingProject(false);
}
})();
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
return (
<View style={styles.projectBlock}>
{rowModel.kind === "workspace_link" ? (
@@ -1583,6 +1546,8 @@ function ProjectBlock({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
isProjectActive={isProjectActive}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
/>
) : (
<>
@@ -1601,7 +1566,10 @@ function ProjectBlock({
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
/>
@@ -1638,7 +1606,7 @@ export function SidebarWorkspaceList({
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = isCompactFormFactor();
const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
@@ -1646,7 +1614,7 @@ export function SidebarWorkspaceList({
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const isDesktopApp = getIsDesktop();
const isDesktopApp = getIsElectronRuntime();
const altDown = useKeyboardShortcutsStore((state) => state.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);
@@ -2023,11 +1991,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
...theme.shadow.md,
},
projectRowLeft: {
flexDirection: "row",
@@ -2106,6 +2070,26 @@ const styles = StyleSheet.create((theme) => ({
projectIconActionButtonHidden: {
opacity: 0,
},
projectTrailingActions: {
flexDirection: "row",
alignItems: "center",
gap: 2,
flexShrink: 0,
},
projectKebabButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
projectKebabButtonHidden: {
opacity: 0,
},
projectKebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
projectTrailingControlSlot: {
width: 24,
height: 24,
@@ -2170,11 +2154,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surface1,
@@ -2193,10 +2173,10 @@ const styles = StyleSheet.create((theme) => ({
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
width: 7,
height: 7,
right: DEFAULT_STATUS_DOT_OFFSET,
bottom: DEFAULT_STATUS_DOT_OFFSET,
width: DEFAULT_STATUS_DOT_SIZE,
height: DEFAULT_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},

View File

@@ -32,7 +32,8 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store";
import { useTrafficLightPadding } from "@/utils/desktop-window";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import {
computeTabDropPreview,
type TabDropPreview,
@@ -83,6 +84,7 @@ interface SplitContainerProps {
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
@@ -175,18 +177,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
paneId,
buildPaneContentModel,
}: MountedTabSlotProps) {
useEffect(() => {
if (tabDescriptor.target.kind !== "terminal") {
return;
}
console.log("[terminal-tab-slot]", {
paneId,
tabId: tabDescriptor.tabId,
terminalId: tabDescriptor.target.terminalId,
isVisible,
isPaneFocused,
});
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
const content = useMemo(
() =>
@@ -264,6 +254,7 @@ export function SplitContainer({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -533,6 +524,7 @@ export function SplitContainer({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -655,6 +647,7 @@ function SplitNodeView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -690,6 +683,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -740,6 +734,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -789,6 +784,7 @@ function SplitPaneView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -809,8 +805,7 @@ function SplitPaneView({
const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled);
const trafficLightPadding = useTrafficLightPadding();
const padding = useWindowControlsPadding("tabRow");
const paneState = useMemo(
() =>
deriveWorkspacePaneState({
@@ -885,13 +880,10 @@ function SplitPaneView({
<View
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
{ paddingLeft: padding.left, paddingRight: padding.right },
]}
>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
@@ -904,6 +896,7 @@ function SplitPaneView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
@@ -1002,6 +995,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
paneTabs: {
position: "relative",
minWidth: 0,
},
paneContent: {

View File

@@ -23,21 +23,7 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style";
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
scrollbar-width: none;
-ms-overflow-style: none;
}
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
`;
import { useWebElementScrollbar } from "./use-web-scrollbar";
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging.
@@ -119,8 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar");
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const [followOutput, setFollowOutputr] = useState(true);
@@ -142,8 +126,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
contentRef,
});
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -271,33 +258,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange(true);
return;
}
streamScrollbarMetrics.onContentSizeChange(
scrollContainer.clientWidth,
scrollContainer.scrollHeight,
);
streamScrollbarMetrics.onLayout({
nativeEvent: {
layout: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
x: 0,
y: 0,
},
},
} as never);
streamScrollbarMetrics.onScroll({
nativeEvent: {
contentOffset: { x: 0, y: scrollContainer.scrollTop },
contentSize: {
width: scrollContainer.clientWidth,
height: scrollContainer.scrollHeight,
},
layoutMeasurement: {
width: scrollContainer.clientWidth,
height: scrollContainer.clientHeight,
},
},
} as never);
syncNearBottom(scrollContainer, onNearBottomChange);
const currentMetrics = {
scrollTop: scrollContainer.scrollTop,
@@ -323,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...currentMetrics,
});
}
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics]);
}, [onNearBottomChange, props.agentId]);
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
@@ -711,7 +671,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return (
<>
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
<div
ref={(node) => {
scrollContainerRef.current = node;
@@ -759,20 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={false}
onScrollToOffset={(nextOffset) => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" });
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
updateScrollMetrics();
}}
/>
{scrollbarOverlay}
</>
);
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -21,6 +21,7 @@ import {
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { isCompactFormFactor } from "@/constants/layout";
interface TerminalPaneProps {
serverId: string;
@@ -91,7 +92,7 @@ export function TerminalPane({
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);

View File

@@ -259,11 +259,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
toastSuccess: {
borderColor: theme.colors.border,

View File

@@ -248,11 +248,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
detailLabel: {
color: theme.colors.foreground,
@@ -275,11 +271,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
scrollView: {
flexGrow: 0,

View File

@@ -136,9 +136,11 @@ export function Button({
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const color = variant === "default"
? theme.colors.accentForeground
: variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function

View File

@@ -65,6 +65,7 @@ export interface ComboboxProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -72,6 +73,12 @@ export interface ComboboxProps {
* for that combobox instance to avoid animation overriding hidden opacity.
*/
desktopPreventInitialFlash?: boolean;
/** Minimum width for the desktop popover (overrides trigger-based width). */
desktopMinWidth?: number;
/** Fixed height for the desktop popover (overrides default 400px max). */
desktopFixedHeight?: number;
/** Content rendered above the scroll area on desktop (sticky header). */
stickyHeader?: ReactNode;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
@@ -145,8 +152,12 @@ export interface ComboboxItemProps {
description?: string;
kind?: "directory" | "file";
leadingSlot?: ReactNode;
trailingSlot?: ReactNode;
selected?: boolean;
active?: boolean;
disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
onPress: () => void;
testID?: string;
}
@@ -156,8 +167,11 @@ export function ComboboxItem({
description,
kind,
leadingSlot,
trailingSlot,
selected,
active,
disabled,
elevated,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -178,12 +192,14 @@ export function ComboboxItem({
return (
<Pressable
testID={testID}
disabled={disabled}
onPress={onPress}
style={({ pressed, hovered = false }) => [
styles.comboboxItem,
hovered && styles.comboboxItemHovered,
pressed && styles.comboboxItemPressed,
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
]}
>
{leadingContent}
@@ -197,9 +213,12 @@ export function ComboboxItem({
</Text>
) : null}
</View>
{selected ? (
<View style={styles.comboboxItemTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
</View>
{trailingSlot}
</View>
) : null}
</Pressable>
@@ -233,8 +252,12 @@ export function Combobox({
open,
onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
desktopMinWidth,
desktopFixedHeight,
stickyHeader,
anchorRef,
children,
}: ComboboxProps): ReactElement {
@@ -379,12 +402,27 @@ export function Combobox({
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
// For top-placed popups: once position resolves, use bottom-based CSS positioning
// so height changes grow upward naturally without floating-ui needing to reposition.
const useStableBottom =
!isDesktopAboveSearch &&
IS_WEB &&
!isMobile &&
hasResolvedDesktopPosition &&
desktopPlacement.startsWith("top") &&
referenceTop !== null;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles;
: useStableBottom
? {
left: floatingLeft ?? 0,
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
}
: floatingStyles;
useEffect(() => {
if (!isMobile) return;
@@ -642,6 +680,7 @@ export function Combobox({
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
@@ -650,6 +689,7 @@ export function Combobox({
<View style={styles.bottomSheetHeader}>
<Text style={styles.comboboxTitle}>{title}</Text>
</View>
{stickyHeader}
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
@@ -675,13 +715,16 @@ export function Combobox({
styles.desktopContainer,
{
position: "absolute",
minWidth: referenceWidth ?? 200,
maxWidth: 400,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
maxWidth: Math.max(400, desktopMinWidth ?? 0),
},
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null,
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number"
? { maxHeight: Math.min(availableSize.height, 400) }
? { maxHeight: Math.min(availableSize.height, desktopFixedHeight ?? 400) }
: null,
]}
ref={refs.setFloating}
@@ -689,14 +732,17 @@ export function Combobox({
onLayout={() => update()}
>
{children ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{content}
</ScrollView>
<>
{stickyHeader}
<ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{content}
</ScrollView>
</>
) : (
<>
{effectiveOptionsPosition === "above-search" ? (
@@ -771,16 +817,30 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemHovered: {
backgroundColor: theme.colors.surface1,
},
comboboxItemHoveredElevated: {
backgroundColor: theme.colors.surface2,
},
comboboxItemPressed: {
backgroundColor: theme.colors.surface1,
},
comboboxItemPressedElevated: {
backgroundColor: theme.colors.surface2,
},
comboboxItemActive: {
backgroundColor: theme.colors.surface1,
},
comboboxItemDisabled: {
opacity: 0.55,
},
comboboxItemTrailingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
comboboxItemTrailingContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginLeft: "auto",
},
comboboxItemContent: {
@@ -845,11 +905,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
maxHeight: 400,
overflow: "hidden",
},
@@ -860,6 +916,9 @@ const styles = StyleSheet.create((theme) => ({
desktopScrollContent: {
paddingVertical: theme.spacing[1],
},
desktopChildrenScrollContent: {
// No padding — custom children (e.g. model selector) control their own spacing
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: "flex-end",

View File

@@ -30,6 +30,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -595,6 +596,7 @@ export function ContextMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -612,6 +614,7 @@ export function ContextMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
@@ -642,7 +645,7 @@ export function ContextMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -704,6 +707,19 @@ export function ContextMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -722,11 +738,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
overflow: "hidden",
},
sheetBackground: {
@@ -766,6 +778,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",

View File

@@ -27,6 +27,7 @@ import {
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";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -471,6 +472,7 @@ export function DropdownMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -491,6 +493,7 @@ export function DropdownMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
@@ -524,7 +527,7 @@ export function DropdownMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -586,6 +589,19 @@ export function DropdownMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -605,11 +621,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
labelContainer: {
paddingHorizontal: theme.spacing[3],
@@ -634,6 +646,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",

View File

@@ -0,0 +1,65 @@
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type StatusBadgeVariant = "success" | "error" | "muted";
interface StatusBadgeProps {
label: string;
variant?: StatusBadgeVariant;
}
export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) {
const { theme } = useUnistyles();
return (
<View
style={[
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
]}
>
<Text
style={[
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
]}
>
{label}
</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
pill: {
flexDirection: "row",
alignItems: "center",
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
paddingHorizontal: theme.spacing[2],
paddingVertical: 3,
},
pillSuccess: {
backgroundColor: theme.colors.palette.green[900],
borderColor: theme.colors.palette.green[800],
},
pillError: {
backgroundColor: theme.colors.palette.red[900],
borderColor: theme.colors.palette.red[800],
},
pillText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
pillTextSuccess: {
color: theme.colors.palette.green[400],
},
pillTextError: {
color: theme.colors.palette.red[500],
},
}));

View File

@@ -535,11 +535,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
...theme.shadow.sm,
zIndex: 1000,
},
}));

View File

@@ -0,0 +1,153 @@
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import {
Platform,
type FlatList,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollView,
} from "react-native";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
type ScrollbarMetrics,
} from "./web-desktop-scrollbar";
const METRICS_EPSILON = 0.5;
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
function ensureHideScrollbarStyle(): void {
if (typeof document === "undefined") return;
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
const style = document.createElement("style");
style.id = HIDE_SCROLLBAR_STYLE_ID;
style.textContent =
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
document.head.appendChild(style);
}
function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
return (
Math.abs(a.offset - b.offset) > METRICS_EPSILON ||
Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON ||
Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON
);
}
// ── DOM element scrollbar ────────────────────────────────────────────
// Fully automatic: listens to scroll/input/resize events on the element,
// hides the native scrollbar, and returns a themed overlay or null.
export function useWebElementScrollbar(
elementRef: RefObject<HTMLElement | null>,
options?: {
enabled?: boolean;
contentRef?: RefObject<HTMLElement | null>;
},
): ReactNode {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const contentRef = options?.contentRef;
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
viewportSize: 0,
contentSize: 0,
});
useEffect(() => {
if (!enabled) return;
const element = elementRef.current;
if (!element) return;
element.setAttribute("data-hide-scrollbar", "");
(element.style as any).scrollbarWidth = "none";
(element.style as any).msOverflowStyle = "none";
ensureHideScrollbarStyle();
function update() {
const el = elementRef.current;
if (!el) return;
const next: ScrollbarMetrics = {
offset: el.scrollTop,
viewportSize: el.clientHeight,
contentSize: el.scrollHeight,
};
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
}
element.addEventListener("scroll", update, { passive: true });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
const contentElement = contentRef?.current;
if (contentElement) {
resizeObserver.observe(contentElement);
}
update();
return () => {
element.removeEventListener("scroll", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
(element.style as any).scrollbarWidth = "";
(element.style as any).msOverflowStyle = "";
};
}, [contentRef, elementRef, enabled]);
const onScrollToOffset = useCallback(
(offset: number) => {
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
},
[elementRef],
);
if (!enabled) return null;
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
}
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
// Returns event handlers to wire onto your ScrollView/FlatList plus
// a renderable overlay. The overlay is null when disabled.
interface WebScrollViewScrollbar {
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onLayout: (event: LayoutChangeEvent) => void;
onContentSizeChange: (width: number, height: number) => void;
overlay: ReactNode;
}
export function useWebScrollViewScrollbar(
scrollableRef: RefObject<ScrollView | FlatList | null>,
options?: { enabled?: boolean },
): WebScrollViewScrollbar {
const isWeb = Platform.OS === "web";
const enabled = (options?.enabled ?? true) && isWeb;
const metricsHook = useWebDesktopScrollbarMetrics();
const onScrollToOffset = useCallback(
(offset: number) => {
const scrollable = scrollableRef.current;
if (!scrollable) return;
if ("scrollToOffset" in scrollable) {
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
} else {
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
}
},
[scrollableRef],
);
const overlay: ReactNode = enabled ? (
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
) : null;
return {
onScroll: metricsHook.onScroll,
onLayout: metricsHook.onLayout,
onContentSizeChange: metricsHook.onContentSizeChange,
overlay,
};
}

View File

@@ -25,11 +25,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
...theme.shadow.md,
},
buttonIdle: {
backgroundColor: theme.colors.surface2,

View File

@@ -37,7 +37,7 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
type ScrollbarMetrics = {
export type ScrollbarMetrics = {
offset: number;
viewportSize: number;
contentSize: number;
@@ -350,8 +350,7 @@ export function WebDesktopScrollbarOverlay({
? HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const isDark = theme.colors.surface0 === "#18181c";
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
const handleColor = theme.colors.scrollbarHandle;
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -20,6 +20,7 @@ import { resolveAppVersion } from "@/utils/app-version";
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";
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -118,6 +119,25 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
setupHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginBottom: theme.spacing[6],
lineHeight: theme.fontSize.sm * 1.5,
},
setupLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
marginBottom: theme.spacing[6],
},
setupLinkText: {
color: theme.colors.accent,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
versionLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -320,12 +340,27 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID="welcome-screen"
>
<View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} />
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
{!showHostList && Platform.OS !== "web" && (
<>
<Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first.
</Text>
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
</>
)}
<View style={styles.actions}>
{actions.map((action) => {
const Icon = action.icon;

View File

@@ -1,5 +1,6 @@
import { Platform } from "react-native";
import { isDesktop, isDesktopMac } from "@/desktop/host";
import { UnistylesRuntime } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75;
@@ -23,51 +24,59 @@ 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 desktop app (any OS)
function isDesktopEnvironment(): boolean {
// Check if running in the Electron desktop runtime (any OS)
function isElectronDesktopRuntime(): boolean {
if (Platform.OS !== "web") return false;
return isDesktop();
return isElectronRuntime();
}
// Check if running in desktop host on macOS
function isDesktopEnvironmentMac(): boolean {
// Check if running in the Electron desktop runtime on macOS
function isElectronDesktopRuntimeMac(): boolean {
if (Platform.OS !== "web") return false;
return isDesktopMac();
return isElectronRuntimeMac();
}
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isDesktopMacCached: boolean | null = null;
let _isDesktopCached: boolean | null = null;
let _isElectronRuntimeMacCached: boolean | null = null;
let _isElectronRuntimeCached: boolean | null = null;
export function getIsDesktopMac(): boolean {
if (_isDesktopMacCached === true) {
export function getIsElectronRuntimeMac(): boolean {
if (_isElectronRuntimeMacCached === true) {
return true;
}
const result = isDesktopEnvironmentMac();
const result = isElectronDesktopRuntimeMac();
if (result) {
_isDesktopMacCached = true;
_isElectronRuntimeMacCached = true;
}
return result;
}
export function getIsDesktop(): boolean {
if (_isDesktopCached === true) {
export function getIsElectronRuntime(): boolean {
if (_isElectronRuntimeCached === true) {
return true;
}
const result = isDesktopEnvironment();
const result = isElectronDesktopRuntime();
if (result) {
_isDesktopCached = true;
_isElectronRuntimeCached = true;
}
return result;
}
// Get traffic light padding values (only non-zero on desktop macOS)
export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsDesktopMac()) {
return { left: 0, top: 0 };
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
export function isCompactFormFactor(): boolean {
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
}
export function isDesktopFormFactor(): boolean {
return !isCompactFormFactor();
}
export function isTouchDesktopFormFactor(): boolean {
return Platform.OS !== "web" && isDesktopFormFactor();
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
// 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";
}

View File

@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { isCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
@@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue {
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
@@ -27,18 +29,20 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isCompactLayout = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
@@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
return;
}
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
@@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}}
>

View File

@@ -1105,6 +1105,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
return;
}

View File

@@ -10,7 +10,7 @@ import {
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { isCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
@@ -27,6 +27,8 @@ interface SidebarAnimationContextValue {
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
@@ -34,18 +36,20 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isCompactLayout = isCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
@@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
return;
}
// Gesture onEnd already started the animation on the UI thread — skip to avoid
// a second competing withTiming that can desync translateX and backdropOpacity
// after a provider remount (e.g. theme change).
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
@@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[
@@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
],
);

View File

@@ -9,7 +9,7 @@ import { settingsStyles } from "@/styles/settings";
export function DesktopPermissionsSection() {
const { theme } = useUnistyles();
const {
isDesktop,
isDesktopApp,
snapshot,
isRefreshing,
requestingPermission,
@@ -20,7 +20,7 @@ export function DesktopPermissionsSection() {
sendTestNotification,
} = useDesktopPermissions();
if (!isDesktop) {
if (!isDesktopApp) {
return null;
}

View File

@@ -10,19 +10,19 @@ import {
Play,
Pause,
RotateCw,
Terminal,
Copy,
FileText,
Smartphone,
Activity,
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliSymlinkInstructions,
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
@@ -30,7 +30,6 @@ import {
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type CliSymlinkInstructions,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
@@ -50,28 +49,26 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
.then(([status, logs]) => {
setDaemonStatus(status);
setDaemonLogs(logs);
setDaemonVersion(version.version);
setDaemonVersion(status.version);
setStatusError(null);
})
.catch((error) => {
@@ -218,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
updateSettings,
]);
const handleOpenCliSymlinkInstructions = useCallback(() => {
if (!showSection || isLoadingCliSymlinkInstructions) {
return;
}
setIsLoadingCliSymlinkInstructions(true);
setCliStatusMessage(null);
void getCliSymlinkInstructions()
.then((instructions) => {
setCliSymlinkInstructions(instructions);
setIsCliSymlinkModalOpen(true);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`);
})
.finally(() => {
setIsLoadingCliSymlinkInstructions(false);
});
}, [isLoadingCliSymlinkInstructions, showSection]);
const handleCopyCliSymlinkCommands = useCallback(() => {
if (!cliSymlinkInstructions?.commands) {
return;
}
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
.then(() => {
Alert.alert("Copied", "CLI symlink commands copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy CLI symlink commands", error);
Alert.alert("Error", "Unable to copy CLI symlink commands.");
});
}, [cliSymlinkInstructions?.commands]);
const handleCopyLogPath = useCallback(() => {
const logPath = daemonLogs?.logPath;
if (!logPath) {
@@ -315,20 +278,47 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
});
}, [pairingOffer?.url]);
const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true);
try {
setCliStatusOutput(await getCliDaemonStatus());
setIsCliStatusModalOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
setIsCliStatusModalOpen(true);
} finally {
setIsLoadingCliStatus(false);
}
}, []);
const handleCopyCliStatus = useCallback(() => {
if (!cliStatusOutput) {
return;
}
void Clipboard.setStringAsync(cliStatusOutput)
.then(() => {
Alert.alert("Copied", "Status copied to clipboard.");
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon status", error);
});
}, [cliStatusOutput]);
if (!showSection) {
return null;
}
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={styles.sectionLinkText}
style={styles.sectionLink}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
accessibilityLabel="Open advanced daemon settings"
>
@@ -336,10 +326,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
<View style={settingsStyles.card}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -348,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
{showLifecycleControls ? (
<>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
@@ -379,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: "Pause"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
@@ -401,26 +391,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
</>
) : null}
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text>
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenCliSymlinkInstructions}
disabled={isLoadingCliSymlinkInstructions}
>
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
@@ -444,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View>
<Button
variant="outline"
@@ -458,6 +432,23 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Pair device
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
@@ -469,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
) : null}
<AdaptiveModalSheet
visible={isCliSymlinkModalOpen}
onClose={() => setIsCliSymlinkModalOpen(false)}
title="Add paseo to your shell"
testID="managed-daemon-cli-symlink-dialog"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
Paseo does not add the command for you. Run the command below in your terminal.
</Text>
{cliSymlinkInstructions?.detail ? (
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliSymlinkInstructions?.commands ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
Copy commands
</Button>
</View>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
@@ -518,12 +482,34 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
snapPoints={["70%", "92%"]}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={styles.logOutput} selectable>
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isCliStatusModalOpen}
onClose={() => setIsCliStatusModalOpen(false)}
title="Daemon status"
testID="daemon-cli-status-dialog"
snapPoints={["60%", "85%"]}
>
<View style={styles.modalBody}>
<Text style={styles.logOutput} selectable>
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliStatusModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliStatus}>
Copy
</Button>
</View>
</View>
</AdaptiveModalSheet>
</View>
);
}
@@ -581,7 +567,7 @@ function PairingOfferDialogContent(input: {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer</Text>
<Text style={settingsStyles.rowHint}>Loading pairing offer</Text>
</View>
);
}
@@ -589,7 +575,7 @@ function PairingOfferDialogContent(input: {
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View>
);
}
@@ -597,21 +583,21 @@ function PairingOfferDialogContent(input: {
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>
<Text style={settingsStyles.rowHint}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
@@ -632,37 +618,6 @@ function PairingOfferDialogContent(input: {
}
const styles = StyleSheet.create((theme) => ({
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionLink: {
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
rowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
rowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
@@ -673,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end",
gap: 2,
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
valueText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -686,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,

View File

@@ -0,0 +1,189 @@
import { useCallback, useState } from "react";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import {
shouldUseDesktopDaemon,
getCliInstallStatus,
installCli,
getSkillsInstallStatus,
installSkills,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
export function IntegrationsSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const [cliStatus, setCliStatus] = useState<InstallStatus | null>(null);
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(null);
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [isInstallingSkills, setIsInstallingSkills] = useState(false);
const loadStatus = useCallback(() => {
if (!showSection) return;
void getCliInstallStatus()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to load CLI status", error);
});
void getSkillsInstallStatus()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to load skills status", error);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
loadStatus();
return undefined;
}, [loadStatus, showSection]),
);
const handleInstallCli = useCallback(() => {
if (isInstallingCli) return;
setIsInstallingCli(true);
void installCli()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to install CLI", error);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isInstallingSkills) return;
setIsInstallingSkills(true);
void installSkills()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to install skills", error);
})
.finally(() => {
setIsInstallingSkills(false);
});
}, [isInstallingSkills]);
if (!showSection) {
return null;
}
return (
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
accessibilityLabel="Open CLI documentation"
>
CLI docs
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
accessibilityLabel="Open skills documentation"
>
Skills docs
</Button>
</View>
</View>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Command line</Text>
</View>
<Text style={settingsStyles.rowHint}>
Control and script agents from your terminal.
</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli ? "Installing..." : "Install"}
</Button>
)}
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI.
</Text>
</View>
{skillsStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerLinks: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[0],
},
rowTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
installedLabel: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));

View File

@@ -1,4 +1,4 @@
import { getDesktopHost, isDesktop } from "@/desktop/host";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -6,10 +6,12 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStatus = {
serverId: string;
status: DesktopDaemonState;
listen: string;
listen: string | null;
hostname: string | null;
pid: number | null;
home: string;
version: string | null;
desktopManaged: boolean;
error: string | null;
};
@@ -24,12 +26,6 @@ export type DesktopPairingOffer = {
qr: string | null;
};
export type CliSymlinkInstructions = {
title: string;
detail: string;
commands: string;
};
export type LocalTransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
@@ -82,10 +78,12 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
return {
serverId: toStringOrNull(raw.serverId) ?? "",
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? "",
listen: toStringOrNull(raw.listen),
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "",
version: toStringOrNull(raw.version),
desktopManaged: raw.desktopManaged === true,
error: toStringOrNull(raw.error),
};
}
@@ -111,19 +109,8 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
};
}
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
if (!isRecord(raw)) {
return null;
}
return {
title: toStringOrNull(raw.title) ?? "",
detail: toStringOrNull(raw.detail) ?? "",
commands: toStringOrNull(raw.commands) ?? "",
};
}
export function shouldUseDesktopDaemon(): boolean {
return isDesktop();
return isElectronRuntime();
}
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
@@ -150,16 +137,12 @@ export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
const instructions = parseCliSymlinkInstructionsInternal(raw);
if (!instructions) {
throw new Error("Unexpected CLI symlink instructions response.");
export async function getCliDaemonStatus(): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
if (typeof raw !== "string") {
throw new Error("Unexpected CLI daemon status response.");
}
return instructions;
}
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
return raw;
}
export type LocalTransportEventUnlisten = () => void;
@@ -212,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
export interface InstallStatus {
installed: boolean;
}
function parseInstallStatus(raw: unknown): InstallStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected install status response.");
}
return { installed: raw.installed === true };
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
}
export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
}
export async function installSkills(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
}

View File

@@ -1,4 +1,8 @@
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
import {
getDesktopHost,
type DesktopWindowBridge,
type DesktopWindowControlsOverlayUpdate,
} from "@/desktop/host";
export function getDesktopWindow(): DesktopWindowBridge | null {
const getter = getDesktopHost()?.window?.getCurrentWindow;
@@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise<boolean> {
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
export async function updateDesktopWindowControls(
update: DesktopWindowControlsOverlayUpdate,
): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
if (!win || typeof win.updateWindowControls !== "function") {
return;
}
await win.setTitleBarTheme(theme);
await win.updateWindowControls(update);
}

View File

@@ -44,14 +44,17 @@ export interface DesktopMenuBridge {
}) => Promise<void>;
}
export interface DesktopWindowControlsOverlayUpdate {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
moving?: (screenX: number, screenY: number) => void;
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -76,6 +79,7 @@ export interface DesktopInvokeBridge {
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
getPendingOpenProject?: () => Promise<string | null>;
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
@@ -97,12 +101,12 @@ export function getDesktopHost(): DesktopHostBridge | null {
return getElectronHost();
}
export function isDesktop(): boolean {
export function isElectronRuntime(): boolean {
return getDesktopHost() !== null;
}
export function isDesktopMac(): boolean {
if (!isDesktop()) {
export function isElectronRuntimeMac(): boolean {
if (!isElectronRuntime()) {
return false;
}
if (typeof navigator === "undefined") {

View File

@@ -9,7 +9,7 @@ import {
import { sendOsNotification } from "@/utils/os-notifications";
export interface UseDesktopPermissionsReturn {
isDesktop: boolean;
isDesktopApp: boolean;
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
@@ -31,7 +31,7 @@ const EMPTY_MICROPHONE_STATUS = {
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const isDesktop = shouldShowDesktopPermissionSection();
const isDesktopApp = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -47,7 +47,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
}, []);
const refreshPermissions = useCallback(async () => {
if (!isDesktop) {
if (!isDesktopApp) {
return;
}
@@ -65,11 +65,11 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setIsRefreshing(false);
}
}
}, [isDesktop]);
}, [isDesktopApp]);
const requestPermission = useCallback(
async (kind: DesktopPermissionKind) => {
if (!isDesktop) {
if (!isDesktopApp) {
return;
}
@@ -110,13 +110,13 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions();
}
},
[isDesktop, refreshPermissions],
[isDesktopApp, refreshPermissions],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => {
if (!isDesktop) {
if (!isDesktopApp) {
return;
}
@@ -137,18 +137,18 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setIsSendingTestNotification(false);
}
}
}, [isDesktop]);
}, [isDesktopApp]);
useEffect(() => {
if (!isDesktop) {
if (!isDesktopApp) {
return;
}
void refreshPermissions();
}, [isDesktop, refreshPermissions]);
}, [isDesktopApp, refreshPermissions]);
return {
isDesktop,
isDesktopApp,
snapshot,
isRefreshing,
requestingPermission,

View File

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

View File

@@ -11,7 +11,7 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
isDesktopApp,
status,
availableUpdate,
errorMessage,
@@ -23,7 +23,7 @@ export function UpdateBanner() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!isDesktop) return;
if (!isDesktopApp) return;
void checkForUpdates({ silent: true });
@@ -36,9 +36,9 @@ export function UpdateBanner() {
clearInterval(intervalRef.current);
}
};
}, [isDesktop, checkForUpdates]);
}, [isDesktopApp, checkForUpdates]);
if (!isDesktop) return null;
if (!isDesktopApp) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
@@ -128,11 +128,7 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3],
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 8,
...theme.shadow.md,
maxWidth: 480,
},
closeButton: {

View File

@@ -18,7 +18,7 @@ export type DesktopAppUpdateStatus =
| "error";
export interface UseDesktopAppUpdaterReturn {
isDesktop: boolean;
isDesktopApp: boolean;
status: DesktopAppUpdateStatus;
statusText: string;
availableUpdate: DesktopAppUpdateCheckResult | null;
@@ -75,7 +75,7 @@ function formatStatusText(input: {
}
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktop = shouldShowDesktopUpdateSection();
const isDesktopApp = shouldShowDesktopUpdateSection();
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -85,7 +85,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
if (!isDesktop) {
if (!isDesktopApp) {
return null;
}
@@ -130,11 +130,11 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null;
}
},
[isDesktop],
[isDesktopApp],
);
const installUpdate = useCallback(async () => {
if (!isDesktop) {
if (!isDesktopApp) {
return null;
}
@@ -162,10 +162,10 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message);
return null;
}
}, [isDesktop]);
}, [isDesktopApp]);
return {
isDesktop,
isDesktopApp,
status,
statusText: formatStatusText({
status,

View File

@@ -1,4 +1,5 @@
import { getDesktopHost } from "@/desktop/host";
import { isAbsolutePath } from "@/utils/path";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
@@ -29,12 +30,8 @@ const IMAGE_FILE_EXTENSIONS = [
"svg",
];
function isAbsoluteWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(value);
}
function shouldTreatAsFileUri(uri: string): boolean {
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
return uri.startsWith("file://") || isAbsolutePath(uri);
}
async function blobFromUri(uri: string): Promise<Blob> {

View File

@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const featureValues = draftConfig.featureValues;
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
};
}

View File

@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
draftConfig?.modeId ?? null,
draftConfig?.model ?? null,
draftConfig?.thinkingOptionId ?? null,
draftConfig?.featureValues ?? null,
] as const;
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueries } from "@tanstack/react-query";
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
@@ -8,9 +8,11 @@ import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
import {
useFormPreferences,
mergeProviderPreferences,
@@ -84,6 +86,7 @@ type UseAgentFormStateResult = {
providerDefinitions: AgentProviderDefinition[];
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
agentDefinition?: AgentProviderDefinition;
allProviderEntries?: ProviderSnapshotEntry[];
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
@@ -319,6 +322,10 @@ function combineInitialValues(
return initialValues;
}
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
return ["providersSnapshot", serverId, cwd ?? ""] as const;
}
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
const {
initialServerId = null,
@@ -336,6 +343,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
} = useFormPreferences();
const daemons = useHosts();
const queryClient = useQueryClient();
// Build a set of valid server IDs for preference validation
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
@@ -371,11 +379,138 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
// Session state for provider model listing
const client = useHostRuntimeClient(formState.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
const supportsProvidersSnapshot = useSessionForServer(
formState.serverId,
(session) => session?.serverInfo?.features?.providersSnapshot === true,
);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const snapshotQueryKey = useMemo(
() => providersSnapshotQueryKey(formState.serverId, debouncedCwd),
[debouncedCwd, formState.serverId],
);
const providersSnapshotQuery = useQuery({
queryKey: snapshotQueryKey,
enabled: Boolean(
supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot({ cwd: debouncedCwd });
},
});
useEffect(() => {
if (
!supportsProvidersSnapshot ||
!client ||
!isConnected ||
!isVisible ||
!isTargetDaemonReady ||
!formState.serverId
) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) {
return;
}
queryClient.setQueryData(snapshotQueryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
});
}, [
client,
debouncedCwd,
formState.serverId,
isConnected,
isTargetDaemonReady,
isVisible,
queryClient,
snapshotQueryKey,
supportsProvidersSnapshot,
]);
const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined;
const allProviderEntries = useMemo(
() => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined),
[snapshotEntries, supportsProvidersSnapshot],
);
const snapshotProviderDefinitions = useMemo(() => {
if (!supportsProvidersSnapshot) {
return [];
}
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
}, [snapshotEntries, supportsProvidersSnapshot]);
const snapshotProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
),
[snapshotProviderDefinitions],
);
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
const readyProviders = new Set(
(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]);
const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) {
map.set(entry.provider, entry.models ?? []);
}
return map;
}, [snapshotEntries]);
const snapshotSelectedEntry = useMemo(
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null,
[formState.provider, snapshotEntries],
);
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
const snapshotSelectedProviderModes =
snapshotSelectedEntry?.modes ??
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
[];
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
@@ -389,43 +524,33 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
},
});
const providerDefinitions = useMemo(() => {
const legacyProviderDefinitions = useMemo(() => {
const availableProviders = availableProvidersQuery.data;
if (!availableProviders) {
return [];
}
const available = new Set(availableProviders);
return allProviderDefinitions.filter((definition) =>
available.has(definition.id as AgentProvider),
);
return allProviderDefinitions.filter((definition) => available.has(definition.id));
}, [availableProvidersQuery.data]);
const providerDefinitionMap = useMemo(
const legacyProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]),
legacyProviderDefinitions.map((definition) => [definition.id, definition]),
),
[providerDefinitions],
[legacyProviderDefinitions],
);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const legacySelectedProviderModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
providerDefinitionMap.has(formState.provider),
legacyProviderDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
@@ -441,12 +566,39 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return payload.models ?? [];
},
});
const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null;
const availableModels = providerModelsQuery.data ?? null;
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const providerModesQuery = useQuery({
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
legacyProviderDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModes(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.modes ?? [];
},
});
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const allProviderModelQueries = useQueries({
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
@@ -465,19 +617,40 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
},
})),
});
const allProviderModels = useMemo(() => {
const legacyAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < providerDefinitions.length; i++) {
for (let i = 0; i < legacyProviderDefinitions.length; i++) {
const query = allProviderModelQueries[i];
if (query?.data) {
map.set(providerDefinitions[i]!.id, query.data);
map.set(legacyProviderDefinitions[i]!.id, query.data);
}
}
return map;
}, [allProviderModelQueries, providerDefinitions]);
}, [allProviderModelQueries, legacyProviderDefinitions]);
const legacySelectedProviderModes =
providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? [];
const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading);
const providerDefinitions = supportsProvidersSnapshot
? snapshotProviderDefinitions
: legacyProviderDefinitions;
const providerDefinitionMap = supportsProvidersSnapshot
? snapshotProviderDefinitionMap
: legacyProviderDefinitionMap;
const selectableProviderDefinitionMap = supportsProvidersSnapshot
? snapshotSelectableProviderDefinitionMap
: legacyProviderDefinitionMap;
const allProviderModels = supportsProvidersSnapshot
? snapshotAllProviderModels
: legacyAllProviderModels;
const availableModels = supportsProvidersSnapshot
? snapshotSelectedProviderModels
: legacySelectedProviderModels;
const modeOptions = supportsProvidersSnapshot
? snapshotSelectedProviderModes
: legacySelectedProviderModes;
const isAllModelsLoading = supportsProvidersSnapshot
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
: allProviderModelQueries.some((q) => q.isLoading);
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -502,7 +675,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
userModified,
formStateRef.current,
validServerIds,
providerDefinitionMap,
selectableProviderDefinitionMap,
);
// Only update if something changed
@@ -527,7 +700,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
availableModels,
userModified,
validServerIds,
providerDefinitionMap,
selectableProviderDefinitionMap,
]);
// Auto-select the first online host when:
@@ -567,8 +740,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const setProviderFromUser = useCallback(
(provider: AgentProvider) => {
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const providerModels = allProviderModels.get(provider) ?? null;
const providerDef = providerDefinitionMap.get(provider);
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
const isValidModel = (m: string) =>
@@ -606,12 +782,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId,
}));
},
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
[
allProviderModels,
preferences?.providerPreferences,
selectableProviderDefinitionMap,
updatePreferences,
],
);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
const providerDef = providerDefinitionMap.get(provider);
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
@@ -631,7 +815,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider });
},
[allProviderModels, providerDefinitionMap, updatePreferences],
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
);
const setModeFromUser = useCallback(
@@ -688,8 +872,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, []);
const refreshProviderModels = useCallback(() => {
void providerModelsQuery.refetch();
}, [providerModelsQuery]);
if (supportsProvidersSnapshot) {
if (!client) {
return;
}
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
return;
}
void legacySelectedProviderModelsQuery.refetch();
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
@@ -722,13 +913,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
]);
const agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = agentDefinition?.modes ?? [];
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const isModelLoading = supportsProvidersSnapshot
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
const modelError =
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
supportsProvidersSnapshot
? providersSnapshotQuery.error instanceof Error
? providersSnapshotQuery.error.message
: null
: legacySelectedProviderModelsQuery.error instanceof Error
? legacySelectedProviderModelsQuery.error.message
: null;
const workingDirIsEmpty = !formState.workingDir.trim();
@@ -751,6 +949,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
providerDefinitions,
providerDefinitionMap,
agentDefinition,
allProviderEntries,
modeOptions,
availableModels: availableModels ?? [],
allProviderModels,
@@ -781,6 +980,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
providerDefinitions,
providerDefinitionMap,
agentDefinition,
allProviderEntries,
modeOptions,
availableModels,
allProviderModels,

View File

@@ -9,7 +9,6 @@ import {
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
@@ -74,7 +73,6 @@ function createBaseMemory(
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
@@ -96,12 +94,8 @@ function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
function expectSyncErrorSync(state: ReadyState): void {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
@@ -165,10 +159,9 @@ describe("deriveAgentScreenViewState", () => {
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
it("uses silent catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
@@ -183,8 +176,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
expect(sync.ui).toBe("silent");
});
it("keeps sync errors non-blocking once the screen was ready", () => {
@@ -200,9 +192,7 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(ready);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
@@ -221,9 +211,7 @@ describe("deriveAgentScreenViewState", () => {
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(errorReady);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
@@ -239,7 +227,6 @@ describe("deriveAgentScreenViewState", () => {
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
@@ -255,35 +242,10 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expectSyncErrorSync(ready);
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
@@ -484,71 +446,6 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.agent.status).toBe("closed");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
@@ -565,7 +462,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.ui).toBe("silent");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});

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