Compare commits

...

95 Commits

Author SHA1 Message Date
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
Mohamed Boudra
352a4e793a feat(website): redeploy on new GitHub releases
The website fetches the latest release version at build time, so it
needs to redeploy when a new release is published to pick up updated
download links.
2026-03-30 08:48:06 +07:00
Mohamed Boudra
8047394154 docs: add 0.1.37 changelog entry 2026-03-29 23:28:00 +07:00
Mohamed Boudra
e5fd61f131 feat(app): add empty states for sidebar, sessions, and open-project
Sidebar shows a card with "No projects yet" and an Add project ghost
button. Sessions screen shows "No sessions yet" with a ghost Back
button. Open-project screen uses MenuHeader borderless, shared Button,
and shows introductory text when no projects exist.
2026-03-29 23:10:45 +07:00
Mohamed Boudra
487af3c822 feat(app): add borderless prop to ScreenHeader and MenuHeader 2026-03-29 23:10:40 +07:00
Mohamed Boudra
a7439c8e56 feat(app): enhance Button ghost variant with hover and flexible leftIcon
Ghost variant text+icon are foregroundMuted by default, foreground on
hover. leftIcon now accepts a ReactElement, ComponentType, or render
function so the Button can control icon color internally.
2026-03-29 23:10:35 +07:00
Mohamed Boudra
930660074d fix(server): treat bare slash queries as search terms in project picker
Queries like "faro/main" were treated as literal paths relative to ~,
so the picker tried to list ~/faro/ (which doesn't exist) instead of
searching the home tree for paths containing "faro/main". Only treat
queries as paths when explicitly rooted with ~, ~/, ./, or /.
2026-03-29 23:10:15 +07:00
Mohamed Boudra
fd030e8673 docs: clarify installation paths and components (#153)
Rewrite Getting Started in README and website docs to explain the
daemon, put agent CLI prerequisites first, and split setup into
Desktop App (recommended, bundles daemon) and CLI/headless paths.
Add "Components at a glance" section to ARCHITECTURE.md.
2026-03-29 22:37:53 +07:00
Mohamed Boudra
bb73147efd feat(app): desktop startup sequence with error recovery (#153)
Replace the silent daemon bootstrap failure path with a multi-phase
startup screen that shows progress and surfaces errors. Uses Expo
Router Stack.Protected to gate app screens behind bootstrap completion,
keeping the Stack mounted at all times to avoid layout remounts.

- bootstrapDesktop() returns structured result instead of swallowing errors
- addConnectionFromListenAndWaitForOnline() waits for real connection, not just probe
- Startup screen shows stacked progress steps with checkmark transitions
- Error state shows daemon logs, copy button, GitHub issue link, docs link, retry
- External URLs open in system browser via openExternalUrl
2026-03-29 22:29:16 +07:00
Mohamed Boudra
04b21f089c Fix mobile dropdown close and draft thinking persistence
Closes #150

Closes #151
2026-03-29 21:17:31 +07:00
Mohamed Boudra
fb42c8eea2 fix(website): fetch desktop version from latest GitHub release
The download page was showing pre-release versions because it read
the version directly from package.json. Now fetches the latest
non-prerelease from the GitHub Releases API at build time, with
fallback to package.json for local dev.
2026-03-29 21:12:30 +07:00
github-actions[bot]
971f74e2ab fix: update lockfile signatures and Nix hash 2026-03-29 13:41:46 +00:00
github-actions[bot]
4318aa2bb1 fix: update lockfile signatures and Nix hash 2026-03-29 20:39:03 +07:00
Mohamed Boudra
a1761363fa fix(website): respect draft frontmatter field when filtering posts
Posts with `draft: true` in frontmatter were shown because isDraft only
checked the file path for `/drafts/`. Now checks both path and frontmatter.
2026-03-29 20:39:03 +07:00
github-actions[bot]
096417d5ac fix: update lockfile signatures and Nix hash 2026-03-29 10:54:20 +00:00
Mohamed Boudra
35446e01a2 docs(skills): add paseo archive command to CLI reference 2026-03-29 17:39:50 +07:00
Mohamed Boudra
6c3559e90f refactor(server): extract CheckoutDiffManager from Session into daemon-global service
Checkout-diff watch targets were per-Session, so multiple clients (phone,
desktop, browser) watching the same workspace duplicated fs.watch sets and
snapshot computation. Move all checkout-diff subscription machinery into a
singleton CheckoutDiffManager that deduplicates watchers and snapshots
across sessions — same pattern as AgentManager.

Session now keeps only a Map<subscriptionId, unsubscribe> and thin message
handler wrappers. Shared utilities (resolveCheckoutGitDir, toCheckoutError,
READ_ONLY_GIT_ENV) extracted to checkout-git-utils.ts.
2026-03-29 17:39:47 +07:00
Mohamed Boudra
409ab466ca docs(website): add draft blog posts 2026-03-29 16:44:51 +07:00
Mohamed Boudra
efb8c8f229 feat(server): cache getPullRequestStatus with TTL and in-flight dedup
Adds @isaacs/ttlcache to avoid repeated gh CLI calls for the same
working directory. Concurrent lookups for the same cwd share a single
in-flight promise. Cache expires after 30s by default.
2026-03-29 16:42:39 +07:00
Mohamed Boudra
b1d663557b refactor(daemon): replace daemon-launch.log with electron-log in desktop, remove from CLI/server
The file-based daemon-launch.log was added as temporary instrumentation
for debugging Windows startup. Desktop now logs lifecycle events through
electron-log; CLI and server supervisor drop the launch logging entirely.
2026-03-29 16:42:31 +07:00
Mohamed Boudra
a460c5e9b9 fix(website): only show blog drafts when explicitly requested
The validateSearch round-trip caused drafts to always show because
`false !== undefined` is true. Check for explicit truthy values instead.
2026-03-29 14:43:23 +07:00
Mohamed Boudra
831e3289e7 chore(windows): instrument detached daemon launch 2026-03-29 14:38:18 +07:00
Mohamed Boudra
094864d779 feat: add chat and website updates 2026-03-29 12:49:05 +07:00
Mohamed Boudra
147482d6ed feat(desktop): sync title bar theme with resolved app theme 2026-03-29 09:26:21 +07:00
Mohamed Boudra
921ddf47a8 refactor(desktop): move title bar overlay logic to window-manager and add setTitleBarTheme IPC 2026-03-29 09:26:18 +07:00
Mohamed Boudra
f287c7f972 fix(app): default theme setting to auto instead of dark 2026-03-29 09:26:15 +07:00
Mohamed Boudra
9eb1f93296 fix(app): reduce sidebar footer icon size from lg to md 2026-03-29 09:24:46 +07:00
Mohamed Boudra
95d56ddf15 fix(server): resolve claude from current windows path 2026-03-28 23:54:50 +07:00
Mohamed Boudra
62b1e94257 chore(server): instrument daemon bootstrap gap 2026-03-28 23:05:21 +07:00
github-actions[bot]
01bab92fed fix: update lockfile signatures and Nix hash 2026-03-28 14:42:25 +00:00
Mohamed Boudra
dae677edeb Merge branch 'main' of github.com:getpaseo/paseo 2026-03-28 21:41:19 +07:00
Mohamed Boudra
03a5323755 Merge branch 'initial-chat' 2026-03-28 21:39:45 +07:00
Mohamed Boudra
2e9f935dbc Redesign landing page with interactive mockups and new sections 2026-03-28 21:39:37 +07:00
Mohamed Boudra
93845db70a Extract chat mention handling from session 2026-03-28 21:38:53 +07:00
Mohamed Boudra
d5085294df fix(desktop): update banner dismiss button and install feedback
Move the X dismiss button to a floating circle in the top-left corner
so it no longer overlaps the banner text. Show the banner during
"installing" and "error" states so the user gets feedback after
clicking "Install & restart" — previously the banner vanished because
those statuses were not in the render condition. Add a retry button
for the error state.
2026-03-28 18:43:49 +07:00
Mohamed Boudra
a3aed9b8b6 Fix desktop terminal link and context menu behavior 2026-03-28 11:14:43 +07:00
Mohamed Boudra
7ca428677c Make chat mentions inline 2026-03-28 10:51:22 +07:00
Mohamed Boudra
9ad3e7661b Fix mobile sidebar reset after theme switch (#152) 2026-03-28 11:50:24 +08:00
Mohamed Boudra
05a4e8f5a3 fix(cli): resolve daemon executable path without wmic 2026-03-28 10:47:53 +07:00
Mohamed Boudra
1b0fe4aa47 fix(server): preserve packaged node runtime for supervised daemon worker 2026-03-28 09:26:32 +07:00
Mohamed Boudra
29876acae5 feat(skills): rewrite skills to use CLI, reframe orchestrator as chat-first team lead
- paseo: add loop, schedule, chat command reference sections
- paseo-loop: replace loop.sh with paseo loop CLI, document model selection and archive
- paseo-chat: replace chat.sh with paseo chat CLI
- paseo-orchestrator: reframe as team orchestrator using chat rooms as coordination backbone,
  agents as disposable workers, schedule heartbeat for oversight
- paseo-handoff/committee: fix --mode bypass → --mode bypassPermissions
- Delete loop.sh (541 lines) and chat.sh (635 lines) bash scripts
2026-03-28 01:13:42 +07:00
Mohamed Boudra
657b3e1ccd feat(loop): add worker/verifier model selection and archive support
Loop runs can now specify separate provider/model for worker and verifier
agents (--provider, --model, --verify-provider, --verify-model). The
--archive flag preserves agent conversation history after each iteration
instead of destroying them.
2026-03-28 01:10:05 +07:00
Mohamed Boudra
a2ab0af3f1 feat(cli): distinguish local and connected daemon status 2026-03-28 00:31:18 +07:00
Mohamed Boudra
bb9e181c20 fix(desktop): remove packaged preload log bridge import 2026-03-28 00:25:23 +07:00
Mohamed Boudra
7bc1f04e27 fix(desktop): restore windows daemon bootstrap logging 2026-03-28 00:25:23 +07:00
Mohamed Boudra
394b9cac13 fix: hardcode paseo://app as allowed CORS origin in daemon bootstrap
The desktop app uses the paseo:// custom protocol scheme, but the
origin was only passed via PASEO_CORS_ORIGINS when the desktop started
the daemon itself. If the daemon was started by the CLI, the origin
was missing and the Electron renderer's WebSocket connection was
rejected.

Also removes file:// and null from allowed origins — any page loaded
via file:// could connect to the daemon, which is a security gap.
2026-03-28 00:25:23 +07:00
Mohamed Boudra
be41911083 feat: add loop, schedule, and chat CLI commands (#149)
* Add metrics collection and terminal performance tests

* feat: add loop, schedule, and chat commands with slash-namespaced RPC

Introduce three new server-side features with CLI command groups:

- `paseo loop` — iterative agent execution with verify-check/verify-prompt
- `paseo schedule` — recurring tasks on interval or cron cadence
- `paseo chat` — chat rooms for agent-to-agent coordination

All features use new slash-namespaced RPC methods (e.g. `loop/run`,
`schedule/create`, `chat/post`) instead of flat message types, backed
by file-based persistence and wired through the existing WebSocket
session dispatch.

* test: stabilize loop schedule chat rollout
2026-03-28 00:25:04 +07:00
github-actions[bot]
8f11902cb7 fix: update lockfile signatures and Nix hash 2026-03-27 14:13:21 +00:00
Mohamed Boudra
e2b2b7c701 chore(release): cut 0.1.37 2026-03-27 21:11:53 +07:00
Mohamed Boudra
af35cccb5f fix(release): skip release notes sync when no changelog entry exists
Draft releases don't have changelog entries, so the script should log
and continue rather than throwing.
2026-03-27 21:10:03 +07:00
github-actions[bot]
6342057a6f fix: update lockfile signatures and Nix hash 2026-03-27 14:00:20 +00:00
Mohamed Boudra
89923b5f76 fix(ci): default to draft release when no GitHub release exists
The fallback was "release", so pushing a tag without pre-creating a
draft release on GitHub caused electron-builder to create a published
release instead of a draft.
2026-03-27 20:59:07 +07:00
Mohamed Boudra
54b5a22688 fix(windows): broken PATH propagation, ps usage, and claude binary resolution
- sherpa-runtime-env: use case-insensitive key lookup when modifying PATH
  on plain env objects. On Windows, `{...process.env}` stores PATH as
  `Path` but `applySherpaLoaderEnv` used hardcoded `"PATH"`, creating a
  duplicate key that could shadow the real system PATH in child processes.

- runtime-toolchain: use `wmic` on Windows instead of Unix-only `ps -o`
  to resolve the node executable path from a PID.

- claude-agent: pass `findExecutable("claude")` as
  `pathToClaudeCodeExecutable` so the SDK uses the user's installed
  binary when available. Update spawn hook to only replace bare
  "node"/"bun" with process.execPath, preserving native binary paths.

- desktop/paseo.cmd: use ELECTRON_RUN_AS_NODE with node-entrypoint-runner
  for the Windows CLI wrapper.
2026-03-27 20:50:18 +07:00
Mohamed Boudra
ab3443fd52 feat(desktop): hide native titlebar on Windows/Linux with overlay window controls
Use titleBarStyle: 'hidden' on all platforms. On Windows/Linux, enable
titleBarOverlay with dark theme colors for native min/max/close buttons.
Extend useTrafficLightPadding to return side ('left'|'right'|null) so
consumers apply padding on the correct side per platform.
2026-03-27 19:59:17 +07:00
Mohamed Boudra
95ad879f82 fix: show toast on dictation errors instead of silent console log 2026-03-27 19:27:29 +07:00
Mohamed Boudra
80e153f861 feat(desktop): add file logging with electron-log
Logs from both main and renderer processes now persist to
~/Library/Logs/Paseo/main.log (macOS) with automatic rotation.
Removes the unused webview_log IPC handler.
2026-03-27 19:22:40 +07:00
Mohamed Boudra
dfa376f5ca docs: add 0.1.36 changelog entry 2026-03-27 18:48:06 +07:00
Mohamed Boudra
76c357c88c fix: run release:check before version bump to prevent dirty state on failure 2026-03-27 18:44:18 +07:00
github-actions[bot]
5adde123e3 fix: update lockfile signatures and Nix hash 2026-03-27 11:32:43 +00:00
Mohamed Boudra
57291b1fd9 chore(release): cut 0.1.36 2026-03-27 18:30:49 +07:00
Mohamed Boudra
89f285ffd0 docs: clarify draft release flow and changelog dependency 2026-03-27 18:29:53 +07:00
Mohamed Boudra
e6c06e2263 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-27 18:18:56 +07:00
Mohamed Boudra
ca443b3ecf fix: handle Windows drive-letter paths across the codebase (#148)
* Add metrics collection and terminal performance tests

* fix: handle Windows drive-letter paths across the codebase

Windows paths like C:\Users\foo\project were broken in multiple places:
- agent-storage slugified D:\MyProject as D:-MyProject (illegal colon)
- terminal-manager rejected all non-/ paths as relative
- bootstrap parser misparsed drive colons as TCP host:port
- daemon/client connection helpers misclassified Windows paths
- CLI cwd filtering used hardcoded / separators
- checkout-git worktree detection used hardcoded / in path checks
- worktree archive used split("/").pop() instead of path.basename()

All path helpers now normalize separators and handle Windows
drive letters with case-insensitive comparison where needed.
2026-03-27 18:17:48 +07:00
Mohamed Boudra
d8e9298222 Add metrics collection and terminal performance tests 2026-03-26 23:38:32 +07:00
Mohamed Boudra
c667aca3e0 chore: remove stale agent-event-stream-redesign doc 2026-03-26 20:56:03 +07:00
Mohamed Boudra
7892b3cbe1 fix(nix): update stale npmDepsHash and auto-fix on all lockfile changes
Hash mismatch was hidden by continue-on-error. Now the Nix Build
workflow fails visibly, and fix-nix-hash runs on every push/PR that
touches the lockfile (not just Dependabot).
2026-03-26 20:19:12 +07:00
274 changed files with 19010 additions and 5226 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

@@ -9,6 +9,8 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:

View File

@@ -124,7 +124,7 @@ jobs:
release_type="release"
fi
else
release_type="release"
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -149,6 +149,119 @@ 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:
- name: Resolve release tag
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"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- 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:
@@ -230,7 +343,7 @@ jobs:
release_type="release"
fi
else
release_type="release"
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -339,7 +452,7 @@ jobs:
release_type="release"
fi
else
release_type="release"
release_type="draft"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"

View File

@@ -1,6 +1,11 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
@@ -12,11 +17,10 @@ permissions:
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4

View File

@@ -32,7 +32,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: true
continue-on-error: false
steps:
- uses: actions/checkout@v4

View File

@@ -1,5 +1,56 @@
# Changelog
## 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
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved

View File

@@ -26,20 +26,32 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
## Getting Started
### Desktop app
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
### Prerequisites
### Headless / server mode
You need at least one agent CLI installed and configured with your credentials:
Run the daemon on any machine:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
```bash
npm install -g @getpaseo/cli
paseo
```
Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)

View File

@@ -31,6 +31,14 @@ Your code never leaves your machine. Paseo is local-first.
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon

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 |

View File

@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
## Manual step-by-step
```bash
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
```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
```
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second 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.
## Fixing a failed release build
@@ -62,12 +64,23 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `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
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
- 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.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
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.
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] `npm run release:patch` completes successfully
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) 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

@@ -1,170 +0,0 @@
# Agent Event Stream Redesign
Status: **Implemented** (2026-03-24)
## Problem
The Claude provider had three event paths delivering the same events to the agent-manager:
1. **Foreground stream** (`stream()``activeForegroundTurn.queue`)
2. **Live event pump** (`streamLiveEvents()``liveEventQueue`) fed by the query pump
3. **JSONL history poller** (`startLiveHistoryPolling()``routeSdkMessageFromPump()`)
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
Codex and OpenCode were stable because they had ONE event path with no routing decision.
## Design
### Core principle
One event source per provider session. Identity-based turn ownership, not timing-based routing.
### Provider contract (`AgentSession`)
```typescript
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
// Turn lifecycle
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
interrupt(): Promise<void>;
// Event delivery (push-based)
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
// History (hydration only — never live dispatch)
streamHistory(): AsyncGenerator<AgentStreamEvent>;
// Run (uses startTurn + subscribe internally)
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
// Session metadata (unchanged)
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;
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Method contracts
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
#### `subscribe(callback): () => void`
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
#### `interrupt(): Promise<void>`
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
### Provider-side guarantees
1. **Per-session ordering**: callbacks invoked in provider event order
2. **No concurrent callback execution**: serialized delivery per session
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
4. **Callback error isolation**: subscriber throws → provider logs and continues
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
### Event tagging
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
### User message dedup
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
## Manager
### Single subscription per session
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
### Lifecycle projection from turn identity
- After `startTurn()` resolves: foreground turn is active
- On `turn_started` for active foreground turnId: lifecycle = `running`
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
- On autonomous `turn_started`: lifecycle = `running`
- On autonomous terminal: lifecycle = `idle` or `error`
### `streamAgent()` as filtered view
```typescript
async *streamAgent(agentId, prompt, options) {
const { turnId } = await session.startTurn(prompt, options);
agent.activeForegroundTurnId = turnId;
// Foreground turn waiter yields events matching this turnId
// Ends when terminal event for turnId arrives
}
```
### State model
| Concept | Implementation |
|---------|---------------|
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
| Lifecycle projection | From turn events via turnId matching |
| Cancellation | `session.interrupt()` + await waiter settlement |
## What was deleted
- `stream()` from `AgentSession` interface and all providers
- `Pushable<T>` async queue from all providers
- `streamLiveEvents()` capability
- `activeForegroundTurn` + foreground queue in Claude provider
- `liveEventQueue` in Claude provider
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
- `startLiveEventPump()` in manager
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
- `shouldSuppressLiveUserMessageEcho()` in manager
- `startLiveHistoryPolling()` for live dispatch
- `snapHistoryOffsetToEnd()`
- `pendingRun` as iterator reference
## Integration tests
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
| Test | What it verifies |
|------|-----------------|
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
| Interruption | Start long task → interrupt → turn_canceled arrives |
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
| Fast-fail | Quick error produces clean terminal, no stale events |
| User message dedup | Exactly one user_message with matching text in event log |
### Invariants (asserted on every test)
1. For each foreground turnId, exactly ONE `user_message` event
2. Every `turn_started` has exactly one matching terminal
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
4. Autonomous turns between foreground turns are visible with distinct turnIds

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-gOwvUBvem1SxDMypaexz6RaHRm2xFmUT9iwOW2ErEAM=";
npmDepsHash = "sha256-wLXSLXXzB1rwuQXPRFFt4EKZsFydN3HTR99ZJqBdXxk=";
# 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/

171
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.35",
"version": "0.1.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.35",
"version": "0.1.40",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -14831,6 +14831,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",
@@ -15855,6 +15862,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",
@@ -16718,6 +16752,15 @@
"node": ">=8"
}
},
"node_modules/electron-log": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-publish": {
"version": "26.8.1",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
@@ -18414,6 +18457,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",
@@ -25117,6 +25167,15 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
@@ -25274,6 +25333,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",
@@ -30138,6 +30216,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",
@@ -32126,6 +32214,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",
@@ -34532,6 +34631,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",
@@ -34542,6 +34651,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",
@@ -34842,16 +34962,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.35",
"version": "0.1.40",
"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.35",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/expo-two-way-audio": "0.1.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34929,6 +35049,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",
@@ -34967,11 +35088,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.35",
"version": "0.1.40",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/relay": "0.1.40",
"@getpaseo/server": "0.1.40",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35012,11 +35133,12 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.35",
"version": "0.1.40",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/cli": "0.1.40",
"@getpaseo/server": "0.1.40",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -35049,7 +35171,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.35",
"version": "0.1.40",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35250,7 +35372,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.35",
"version": "0.1.40",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35276,7 +35398,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.35",
"version": "0.1.40",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35292,13 +35414,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.35",
"version": "0.1.40",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/relay": "0.1.35",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/relay": "0.1.40",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -35341,6 +35464,15 @@
"vitest": "^3.2.4"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
"integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=12"
}
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
@@ -35686,13 +35818,14 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.35",
"version": "0.1.40",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.35",
"version": "0.1.40",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -49,13 +49,13 @@
"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 version:all:patch && npm run release:check && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
"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:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && 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"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",

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

@@ -448,12 +448,15 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},

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

View File

@@ -0,0 +1,228 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
> {
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";
}) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
await page.goto(workspaceRoute);
// Wait for daemon connection (sidebar shows host label)
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
// The workspace should now query listTerminals and discover our terminal.
// Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
if (!surfaceVisible) {
// Terminal tab might not be focused — look for it in the tab row and click it
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
if (tabExists) {
await terminalTab.click();
} else {
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
await newTerminalBtn.click();
}
}
// Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}

View File

@@ -0,0 +1,160 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
THROUGHPUT_BUDGET_MS,
);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.35",
"version": "0.1.40",
"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.35",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/expo-two-way-audio": "0.1.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@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

@@ -15,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, ActivityIndicator, Text } from "react-native";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -26,12 +26,13 @@ import {
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useCallback,
useContext,
useState,
useEffect,
@@ -56,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,6 +69,7 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -79,7 +81,18 @@ import {
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
const HostRuntimeBootstrapContext = createContext(false);
export type HostRuntimeBootstrapState = {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
});
function PushNotificationRouter() {
const router = useRouter();
@@ -90,7 +103,7 @@ function PushNotificationRouter() {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsDesktop()) {
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
@@ -207,49 +220,99 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
useEffect(() => {
let cancelled = false;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
await store.bootstrap({ manageBuiltInDaemon: true });
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
}
return;
}
if (cancelled) {
return;
}
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
if (!cancelled) {
setPhase("online");
setError(null);
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
};
void init()
.then(() => {
if (!cancelled) {
setReady(true);
}
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setReady(true);
}
});
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
return () => {
cancelled = true;
};
}, []);
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
return (
<HostRuntimeBootstrapContext.Provider value={ready}>
<HostRuntimeBootstrapContext.Provider value={state}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
function useStoreReady(): boolean {
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
return useContext(HostRuntimeBootstrapContext);
}
@@ -279,12 +342,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,
@@ -300,10 +363,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 />
@@ -312,7 +377,7 @@ function AppContainer({
</View>
);
if (!isMobile) {
if (!isCompactLayout) {
return content;
}
@@ -410,26 +475,30 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const isLoading = settingsLoading || !storeReady;
const systemColorScheme = useColorScheme();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (isLoading) return;
if (settingsLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
}
}, [isLoading, settings.theme]);
}, [settingsLoading, settings.theme]);
if (isLoading) {
const isDesktopManaged =
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
}
useEffect(() => {
if (settingsLoading || Platform.OS !== "web") {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
});
}, [settingsLoading, resolvedTheme]);
return (
<VoiceProvider>
@@ -532,6 +601,38 @@ function FaviconStatusSync() {
return null;
}
function RootStack() {
const storeReady = useStoreReady();
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="index" />
</Stack>
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
@@ -552,57 +653,6 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
function LoadingView({ message }: { message?: string } = {}) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
@@ -619,28 +669,7 @@ export default function RootLayout() {
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
<RootStack />
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>

View File

@@ -1,18 +1,65 @@
import { useEffect } from "react";
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHosts,
} from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => null,
);
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
useEffect(() => {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(WELCOME_ROUTE as any);
}, [pathname, router]);
return null;
const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE;
router.replace(targetRoute as any);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}

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,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

@@ -8,6 +8,7 @@ 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 {
DropdownMenu,
DropdownMenuContent,
@@ -32,6 +33,7 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = {
id: string;
@@ -609,6 +611,7 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -632,12 +635,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 () => {
@@ -709,6 +707,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
@@ -719,11 +728,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
disabled={!client}
/>
);

View File

@@ -4,7 +4,7 @@ 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 +13,11 @@ 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";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -39,7 +40,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);
@@ -336,12 +337,13 @@ 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">
<View style={styles.tabsContainer}>
{isGit && (
<Pressable

View File

@@ -26,18 +26,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";
@@ -65,7 +64,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) {
@@ -384,7 +383,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 +397,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}>
@@ -552,27 +564,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}
@@ -609,99 +625,6 @@ export function FileExplorerPane({
);
}
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 +765,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 +847,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 +866,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

@@ -119,7 +119,7 @@ function FilePreviewBody({
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const isDark = theme.colors.surface0 === "#181B1A";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";

View File

@@ -86,7 +86,7 @@ interface HighlightedTextProps {
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const isDark = theme.colors.surface0 === "#181B1A";
// Get color for a highlight style
const getTokenColor = (style: HighlightStyle | null): string => {

View File

@@ -1,15 +1,17 @@
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 {
title?: string;
rightContent?: ReactNode;
borderless?: boolean;
}
interface SidebarMenuToggleProps {
@@ -42,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);
@@ -75,7 +77,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -90,6 +92,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
}
right={rightContent}
leftStyle={styles.left}
borderless={borderless}
/>
);
}

View File

@@ -1,38 +1,35 @@
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,
getIsDesktopMac,
isCompactFormFactor,
} from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
interface ScreenHeaderProps {
left?: ReactNode;
right?: ReactNode;
leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean;
}
/**
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle }: 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 collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
@@ -42,7 +39,11 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
<View
style={[
styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
{
paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + padding.right,
},
borderless && styles.borderless,
]}
{...dragHandlers}
>
@@ -83,4 +84,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
borderless: {
borderBottomColor: "transparent",
},
}));

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

@@ -20,7 +20,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 +35,15 @@ import {
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
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 +98,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
isOpen: boolean;
handleViewMore: () => void;
}
@@ -105,7 +110,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 +180,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 +270,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleHostSelect,
};
if (isMobile) {
if (isCompactLayout) {
return (
<MobileSidebar
{...sharedProps}
@@ -283,6 +288,7 @@ export const LeftSidebar = memo(function LeftSidebar({
return (
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleSettings={handleSettingsDesktop}
@@ -520,6 +526,7 @@ function MobileSidebar({
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
@@ -556,7 +563,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -581,7 +588,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -626,12 +633,13 @@ 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();
@@ -680,10 +688,8 @@ function DesktopSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
@@ -701,6 +707,7 @@ function DesktopSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
/>
)}
@@ -736,7 +743,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -761,7 +768,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -809,7 +816,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
desktopSidebar: {
position: "relative",

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

@@ -346,9 +346,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
[onChangeText, onSubmit, images, isAgentRunning],
);
const handleDictationError = useCallback((error: Error) => {
console.error("[MessageInput] Dictation error:", error);
}, []);
const handleDictationError = useCallback(
(error: Error) => {
console.error("[MessageInput] Dictation error:", error);
toast.error(error.message);
},
[toast],
);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,

View File

@@ -22,7 +22,7 @@ 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 {
@@ -36,12 +36,13 @@ import {
GitPullRequest,
Monitor,
MoreVertical,
Plus,
} 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";
@@ -73,6 +74,7 @@ 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 { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -113,6 +115,7 @@ interface SidebarWorkspaceListProps {
isRefreshing?: boolean;
onRefresh?: () => void;
onWorkspacePress?: () => void;
onAddProject?: () => void;
listFooterComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -661,8 +664,7 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
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();
@@ -833,7 +835,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,
@@ -898,7 +900,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}
@@ -1631,10 +1633,11 @@ export function SidebarWorkspaceList({
isRefreshing = false,
onRefresh,
onWorkspacePress,
onAddProject,
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();
@@ -1642,7 +1645,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);
@@ -1900,7 +1903,18 @@ export function SidebarWorkspaceList({
const content = (
<>
{projects.length === 0 ? (
<Text style={styles.emptyText}>No projects yet</Text>
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Add a project to get started</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={onAddProject}
>
Add project
</Button>
</View>
) : (
<DraggableList
testID="sidebar-project-list"
@@ -1963,11 +1977,27 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {},
emptyContainer: {
marginHorizontal: theme.spacing[2],
marginTop: theme.spacing[4],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
alignItems: "center",
gap: theme.spacing[3],
},
emptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginTop: theme.spacing[8],
marginHorizontal: theme.spacing[2],
},
projectRow: {
minHeight: 36,

View File

@@ -32,7 +32,7 @@ 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 {
computeTabDropPreview,
type TabDropPreview,
@@ -809,8 +809,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,8 +884,7 @@ function SplitPaneView({
<View
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
{ paddingLeft: padding.left, paddingRight: padding.right },
]}
>
<WorkspaceDesktopTabsRow

View File

@@ -565,6 +565,18 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
}
const hasSelection = Boolean(window.getSelection()?.toString());
void showContextMenu({
kind: "terminal",
hasSelection,
});
};
return (
<div
@@ -586,6 +598,10 @@ export default function TerminalEmulator({
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
>
<div
ref={hostRef}

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

@@ -1,11 +1,19 @@
import type { PropsWithChildren, ReactElement } from "react";
import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "sm" | "md" | "lg";
type LeftIcon =
| ReactElement
| ComponentType<{ color: string; size: number }>
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
const styles = StyleSheet.create((theme) => ({
base: {
flexDirection: "row",
@@ -67,6 +75,12 @@ const styles = StyleSheet.create((theme) => ({
textDestructive: {
color: theme.colors.palette.white,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
}));
export function Button({
@@ -83,11 +97,14 @@ export function Button({
Omit<PressableProps, "style"> & {
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: ReactElement | null;
leftIcon?: LeftIcon;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const variantStyle =
variant === "default"
? styles.default
@@ -100,19 +117,47 @@ export function Button({
: styles.destructive;
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
const isGhostHovered = hovered && variant === "ghost";
const resolvedTextStyle = [
styles.text,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle,
isGhostHovered ? styles.textGhostHovered : null,
];
function renderIcon() {
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function
if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return <View><Icon color={color} size={iconSize} /></View>;
}
return (
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={({ pressed }) => [
styles.base,
sizeStyle,
@@ -122,7 +167,7 @@ export function Button({
style,
]}
>
{leftIcon ? <View>{leftIcon}</View> : null}
{renderIcon()}
<Text style={resolvedTextStyle}>{children}</Text>
</Pressable>
);

View File

@@ -278,7 +278,10 @@ export function DropdownMenuContent({
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
setClosing(true);
// Avoid leaving an invisible full-screen Modal mounted on native when
// the exit animation callback does not fire.
setClosing(false);
setModalVisible(false);
}
}, [open, modalVisible]);

View File

@@ -350,7 +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 isDark = theme.colors.surface0 === "#181B1A";
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =

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;
@@ -19,51 +20,63 @@ export const MAX_CONTENT_WIDTH = 820;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
// Windows/Linux window controls (minimize/maximize/close) — top-right
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in the Electron desktop runtime (any OS)
function isElectronDesktopRuntime(): boolean {
if (Platform.OS !== "web") return false;
return 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,8 +2,12 @@ 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,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -23,63 +27,67 @@ 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 translateX = useSharedValue(isOpen ? 0 : windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
if (isOpen) {
translateX.value = withTiming(0, {
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [
isOpen,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
mobileView,
desktopFileExplorerOpen,
]);
const animateToOpen = () => {

View File

@@ -10,8 +10,12 @@ 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,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -30,54 +34,61 @@ 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 translateX = useSharedValue(isOpen ? 0 : -windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
if (isOpen) {
translateX.value = withTiming(0, {
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => {

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

@@ -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,7 +6,7 @@ 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;
@@ -82,7 +82,7 @@ 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) ?? "",
@@ -123,7 +123,7 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
}
export function shouldUseDesktopDaemon(): boolean {
return isDesktop();
return isElectronRuntime();
}
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {

View File

@@ -27,3 +27,12 @@ export async function isDesktopFullscreen(): Promise<boolean> {
}
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
return;
}
await win.setTitleBarTheme(theme);
}

View File

@@ -37,6 +37,13 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopMenuBridge {
showContextMenu?: (input?: {
kind?: "terminal";
hasSelection?: boolean;
}) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
@@ -44,6 +51,7 @@ export interface DesktopWindowBridge {
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -73,6 +81,7 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
menu?: DesktopMenuBridge;
}
declare global {
@@ -88,12 +97,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

@@ -10,13 +10,20 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
useDesktopAppUpdater();
const {
isDesktopApp,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!isDesktop) return;
if (!isDesktopApp) return;
void checkForUpdates({ silent: true });
@@ -29,28 +36,40 @@ 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") return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
const isInstalled = status === "installed";
const isError = status === "error";
function getTitle(): string {
if (isInstalled) return "Update installed";
if (isInstalling) return "Installing update";
if (isError) return "Update failed";
return "Update available";
}
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}
return (
<View style={styles.container} pointerEvents="box-none">
<View style={styles.banner}>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.banner}>
<View style={styles.textSection}>
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
<Text style={styles.title}>{getTitle()}</Text>
<Text style={styles.subtitle}>{getSubtitle()}</Text>
</View>
<View style={styles.actions}>
@@ -61,7 +80,7 @@ export function UpdateBanner() {
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && (
{!isInstalled && !isError && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
@@ -76,6 +95,15 @@ export function UpdateBanner() {
</Text>
</Pressable>
)}
{isError && (
<Pressable
onPress={() => void checkForUpdates()}
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
)}
</View>
</View>
</View>
@@ -109,15 +137,21 @@ const styles = StyleSheet.create((theme) => ({
},
closeButton: {
position: "absolute",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,

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

@@ -13,6 +13,7 @@ import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
useFormPreferences,
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
@@ -417,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [formState.workingDir]);
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
queryKey: ["providerModels", formState.serverId, formState.provider],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
@@ -445,7 +446,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const allProviderModelQueries = useQueries({
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
queryKey: ["providerModels", formState.serverId, def.id],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
@@ -693,26 +694,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
const nextProviderPrefs: ProviderPreferences = {
model: modelId || undefined,
mode: formState.modeId || undefined,
thinkingByModel: {
...existingProviderPrefs?.thinkingByModel,
...(modelId && formState.thinkingOptionId
? { [modelId]: formState.thinkingOptionId }
: {}),
},
};
await updatePreferences({
const nextPreferences = mergeProviderPreferences({
preferences: preferences ?? {},
provider: formState.provider,
providerPreferences: {
...preferences?.providerPreferences,
[formState.provider]: nextProviderPrefs,
},
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
...(modelId && formState.thinkingOptionId
? {
thinkingByModel: {
[modelId]: formState.thinkingOptionId,
},
}
: {}),
} satisfies Partial<ProviderPreferences>,
});
await updatePreferences(nextPreferences);
}, [
availableModels,
formState.model,

View File

@@ -1,8 +1,46 @@
import type { DaemonClient } from "@server/client/daemon-client";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-archive-agent";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
serverId: "server-a",
id: "agent-1",
provider: "codex",
status: "running",
createdAt: new Date("2026-04-01T03:00:00.000Z"),
updatedAt: new Date("2026-04-01T03:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-04-01T03:00:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent 1",
cwd: "/repo",
model: null,
labels: {},
archivedAt: null,
...overrides,
};
}
describe("useArchiveAgent", () => {
beforeEach(() => {
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
it("tracks pending archive state in shared react-query cache", () => {
const queryClient = new QueryClient();
@@ -63,4 +101,42 @@ describe("useArchiveAgent", () => {
expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]);
expect(next.pageInfo).toEqual({ hasMore: false });
});
it("applies archived agent close results to session state and cached lists", async () => {
const queryClient = new QueryClient();
useSessionStore
.getState()
.initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents(
"server-a",
new Map([
[
"agent-1",
makeAgent(),
],
]),
);
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
queryClient.setQueryData(["allAgents", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
applyArchivedAgentCloseResults({
queryClient,
serverId: "server-a",
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
});
expect(
useSessionStore.getState().sessions["server-a"]?.agents.get("agent-1")?.archivedAt?.toISOString(),
).toBe("2026-04-01T04:00:00.000Z");
expect(queryClient.getQueryData(["sidebarAgentsList", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
});
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
});
});
});

View File

@@ -9,6 +9,11 @@ export interface ArchiveAgentInput {
agentId: string;
}
export interface ArchivedAgentCloseResult {
agentId: string;
archivedAt: string;
}
type ArchiveAgentPendingState = Record<string, true>;
interface SetAgentArchivingInput extends ArchiveAgentInput {
@@ -130,6 +135,39 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin
});
}
interface ApplyArchivedAgentCloseResultsInput {
queryClient: QueryClient;
serverId: string;
results: ArchivedAgentCloseResult[];
}
export function applyArchivedAgentCloseResults(
input: ApplyArchivedAgentCloseResultsInput,
): void {
if (input.results.length === 0) {
return;
}
for (const result of input.results) {
markAgentArchivedInStore({
serverId: input.serverId,
agentId: result.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(input.queryClient, {
serverId: input.serverId,
agentId: result.agentId,
});
}
void input.queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void input.queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
setAgentArchiving({
...input,
@@ -165,17 +203,10 @@ export function useArchiveAgent() {
});
},
onSuccess: (result, input) => {
markAgentArchivedInStore({
applyArchivedAgentCloseResults({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(queryClient, input);
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
results: [{ agentId: input.agentId, archivedAt: result.archivedAt }],
});
},
onSettled: (_result, _error, input) => {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { isDesktop } from "@/desktop/host";
import { isElectronRuntime } from "@/desktop/host";
export interface AudioCaptureConfig {
sampleRate?: number;
@@ -157,7 +157,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isDesktop();
const isDesktopApp = isElectronRuntime();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");

View File

@@ -17,7 +17,7 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
import { getIsElectronRuntime } from "@/constants/layout";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
@@ -110,8 +110,8 @@ function resolveActionShortcutKeys(
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
const platform = { isMac, isDesktop };
const isDesktopApp = getIsElectronRuntime();
const platform = { isMac, isDesktop: isDesktopApp };
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
import { isDesktop } from "@/desktop/host";
import { isElectronRuntime } from "@/desktop/host";
import type {
DictationAudioSource,
@@ -168,7 +168,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isDesktop();
const isDesktopApp = isElectronRuntime();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsDesktopMac } from "@/constants/layout";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host";
@@ -96,7 +96,7 @@ function getSystemColorScheme(): ColorScheme {
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { mergeProviderPreferences } from "./use-form-preferences";
describe("mergeProviderPreferences", () => {
it("stores the selected model for a provider", () => {
expect(
mergeProviderPreferences({
preferences: {},
provider: "claude",
updates: { model: "claude-opus-4-6" },
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-opus-4-6",
},
},
});
});
it("merges thinking preferences by model without dropping existing entries", () => {
expect(
mergeProviderPreferences({
preferences: {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
},
},
},
},
provider: "claude",
updates: {
thinkingByModel: {
"claude-opus-4-6": "high",
},
},
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
"claude-opus-4-6": "high",
},
},
},
});
});
});

View File

@@ -2,6 +2,7 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
@@ -35,6 +36,36 @@ export interface UseFormPreferencesReturn {
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
},
},
};
}
export function useFormPreferences(): UseFormPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({

View File

@@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { isDesktop } from "@/desktop/host";
import { isElectronRuntime } from "@/desktop/host";
import {
normalizePickedImageAssets,
openImagePathsWithDesktopDialog,
@@ -45,7 +45,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isDesktop()) {
if (Platform.OS === "web" && isElectronRuntime()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;

View File

@@ -17,12 +17,12 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim();
const isDesktop = shouldUseDesktopDaemon();
const isDesktopApp = shouldUseDesktopDaemon();
const query = useQuery({
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadDesktopDaemonServerId,
enabled: isDesktop,
enabled: isDesktopApp,
staleTime: Infinity,
gcTime: Infinity,
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
@@ -32,7 +32,7 @@ export function useIsLocalDaemon(serverId: string): boolean {
retry: false,
});
if (!isDesktop || normalizedServerId.length === 0) {
if (!isDesktopApp || normalizedServerId.length === 0) {
return false;
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsDesktop } from "@/constants/layout";
import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -66,7 +66,7 @@ export function useKeyboardShortcuts({
if (Platform.OS !== "web") return;
if (isMobile) return;
const isDesktopApp = getIsDesktop();
const isDesktopApp = getIsElectronRuntime();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {

View File

@@ -30,6 +30,16 @@ describe("use-settings", () => {
);
});
it("defaults theme to auto when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.theme).toBe("auto");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {

View File

@@ -12,7 +12,7 @@ export interface AppSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "dark",
theme: "auto",
manageBuiltInDaemon: true,
};

View File

@@ -4,15 +4,15 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
import { getIsElectronRuntime } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
const isDesktopApp = getIsElectronRuntime();
return useMemo(() => {
const platform = { isMac, isDesktop };
const platform = { isMac, isDesktop: isDesktopApp };
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return null;
@@ -23,5 +23,5 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
}, [actionId, overrides, isMac, isDesktop]);
}, [actionId, overrides, isMac, isDesktopApp]);
}

View File

@@ -6,7 +6,7 @@ import type {
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
HostRuntimeController,
HostRuntimeStore,
@@ -112,6 +112,7 @@ function makeFetchAgentsEntry(input: {
title?: string | null;
requiresAttention?: boolean;
attentionReason?: "permission" | "error" | null;
archivedAt?: string | null;
}): FetchAgentsEntry {
return {
agent: {
@@ -145,7 +146,7 @@ function makeFetchAgentsEntry(input: {
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null,
archivedAt: null,
archivedAt: input.archivedAt ?? null,
labels: {},
},
project: {
@@ -1134,6 +1135,93 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => {
const host = makeHost({
serverId: "srv_archived_rehydrate",
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:30:00.000Z",
archivedAt: "2026-03-30T15:31:00.000Z",
title: "Archived remotely",
}),
],
subscriptionId: "app:srv_archived_rehydrate",
}),
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
useSessionStore.getState().setAgents(host.serverId, () => {
const stale = makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:29:00.000Z",
archivedAt: null,
title: "Stale active copy",
}).agent;
const staleAgent: Agent = {
...stale,
serverId: host.serverId,
createdAt: new Date(stale.createdAt),
updatedAt: new Date(stale.updatedAt),
lastUserMessageAt: null,
lastActivityAt: new Date(stale.updatedAt),
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
};
return new Map([
[
stale.id,
staleAgent,
],
]);
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
let archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
while (!archivedAt && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
}
expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z");
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("records unavailable startup probes when no connection can be established", async () => {
const host = makeHost({
connections: [

View File

@@ -15,7 +15,10 @@ import {
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import {
shouldUseDesktopDaemon,
startDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
@@ -33,6 +36,10 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRuntimeBootstrapResult =
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
| { ok: false; error: string };
export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
@@ -1073,6 +1080,7 @@ const DEFAULT_LOCALHOST_ENDPOINT =
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore {
@@ -1161,20 +1169,40 @@ export class HostRuntimeStore {
}
}
private async bootstrapDesktop(): Promise<void> {
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
try {
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection || !daemon.serverId) {
return;
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return {
ok: false,
error: "Desktop daemon did not return a listen address.",
};
}
await this.upsertHostConnection({
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
});
if (!serverId) {
return {
ok: false,
error: "Desktop daemon did not return a server id.",
};
}
if (!connectionFromListen(listenAddress)) {
return {
ok: false,
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
};
}
return {
ok: true,
listenAddress,
serverId,
hostname: daemon.hostname,
};
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error);
return {
ok: false,
error: toErrorMessage(error),
};
}
}
@@ -1280,6 +1308,36 @@ export class HostRuntimeStore {
return this.upsertConnectionFromOffer(offer);
}
async addConnectionFromListenAndWaitForOnline(input: {
listenAddress: string;
serverId: string;
hostname: string | null;
timeoutMs?: number;
}): Promise<HostProfile> {
const normalizedListenAddress = input.listenAddress.trim();
const serverId = input.serverId.trim();
const connection = connectionFromListen(normalizedListenAddress);
if (!connection) {
throw new Error(`Unsupported listen address: ${input.listenAddress}`);
}
if (!serverId) {
throw new Error("Desktop daemon did not return a server id.");
}
const profile = await this.upsertHostConnection({
serverId,
label: input.hostname ?? undefined,
connection,
});
await this.waitForConnectionOnline({
serverId,
connectionId: connection.id,
timeoutMs: input.timeoutMs,
});
return profile;
}
async renameHost(serverId: string, label: string): Promise<void> {
const next = this.hosts.map((h) =>
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
@@ -1441,6 +1499,100 @@ export class HostRuntimeStore {
}
}
private waitForConnectionOnline(input: {
serverId: string;
connectionId: string;
timeoutMs?: number;
}): Promise<void> {
const { serverId, connectionId } = input;
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const cleanup = (unsubscribe: (() => void) | null): void => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
unsubscribe?.();
};
const settle = (
unsubscribe: (() => void) | null,
outcome: { ok: true } | { ok: false; error: Error },
): void => {
if (settled) {
return;
}
settled = true;
cleanup(unsubscribe);
if (outcome.ok) {
resolve();
} else {
reject(outcome.error);
}
};
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
const snapshot = this.getSnapshot(serverId);
if (!snapshot) {
return {
ok: false,
error: new Error(`Unknown host runtime for serverId ${serverId}`),
};
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "online"
) {
return { ok: true };
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "error"
) {
return {
ok: false,
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
};
}
return null;
};
const unsubscribe = this.subscribe(serverId, () => {
const outcome = readSnapshot();
if (outcome) {
settle(unsubscribe, outcome);
}
});
timeoutHandle = setTimeout(() => {
settle(unsubscribe, {
ok: false,
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
});
}, timeoutMs);
const initialOutcome = readSnapshot();
if (initialOutcome) {
settle(unsubscribe, initialOutcome);
return;
}
void this.runProbeCycleNow(serverId).catch((error) => {
settle(unsubscribe, {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
});
});
});
}
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {

View File

@@ -4,7 +4,7 @@ import type { ImageAttachment } from "@/components/message-input";
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
@@ -40,7 +40,7 @@ import {
} from "@/runtime/host-runtime";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
import { encodeImages } from "@/utils/encode-images";
@@ -233,7 +233,7 @@ function DraftAgentScreenContent({
isCreateFlow: true,
onlineServerIds,
});
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 toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);

View File

@@ -1,52 +1,51 @@
import { useEffect } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor } from "@/constants/layout";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const trafficLightPadding = useTrafficLightPadding();
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
const openAgentList = usePanelStore((s) => s.openAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const isCompactLayout = isCompactFormFactor();
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
if (!isMobile) {
if (!isCompactLayout) {
openAgentList();
}
}, [isMobile, openAgentList]);
}, [isCompactLayout, openAgentList]);
return (
<View style={styles.container} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>
<View style={styles.content}>
<PaseoLogo size={56} />
<Text style={styles.heading}>What shall we build today?</Text>
<Pressable
style={({ hovered }) => [styles.openButton, hovered && styles.openButtonHovered]}
onPress={() => {
void openProjectPicker();
}}
testID="open-project-submit"
>
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.openButtonText}>Add a project</Text>
</Pressable>
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content} {...dragHandlers}>
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
Add a project
</Button>
</View>
</View>
</View>
);
@@ -58,43 +57,32 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
menuToggle: {
position: "absolute",
top: theme.spacing[3],
left: theme.spacing[3],
zIndex: 1,
},
content: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
gap: 0,
padding: theme.spacing[6],
},
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
},
heading: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
openButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
openButtonHovered: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
openButtonText: {
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));

View File

@@ -1,10 +1,14 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View } from "react-native";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
const isFocused = useIsFocused();
@@ -44,13 +48,26 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title="Sessions" />
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
{sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No sessions yet</Text>
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
>
Back
</Button>
</View>
) : (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
)}
</View>
);
}
@@ -60,4 +77,15 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
},
}));

View File

@@ -4,7 +4,7 @@ import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native
import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/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 { Buffer } from "buffer";
import {
Sun,
@@ -51,7 +51,7 @@ import {
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isDesktop as isDesktopHost } from "@/desktop/host";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
@@ -59,6 +59,7 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { isCompactFormFactor } from "@/constants/layout";
// ---------------------------------------------------------------------------
// Section definitions
@@ -79,7 +80,7 @@ interface SettingsSectionDef {
icon: ComponentType<{ size: number; color: string }>;
}
function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDef[] {
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
@@ -88,7 +89,7 @@ function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDe
{ id: "about", label: "About", icon: Info },
];
if (context.isDesktop) {
if (context.isDesktopApp) {
sections.push(
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "daemon", label: "Daemon", icon: Settings },
@@ -174,7 +175,6 @@ interface HostsSectionProps {
daemons: HostProfile[];
settings: AppSettings;
routeServerId: string;
isDesktop: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
@@ -464,10 +464,10 @@ function DiagnosticsSection({
interface AboutSectionProps {
appVersionText: string;
isDesktop: boolean;
isDesktopApp: boolean;
}
function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) {
function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>About</Text>
@@ -478,7 +478,7 @@ function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) {
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
{isDesktop ? <DesktopAppUpdateRow /> : null}
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
</View>
);
@@ -496,7 +496,7 @@ interface SettingsSectionContentProps {
aboutProps: AboutSectionProps;
appVersion: string | null;
isLocalDaemon: boolean;
isDesktop: boolean;
isDesktopApp: boolean;
}
function SettingsSectionContent({
@@ -507,7 +507,7 @@ function SettingsSectionContent({
aboutProps,
appVersion,
isLocalDaemon,
isDesktop,
isDesktopApp,
}: SettingsSectionContentProps) {
switch (sectionId) {
case "hosts":
@@ -521,9 +521,9 @@ function SettingsSectionContent({
case "about":
return <AboutSection {...aboutProps} />;
case "permissions":
return isDesktop ? <DesktopPermissionsSection /> : null;
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "daemon":
return isDesktop ? (
return isDesktopApp ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
) : null;
}
@@ -619,7 +619,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
function DesktopAppUpdateRow() {
const {
isDesktop,
isDesktopApp,
statusText,
availableUpdate,
errorMessage,
@@ -631,23 +631,23 @@ function DesktopAppUpdateRow() {
useFocusEffect(
useCallback(() => {
if (!isDesktop) {
if (!isDesktopApp) {
return undefined;
}
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktop]),
}, [checkForUpdates, isDesktopApp]),
);
const handleCheckForUpdates = useCallback(() => {
if (!isDesktop) {
if (!isDesktopApp) {
return;
}
void checkForUpdates();
}, [checkForUpdates, isDesktop]);
}, [checkForUpdates, isDesktopApp]);
const handleInstallUpdate = useCallback(() => {
if (!isDesktop) {
if (!isDesktopApp) {
return;
}
@@ -667,9 +667,9 @@ function DesktopAppUpdateRow() {
console.error("[Settings] Failed to open app update confirmation", error);
Alert.alert("Error", "Unable to open the update confirmation dialog.");
});
}, [installUpdate, isDesktop]);
}, [installUpdate, isDesktopApp]);
if (!isDesktop) {
if (!isDesktopApp) {
return null;
}
@@ -745,7 +745,7 @@ export default function SettingsScreen() {
const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = isDesktopHost();
const isDesktopApp = isElectronRuntime();
const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -896,12 +896,6 @@ export default function SettingsScreen() {
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
if (newTheme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(newTheme);
}
},
[updateSettings],
);
@@ -935,14 +929,13 @@ export default function SettingsScreen() {
}
}, [isPlaybackTestRunning, voiceAudioEngine]);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sections = getSettingsSections({ isDesktop });
const isCompactLayout = isCompactFormFactor();
const sections = getSettingsSections({ isDesktopApp });
const hostsProps: HostsSectionProps = {
daemons,
settings,
routeServerId,
isDesktop,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
@@ -991,7 +984,7 @@ export default function SettingsScreen() {
const aboutProps: AboutSectionProps = {
appVersionText,
isDesktop,
isDesktopApp,
};
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
@@ -1001,7 +994,7 @@ export default function SettingsScreen() {
aboutProps,
appVersion,
isLocalDaemon,
isDesktop,
isDesktopApp,
};
if (isLoading) {
@@ -1015,7 +1008,7 @@ export default function SettingsScreen() {
return (
<View style={styles.container}>
<MenuHeader title="Settings" />
{isMobile ? (
{isCompactLayout ? (
<SettingsMobileLayout sections={sections} sectionContentProps={sectionContentProps} />
) : (
<SettingsDesktopLayout sections={sections} sectionContentProps={sectionContentProps} />

View File

@@ -17,7 +17,7 @@ import {
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
import { getIsElectronRuntime } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
@@ -93,8 +93,8 @@ export function KeyboardShortcutsSection() {
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
const isDesktopApp = getIsElectronRuntime();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
function cancelCapture() {
setCapturedCombos([]);
@@ -180,7 +180,10 @@ export function KeyboardShortcutsSection() {
<Text style={styles.subsectionTitle}>{section.title}</Text>
<View style={settingsStyles.card}>
{section.rows.map(function (row, index) {
const bindingId = getBindingIdForAction(row.id, { isMac, isDesktop });
const bindingId = getBindingIdForAction(row.id, {
isMac,
isDesktop: isDesktopApp,
});
const overrideCombo = bindingId ? overrides[bindingId] : undefined;
return (

View File

@@ -1,29 +1,318 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
type StartupSplashScreenProps = {
bootstrapState?: {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
};
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const DOCS_URL = "https://paseo.sh/docs";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[8],
paddingVertical: theme.spacing[8],
},
status: {
containerError: {
justifyContent: "flex-start",
paddingTop: theme.spacing[16],
},
centeredContent: {
alignItems: "center",
justifyContent: "center",
maxWidth: 520,
width: "100%",
},
errorContent: {
alignItems: "stretch",
maxWidth: 720,
width: "100%",
gap: theme.spacing[6],
},
errorHeader: {
alignItems: "flex-start",
},
title: {
marginTop: theme.spacing[8],
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
titleError: {
textAlign: "left",
},
subtitleRow: {
marginTop: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
progressSteps: {
marginTop: theme.spacing[4],
gap: theme.spacing[3],
width: "100%",
},
progressStepRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
subtitle: {
marginTop: theme.spacing[8],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
subtitleInline: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
errorDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
errorMessage: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontFamily: Fonts.mono,
},
logsMeta: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
logsContainer: {
height: 200,
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
logsScroll: {
flexGrow: 0,
},
logsContent: {
padding: theme.spacing[4],
},
logsText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
actionRow: {
flexDirection: "row",
gap: theme.spacing[3],
flexWrap: "wrap",
},
}));
export function StartupSplashScreen() {
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
const dragHandlers = useDesktopDragHandlers();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const phase = bootstrapState?.phase;
const isError = phase === "error";
const isSimpleSplash = bootstrapState === undefined;
useEffect(() => {
if (!isError) {
setDaemonLogs(null);
setLogsError(null);
setIsLoadingLogs(false);
return;
}
let isCancelled = false;
setIsLoadingLogs(true);
setLogsError(null);
void getDesktopDaemonLogs()
.then((logs) => {
if (isCancelled) {
return;
}
setDaemonLogs(logs);
})
.catch((error) => {
if (isCancelled) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setDaemonLogs(null);
setLogsError(`Unable to load daemon logs: ${message}`);
})
.finally(() => {
if (!isCancelled) {
setIsLoadingLogs(false);
}
});
return () => {
isCancelled = true;
};
}, [isError]);
const progressSteps =
phase === "starting-daemon"
? [{ key: "starting-daemon", label: "Starting local server...", status: "active" as const }]
: phase === "connecting"
? [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connecting to local server...", status: "active" as const },
]
: [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connected to local server", status: "complete" as const },
];
const logsText = useMemo(() => {
if (isLoadingLogs) {
return "Loading daemon logs...";
}
if (daemonLogs?.contents) {
return daemonLogs.contents;
}
if (logsError) {
return logsError;
}
return "No daemon logs available.";
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
const handleCopyLogs = () => {
const payload = daemonLogs?.logPath
? `${daemonLogs.logPath}\n\n${daemonLogs.contents}`
: logsText;
void Clipboard.setStringAsync(payload);
};
if (isSimpleSplash) {
return (
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up</Text>
</View>
);
}
if (!isError) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<View style={styles.progressSteps}>
{progressSteps.map((step) => (
<View key={step.key} style={styles.progressStepRow}>
{step.status === "complete" ? (
<Check size={18} color={theme.colors.success} />
) : (
<ActivityIndicator color={theme.colors.accent} />
)}
<Text style={styles.subtitleInline}>{step.label}</Text>
</View>
))}
</View>
</View>
</View>
);
}
return (
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up</Text>
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
</View>
<View style={styles.actionRow}>
<Button
variant="secondary"
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
onPress={handleCopyLogs}
>
Copy logs
</Button>
<Button
variant="outline"
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outline"
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(DOCS_URL)}
>
Docs
</Button>
<Button
variant="default"
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
onPress={bootstrapState.retry}
>
Retry
</Button>
</View>
</View>
</View>
);
}

View File

@@ -102,6 +102,17 @@ describe("workspace agent visibility", () => {
).toBe(true);
});
it("prunes pinned archived agent tabs because archive state is authoritative", () => {
expect(
shouldPruneWorkspaceAgentTab({
agentId: "archived-agent",
agentsHydrated: true,
knownAgentIds: new Set(["archived-agent"]),
activeAgentIds: new Set<string>(),
}),
).toBe(true);
});
it("does not prune active agent tabs", () => {
const knownAgentIds = new Set(["active-agent"]);
const activeAgentIds = new Set(["active-agent"]);

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -71,4 +72,90 @@ describe("workspace bulk close helpers", () => {
"This will close 1 terminal(s). Any running process in a closed terminal will be stopped immediately.",
);
});
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
makeTerminalTab("t2"),
makeFileTab("/repo/README.md"),
]);
const closedTabIds: string[] = [];
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const closeItems = vi.fn(async () => ({
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
],
requestId: "req-1",
}));
const result = await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
},
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
logLabel: "all tabs",
});
expect(closeItems).toHaveBeenCalledTimes(1);
expect(closeItems).toHaveBeenCalledWith({
agentIds: ["a1"],
terminalIds: ["t1", "t2"],
});
expect(result).toEqual({
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
],
requestId: "req-1",
});
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
expect(cleanupCalls).toEqual([
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ tabId: "file_/repo/README.md" },
]);
});
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
makeFileTab("/repo/README.md"),
]);
const closedTabIds: string[] = [];
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const warn = vi.fn();
const result = await closeBulkWorkspaceTabs({
groups,
client: {
closeItems: async () => {
throw new Error("rpc failed");
},
},
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
},
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
warn,
logLabel: "others",
});
expect(warn).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
});
});

View File

@@ -1,3 +1,4 @@
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export type BulkClosableTabGroups = {
@@ -6,6 +7,22 @@ export type BulkClosableTabGroups = {
otherTabs: Array<{ tabId: string }>;
};
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
interface CloseWorkspaceTabWithCleanupInput {
tabId: string;
target?: WorkspaceTabDescriptor["target"];
}
interface CloseBulkWorkspaceTabsInput {
client: Pick<DaemonClient, "closeItems"> | null;
groups: BulkClosableTabGroups;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
logLabel: string;
warn?: (message: string, payload: object) => void;
}
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
const groups: BulkClosableTabGroups = {
agentTabs: [],
@@ -50,3 +67,72 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
}
return `This will archive ${agentTabs.length} agent(s).`;
}
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
}
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
[],
);
}
export async function closeBulkWorkspaceTabs(
input: CloseBulkWorkspaceTabsInput,
): Promise<CloseItemsPayload | null> {
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
let payload: CloseItemsPayload | null = null;
if (hasDestructiveTabs && client) {
try {
payload = await client.closeItems({
agentIds: groups.agentTabs.map((tab) => tab.agentId),
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
});
} catch (error) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
}
} else if (hasDestructiveTabs) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
error: new Error("Daemon client not available"),
});
}
const successfulAgentIds = toSuccessfulAgentIds(payload);
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
for (const { tabId, agentId } of groups.agentTabs) {
if (!successfulAgentIds.has(agentId)) {
continue;
}
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
for (const { tabId, terminalId } of groups.terminalTabs) {
if (!successfulTerminalIds.has(terminalId)) {
continue;
}
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
});
}
for (const { tabId } of groups.otherTabs) {
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId });
});
}
return payload;
}

View File

@@ -8,7 +8,17 @@ import {
View,
type LayoutChangeEvent,
} from "react-native";
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
import {
CopyX,
ArrowLeftToLine,
ArrowRightToLine,
Columns2,
Copy,
Rows2,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import {
@@ -73,6 +83,7 @@ type WorkspaceDesktopTabsRowProps = {
externalDndContext?: boolean;
activeDragTabId?: string | null;
tabDropPreviewIndex?: number | null;
showPaneSplitActions?: boolean;
};
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
@@ -258,7 +269,14 @@ function TabChip({
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
{tab.target.kind === "agent" ? (
<View style={styles.tooltipAgentRow}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
</View>
) : (
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
)}
</TooltipContent>
</Tooltip>
@@ -273,6 +291,28 @@ function TabChip({
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
}
})()}
trailing={
entry.hint ? (
<Text style={styles.menuItemHint}>{entry.hint}</Text>
) : undefined
}
>
{entry.label}
</ContextMenuItem>
@@ -307,6 +347,7 @@ export function WorkspaceDesktopTabsRow({
externalDndContext = false,
activeDragTabId = null,
tabDropPreviewIndex = null,
showPaneSplitActions = true,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
@@ -483,48 +524,52 @@ export function WorkspaceDesktopTabsRow({
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitRight}
accessibilityRole="button"
accessibilityLabel="Split pane right"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitDown}
accessibilityRole="button"
accessibilityLabel="Split pane down"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
{showPaneSplitActions ? (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitRight}
accessibilityRole="button"
accessibilityLabel="Split pane right"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitDown}
accessibilityRole="button"
accessibilityLabel="Split pane down"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</>
) : null}
</View>
</View>
);
@@ -792,4 +837,17 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
tooltipAgentRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipAgentId: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
menuItemHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));

View File

@@ -14,6 +14,9 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import {
CopyX,
ArrowLeftToLine,
ArrowRightToLine,
ChevronDown,
Copy,
Ellipsis,
@@ -21,11 +24,12 @@ import {
PanelRight,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
@@ -73,7 +77,7 @@ import {
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
@@ -82,6 +86,10 @@ import {
WorkspaceTabIcon,
WorkspaceTabOptionRow,
} from "@/screens/workspace/workspace-tab-presentation";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
@@ -103,8 +111,10 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation";
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -332,6 +342,28 @@ function MobileWorkspaceTabOption({
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
}
})()}
trailing={
entry.hint ? (
<Text style={styles.menuItemHint}>{entry.hint}</Text>
) : undefined
}
>
{entry.label}
</DropdownMenuItem>
@@ -549,7 +581,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = isCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
@@ -952,7 +984,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
if (
canPruneAgentTabs &&
tab.target.kind === "agent" &&
!pinnedAgentIds.has(tab.target.agentId) &&
shouldPruneWorkspaceAgentTab({
agentId: tab.target.agentId,
agentsHydrated: hasHydratedAgents,
@@ -1382,62 +1413,45 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return;
}
for (const { tabId, terminalId } of groups.terminalTabs) {
await closeTab(tabId, async () => {
try {
await killTerminalAsync(terminalId);
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
});
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
}
} catch (error) {
console.warn(`[WorkspaceScreen] Failed to close terminal tab ${logLabel}`, {
terminalId,
error,
});
const closeItemsPayload = await closeBulkWorkspaceTabs({
client,
groups,
closeTab,
closeWorkspaceTabWithCleanup: (cleanupInput) => {
if (!persistenceKey) {
return;
}
});
}
closeWorkspaceTabWithCleanup(cleanupInput);
},
logLabel,
warn: (message, payload) => {
console.warn(message, payload);
},
});
for (const { tabId, agentId } of groups.agentTabs) {
if (!normalizedServerId) {
continue;
if (closeItemsPayload) {
for (const terminal of closeItemsPayload.terminals) {
if (!terminal.success) {
continue;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
};
});
}
await closeTab(tabId, async () => {
try {
await archiveAgent({ serverId: normalizedServerId, agentId });
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
}
} catch (error) {
console.warn(`[WorkspaceScreen] Failed to archive agent tab ${logLabel}`, {
agentId,
error,
});
}
});
}
for (const { tabId } of groups.otherTabs) {
await closeTab(tabId, async () => {
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId });
}
});
if (normalizedServerId) {
applyArchivedAgentCloseResults({
queryClient,
serverId: normalizedServerId,
results: closeItemsPayload.agents,
});
}
}
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
@@ -1445,10 +1459,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
archiveAgent,
client,
closeTab,
closeWorkspaceTabWithCleanup,
killTerminalAsync,
normalizedServerId,
persistenceKey,
queryClient,
@@ -1695,6 +1708,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
});
const activeTabDescriptor = activeTab?.descriptor ?? null;
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
return;
@@ -1873,6 +1888,17 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[buildPaneContentModel],
);
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
tabs.map((tab) => ({
tab,
isActive: tab.tabId === activeTabDescriptor?.tabId,
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: closingTabIds.has(tab.tabId),
})),
[activeTabDescriptor?.tabId, closingTabIds, hoveredCloseTabKey, tabs],
);
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
if (!persistenceKey || paneFocusSuppressedRef.current) {
return;
@@ -1924,6 +1950,19 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[persistenceKey, reorderWorkspaceTabsInPane],
);
const handleReorderTabsInFocusedPane = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
if (!focusedPaneId) {
return;
}
handleReorderTabsInPane(
focusedPaneId,
nextTabs.map((tab) => tab.tabId),
);
},
[focusedPaneId, handleReorderTabsInPane],
);
const renderSplitPaneEmptyState = useCallback(function renderSplitPaneEmptyState() {
return (
<View style={styles.emptyState}>
@@ -2179,6 +2218,32 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneId ?? undefined}
isFocused
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
onReorderTabs={handleReorderTabsInFocusedPane}
onNewTerminalTab={handleCreateTerminal}
onSplitRight={() => {}}
onSplitDown={() => {}}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
@@ -2186,7 +2251,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</GestureDetector>
) : (
<View style={styles.content}>
{workspaceLayout && persistenceKey ? (
{canRenderDesktopPaneSplits && workspaceLayout && persistenceKey ? (
<SplitContainer
layout={workspaceLayout}
focusModeEnabled={isFocusModeEnabled && !isMobile}
@@ -2422,6 +2487,10 @@ const styles = StyleSheet.create((theme) => ({
mobileTabMenuTriggerActive: {
backgroundColor: theme.colors.surface2,
},
menuItemHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
tabsContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,

View File

@@ -8,6 +8,8 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
hint?: string;
disabled?: boolean;
destructive?: boolean;
testID: string;
@@ -106,6 +108,7 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "copy-resume-command",
label: "Copy resume command",
icon: "copy",
testID: `${menuTestIDBase}-copy-resume-command`,
onSelect: () => {
void onCopyResumeCommand(agentId);
@@ -115,6 +118,8 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "copy-agent-id",
label: "Copy agent id",
icon: "copy",
hint: agentId.slice(0, 7),
testID: `${menuTestIDBase}-copy-agent-id`,
onSelect: () => {
void onCopyAgentId(agentId);
@@ -130,6 +135,7 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close-before",
label: buildCloseBeforeLabel(surface),
icon: "arrow-left-to-line",
disabled: isFirstTab,
testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`,
onSelect: () => {
@@ -140,6 +146,7 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close-after",
label: buildCloseAfterLabel(surface),
icon: "arrow-right-to-line",
disabled: isLastTab,
testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`,
onSelect: () => {
@@ -150,6 +157,7 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close-others",
label: "Close other tabs",
icon: "copy-x",
disabled: isOnlyTab,
testID: `${menuTestIDBase}-close-others`,
onSelect: () => {
@@ -160,6 +168,7 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close",
label: "Close",
icon: "x",
testID: `${menuTestIDBase}-close`,
onSelect: () => {
void onCloseTab(tab.tabId);

View File

@@ -175,21 +175,21 @@ const lightSemanticColors = {
} as const;
const darkSemanticColors = {
// Surfaces (layers)
surface0: "#18181c", // App background
surface1: "#1f1f23", // Subtle hover
surface2: "#27272a", // Elevated: badges, inputs, sheets
surface3: "#3f3f46", // Highest elevation
surface4: "#52525b", // Extra emphasis
surfaceSidebar: "#121216", // Sidebar background (darker than main)
// Surfaces (layers) — subtle teal tint
surface0: "#181B1A", // App background
surface1: "#1E2120", // Subtle hover
surface2: "#272A29", // Elevated: badges, inputs, sheets
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceSidebar: "#141716", // Sidebar background (darker than main)
// Text
foreground: "#fafafa",
foregroundMuted: "#a1a1aa",
foregroundMuted: "#A1A5A4",
// Borders
border: "#27272a",
borderAccent: "#34343a",
border: "#252B2A",
borderAccent: "#2F3534",
// Brand
accent: "#20744A",
@@ -203,28 +203,28 @@ const darkSemanticColors = {
successForeground: "#ffffff",
// Legacy aliases (for gradual migration)
background: "#18181c",
popover: "#27272a",
background: "#181B1A",
popover: "#272A29",
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: "#18181b",
secondary: "#27272a",
primaryForeground: "#181B1A",
secondary: "#272A29",
secondaryForeground: "#fafafa",
muted: "#27272a",
mutedForeground: "#a1a1aa",
accentBorder: "#34343a",
input: "#27272a",
muted: "#272A29",
mutedForeground: "#A1A5A4",
accentBorder: "#2F3534",
input: "#272A29",
ring: "#d4d4d8",
terminal: {
background: "#18181c",
background: "#181B1A",
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: "#18181c",
cursorAccent: "#181B1A",
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
black: "#121214",
black: "#141716",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
@@ -233,7 +233,7 @@ const darkSemanticColors = {
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#3f3f46",
brightBlack: "#434645",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",

View File

@@ -8,6 +8,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -168,7 +169,12 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void openExternalUrl(uri);
}),
);
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {

View File

@@ -1,12 +1,16 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import {
getIsDesktopMac,
getIsElectronRuntimeMac,
getIsElectronRuntime,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
DESKTOP_WINDOW_CONTROLS_WIDTH,
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
import { isElectronRuntime } from "@/desktop/host";
import { usePanelStore } from "@/stores/panel-store";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
export async function toggleMaximize() {
@@ -52,7 +56,7 @@ export function isInteractiveDesktopDragTarget(target: unknown): boolean {
export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false);
const lastPointerDownAt = useRef(0);
const isActive = Platform.OS === "web" && isDesktop();
const isActive = Platform.OS === "web" && isElectronRuntime();
useEffect(() => {
if (!isActive) return;
@@ -120,11 +124,19 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]);
}
export function useTrafficLightPadding(): { left: number; top: number } {
type RawWindowControlsPadding = {
left: number;
right: number;
top: number;
};
type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar";
function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
if (Platform.OS !== "web" || !getIsElectronRuntime()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -175,12 +187,52 @@ export function useTrafficLightPadding(): { left: number; top: number } {
};
}, []);
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
return useMemo((): RawWindowControlsPadding => {
if (!getIsElectronRuntime() || isFullscreen) {
return { left: 0, right: 0, top: 0 };
}
if (getIsElectronRuntimeMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
return {
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
};
}, [isFullscreen]);
}
export function useWindowControlsPadding(
role: WindowControlsPaddingRole,
): { left: number; right: number; top: number } {
const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen);
const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const rawPadding = useRawWindowControlsPadding();
const sidebarClosed = !sidebarOpen;
let left = 0;
let right = 0;
let top = 0;
if (role === "sidebar") {
left = rawPadding.left;
top = rawPadding.top;
} else if (role === "header") {
left = sidebarClosed ? rawPadding.left : 0;
right = explorerOpen ? 0 : rawPadding.right;
} else if (role === "tabRow") {
left = sidebarClosed && focusModeEnabled ? rawPadding.left : 0;
right = focusModeEnabled && !explorerOpen ? rawPadding.right : 0;
} else if (role === "explorerSidebar") {
right = rawPadding.right;
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
return useMemo(() => ({ left, right, top }), [left, right, top]);
}

View File

@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { getIsDesktopMac } from "@/constants/layout";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut";
export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") {
return Platform.OS === "ios" ? "mac" : "non-mac";
}
if (getIsDesktopMac()) return "mac";
if (getIsElectronRuntimeMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? "";

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
});

View File

@@ -0,0 +1,41 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen ||
input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}

View File

@@ -1,4 +1,4 @@
import { isDesktop } from "@/desktop/host";
import { isElectronRuntime } from "@/desktop/host";
import type {
AudioEngine,
AudioEngineCallbacks,
@@ -289,7 +289,7 @@ export function createAudioEngine(
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isDesktop();
const isDesktopApp = isElectronRuntime();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.35",
"version": "0.1.40",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/relay": "0.1.40",
"@getpaseo/server": "0.1.40",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -2,9 +2,13 @@ import { Command } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createTerminalCommand } from "./commands/terminal/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
@@ -137,6 +141,18 @@ export function createCli(): Command {
// Daemon commands
program.addCommand(createDaemonCommand());
// Chat commands
program.addCommand(createChatCommand());
// Terminal commands
program.addCommand(createTerminalCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
// Permission commands
program.addCommand(createPermitCommand());

View File

@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);

View File

@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addLsOptions(cmd: Command): Command {
return cmd
@@ -193,11 +194,7 @@ export async function runLsCommand(
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
}
// Apply label filtering only when explicitly requested.

View File

@@ -1,5 +1,6 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
SingleResult,
@@ -75,12 +76,9 @@ export async function runStopCommand(
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
// Stop specific agent

View File

@@ -0,0 +1,31 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export interface ChatCreateOptions extends ChatCommandOptions {
purpose?: string;
}
export async function runCreateCommand(
name: string,
options: ChatCreateOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.createChatRoom({
name,
purpose: options.purpose,
});
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,24 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runDeleteCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.deleteChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,69 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runDeleteCommand } from "./delete.js";
import { runPostCommand } from "./post.js";
import { runReadCommand } from "./read.js";
import { runWaitCommand } from "./wait.js";
export function createChatCommand(): Command {
const chat = new Command("chat").description("Manage chat rooms for agent coordination");
addJsonAndDaemonHostOptions(
chat
.command("create")
.description("Create a chat room")
.argument("<name>", "Room name (must be unique)")
.option("--purpose <text>", "Room purpose/description"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
chat.command("ls").description("List chat rooms"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
chat
.command("inspect")
.description("Inspect a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
chat
.command("delete")
.description("Delete a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
chat
.command("post")
.description("Post a chat message")
.argument("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions(
chat
.command("read")
.description("Read chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--limit <n>", "Maximum number of messages to return")
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
.option("--agent <agent-id>", "Filter by author agent ID"),
).action(withOutput(runReadCommand));
addJsonAndDaemonHostOptions(
chat
.command("wait")
.description("Wait for new chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--timeout <duration>", "Maximum wait time"),
).action(withOutput(runWaitCommand));
return chat;
}

View File

@@ -0,0 +1,24 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runInspectCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.inspectChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,23 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runLsCommand(
options: ChatCommandOptions,
_command: Command,
): Promise<ListResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.listChatRooms();
return {
type: "list",
data: payload.rooms.map(toChatRoomRow),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err);
} finally {
await client.close().catch(() => {});
}
}

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