Compare commits

..

39 Commits

Author SHA1 Message Date
Mohamed Boudra
98c0033f22 test(daemon): make self-update paths platform-stable 2026-06-26 13:25:17 +07:00
Mohamed Boudra
444214cbe2 feat(daemon): harden remote self-update
Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.
2026-06-26 13:02:35 +07:00
Davy
7f6403f517 fix: use npm install@latest and global concurrency guard
Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard
2026-06-26 12:54:17 +07:00
Davy
be8c2a54ce fix: clean up progress subscription on unmount, use typed phase cast
Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }
2026-06-26 12:53:23 +07:00
Davy
c6c030ab26 feat: add remote daemon self-update from client
Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.
2026-06-26 12:53:23 +07:00
Mohamed Boudra
8c3b709794 fix(server): honor X-Forwarded-Proto so daemon web UI auto-connects behind HTTPS reverse proxy (#1739)
* fix(server): honor forwarded proto for daemon web UI

* fix(server): configure trusted daemon proxies

* docs: document daemon web UI reverse proxy setup

* docs: consolidate web UI proxy guidance
2026-06-26 13:26:50 +08:00
Mohamed Boudra
c5c2ace698 docs: document self-hosting the web UI and community projects
Documents the daemon-served web UI from #1635: enabling it, reverse
proxy, TLS, tunnels, and the auth/exposure model. Adds a community
projects page for community-built self-hosting tooling.
2026-06-26 11:35:36 +07:00
Mohamed Boudra
a49c658d1f Merge sidebar workspaces across all connected hosts (#1538)
* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
2026-06-26 12:16:49 +08:00
Mohamed Boudra
1109e453bc Merge branch 'main' of github.com:getpaseo/paseo 2026-06-26 11:01:38 +07:00
Mohamed Boudra
4e2c06ec71 Serve the web client from the daemon (#1635)
* Serve the web client from the daemon

Keep the bundled browser UI opt-in and exclude it from desktop packaging so desktop builds do not ship a duplicate renderer.

* Escape daemon web UI bootstrap hint

* Fix bundled web UI dist path
2026-06-26 12:00:11 +08:00
Mohamed Boudra
d4cdd4d749 fix(app): let native user messages use font metrics 2026-06-26 10:50:16 +07:00
paseo-ai[bot]
3d6b4adc68 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-25 17:24:07 +00:00
Mohamed Boudra
bbde200aa2 chore(release): cut 0.1.101 2026-06-26 00:19:37 +07:00
Mohamed Boudra
a19e3305ad docs(changelog): 0.1.101 2026-06-26 00:10:37 +07:00
Mohamed Boudra
4ba0ce44e5 chore(acp): update provider catalog 2026-06-25 23:52:20 +07:00
Mohamed Boudra
d5cb4421b5 fix(diagnostics): render sheet reports reliably
Diagnostic sheets now share one themed code surface. String reports render line by line so large log lines do not turn the whole report into one oversized native text surface.
2026-06-25 23:49:16 +07:00
Mohamed Boudra
26f169866f Add app diagnostic report (#1728)
* feat(diagnostics): add app diagnostic report

* fix(diagnostics): guard app diagnostic runs

* fix(diagnostics): include websocket runtime metrics

* fix(diagnostics): type websocket metric snapshot

* fix(diagnostics): include daemon shell env

* fix(loop): make stop cancellation test deterministic
2026-06-25 23:20:52 +08:00
Mohamed Boudra
bdd9419189 Speed up new Pi agent startup (#1727)
* fix(providers): avoid draft feature model guessing

* fix(providers): avoid draft command model guessing
2026-06-25 22:18:58 +08:00
Mohamed Boudra
f12c9e9cfa fix(voice): scope OpenAI voice credentials
Resolve OpenAI voice credentials and endpoints from voice-specific config before broader OpenAI fallbacks, and use the same REST STT provider for dictation and voice mode.
2026-06-25 18:41:57 +07:00
Mohamed Boudra
2692211ef9 fix(providers): prevent stale models during refresh
Provider settings sheets can remain mounted while hidden, so the old display cache could survive into a different provider. Scope the cache to the active provider identity and remount the sheet when that identity changes.
2026-06-25 18:41:57 +07:00
Mohamed Boudra
f13c496ebc Update ACP provider catalog 2026-06-25 18:41:57 +07:00
Mohamed Boudra
2cf041c4d8 Keep provider diagnostics useful when discovery is slow (#1724) 2026-06-25 19:30:19 +08:00
Mohamed Boudra
c00121273b Fix Windows daemon status output (#1725) 2026-06-25 19:17:02 +08:00
Mohamed Boudra
3b5e34fe12 Keep liveness pings responsive during slow requests (#1723) 2026-06-25 19:16:54 +08:00
Alcimério Rangel
9960772fe9 feat: add Brazilian Portuguese locale (#1653)
* Add Brazilian Portuguese locale

* Add missing pt-BR translations after main merge

* Test zh-CN translation fallback coverage
2026-06-25 17:36:04 +08:00
Mohamed Boudra
eaecd8d0a7 Prepare providers for direct Paseo tools (#1707)
* refactor(agent): decouple Paseo tools from MCP

Keep MCP as the fallback adapter while exposing the same runtime catalog for providers that can register tools directly.

* fix(protocol): keep native tool support server-internal

* fix(i18n): sync Japanese shortcut labels

* fix(agent): preserve schedule provider error
2026-06-25 13:20:51 +08:00
Mohamed Boudra
53e39e6d23 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-25 12:17:15 +07:00
Mohamed Boudra
ecf4f9037e docs(release): document release previews 2026-06-25 12:14:15 +07:00
Mohamed Boudra
f9ff668a71 Link worktrees to PRs from differently named tracked branches (#1718)
* fix(server): link PRs for differently named tracked branches

PR lookup now uses the configured tracked head ref when it differs from the local branch, while keeping fork owner scoping only for cross-repo remotes.

* fix(server): avoid extra PR lookup git reads

Use the shared lookup-target builder for an early local-branch result before reading origin and base metadata on on-demand PR status refreshes.
2026-06-25 05:05:32 +00:00
Mohamed Boudra
b561b108a7 refactor(server): extract the workspace-scripts feature into a deep module (#1716)
* refactor(server): extract the workspace-scripts feature into a deep module

The service-proxy-backed "workspace scripts" feature had no home: building a
workspace's scripts payload was duplicated between the descriptor builder and
buildWorkspaceScriptPayloadSnapshot (the same 9-input buildWorkspaceScriptPayloads
assembly), and the "scripts available on this daemon?" guard appeared a third time
in the start handler.

Move the feature into session/workspace-scripts/workspace-scripts-service.ts behind
createWorkspaceScriptsService(deps): { buildSnapshot, emitStatusUpdate, start }. The
payload assembly and availability guard now live in one place; the descriptor builder,
the status-emission path, and the start RPC all funnel through it. spawnWorkspaceScript
is injected as a port so the feature is testable without a real process. session.ts
drops ~98 lines; the two methods existing tests reach stay as thin delegators.

New zero-mock unit test covers the guard, status emission, and the start branch matrix
with injected fakes, the real service proxy + runtime store, and a fake launcher.

* refactor(server): address review — durable comment + documented test stand-in

- Drop the transient "#1714" PR reference from the buildWorkspaceScriptPayloadSnapshot
  accessor comment; state the durable reason instead.
- Hoist the opaque terminalManager test stand-in to a named const documenting the
  non-call guarantee, rather than an inline `as unknown as` cast.
2026-06-25 12:20:55 +08:00
Mohamed Boudra
0be5cc9dae refactor(server): extract the agent-update subscription stream into a deep module (#1715)
Move the per-client `agent_update` subscription out of session.ts into
session/agent-updates/ behind createAgentUpdatesService(deps). The module owns
the mutable subscription state, the bootstrap buffer, the provider-visibility
gate, and the filter predicate; the rest of session.ts no longer pokes the
subscription shape or hand-rolls `agent_update` payloads — the ~12 call sites
collapse to thin delegations (forwardLiveAgent / emitStoredRecord / removeAgent /
beginSubscription / flushBootstrapped / clearSubscription / dispose) plus the
pure, shared matchesAgentUpdatesFilter used by the snapshot listing pager.

The shared payload builders (buildAgentPayload / buildStoredAgentPayload /
isProviderVisibleToClient) and buildProjectPlacementForWorkspaceId stay in
session.ts and are injected as host callbacks, since they are used widely
outside this cluster. session.ts: 6187 -> 5967.

Behavior preserved: the existing session.workspaces.test.ts integration suite is
repointed through the new boundary and stays green; the previously-untested
filter / buffer / flush branches are now covered by a zero-mock unit test with
injected fakes. cleanup() now disposes the subscription (it was never cleared
before). The clear-attention-for-workspace path that emits agent_update directly
(bypassing the buffer) is left as-is — a pre-existing inconsistency, out of scope.
2026-06-25 12:20:46 +08:00
Mohamed Boudra
e4f32a4d82 refactor(server): extract workspace git-observer into a deep module (#1714)
* refactor(server): extract workspace git-observer into a deep module

Move the workspace git-observer cluster out of session.ts into
session/workspace-git-observer/ behind createWorkspaceGitObserverService(deps).
The module owns the per-cwd watch targets and the WorkspaceGitService
subscription handles, so the registration / dedupe / branch-change / teardown
lifecycle lives in one place instead of being reached into by hand from the
emit hot-path, the archive path, CheckoutSession's host, and dispose.

session.ts: 6401 -> 6219 lines. The caller surface narrows: archive teardown
calls removeForWorkspaceId / removeForCwd (no longer resolving watch targets and
cwds itself), and dispose() replaces iterating a raw subscription Map.

Sheds dead code orphaned by 40e27f5a0 (FSWatcher watching moved into
WorkspaceGitService): the workspaceGitFetchSubscriptions Map (never written), the
WorkspaceGitWatchTarget watchers/debounceTimer/refreshPromise/refreshQueued
fields, and the no-op closeWorkspaceGitWatchTarget method.

The module is unit-tested with injected fakes (zero mocks); the existing
session.workspace-git-watch.test.ts integration suite is repointed through the
new boundary and still passes.

Behavior is preserved verbatim, including a latent quirk where syncObservers
seeds descriptor state with a directory in the workspaceId slot (an effective
no-op) -- flagged as a follow-up, not changed here.

* refactor(server): clear watch targets on dispose; document shouldSkipUpdate dedupe

Addresses Greptile review on PR #1714:
- dispose() now clears watchTargets alongside subscriptions so post-teardown
  state is consistent (dispose is terminal; behavior-preserving).
- Document shouldSkipUpdate as a check-and-record dedupe gate (mutation on the
  changed path is intentional, preserved from the original).

* test(server): make git-observer test paths platform-portable

server-tests (windows-latest) failed: the test asserted raw POSIX path literals
against the service's resolve(cwd)-normalized output, which becomes D:\repo\ws1
on Windows. Resolve the test cwds the same way so assertions hold on every
platform (matches the path.resolve pattern in session.workspace-git-watch.test).
Test-only fix; production normalization is unchanged and correct.
2026-06-25 12:20:38 +08:00
Mohamed Boudra
b625b69302 Render images from Claude Code tool results in chat (#1717)
* feat(providers): render images Claude Code emits instead of base64

Claude returns images as base64 blocks inside tool_result content; they were
dumped into the tool output as a wall of raw base64 text. Materialize them to a
temp file and emit an assistant_message markdown image — the path Codex already
uses — so the client fetches the bytes over the existing WebSocket binary-frame
channel. No protocol or client change; assistant_message is an existing type.

Also strips the base64 out of the failed-tool-call error field, and widens the
Claude history-replay filter so the image survives reload. Collapses the image
writer Codex had duplicated twice into one shared materializeProviderImage.

* fix(providers): match only materialized image paths in the history filter

The history-replay filter recognized a provider image by a leading "![". Review
flagged that user-authored text starting with an image-markdown pointing at the
attachments dir could then replay as assistant output. Tighten the predicate to
the content-hashed <sha256>.<ext> shape the writer actually emits, so only
materialized images pass.

* fix(providers): handle Windows paths in the image-markdown predicate

On Windows the materialized image path uses backslash separators, and
escapeMarkdownImageSource doubles each backslash in the markdown source. The
history-replay predicate matched a single separator, so on a Windows daemon the
image was dropped on reload; the test path assertions failed for the same
reason. Allow one-or-more separators in the predicate, unescape the source in
the test helper, and add a Windows-path case to the predicate test.
2026-06-25 04:11:46 +00:00
Mohamed Boudra
1b9543023e refactor(server): extract workspace provisioning into a deep module (#1712)
* refactor(server): extract workspace provisioning into workspace-provisioning module

Move the "find-or-create a workspace & project for a directory" cluster out of
the 6.4k-line Session god class into session/workspace-provisioning/
behind an injected createWorkspaceProvisioningService(deps) port.

Nine methods (~215 LOC) — findOrCreateWorkspaceForDirectory,
resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory,
findOrCreateProjectForDirectory, ensureWorkspaceRecordUnarchived, and the
private helpers resolveWorkspaceDirectory / findExactWorkspaceByDirectory /
reclassifyOrUnarchiveWorkspaceForDirectory / resolveProjectRecordForPlacement —
now live in one deep module with a 5-method interface. The session shed four
pure-function imports it no longer needs and delegates from five call sites.

The module's only dependencies are the workspace/project registries and the
git-service checkout port — no emit, clientActivity, or observer coupling — so
it is now unit-testable with real file-backed registries and a fake git port
(workspace-provisioning-service.test.ts), instead of only through a booted
Session. Behavior is unchanged: the existing open_project / import / create-agent
characterization suites stay green.

* refactor(server): address review — drop redundant spread property, test always-create

- Remove the dead `workspaceId: input.workspace.workspaceId` in
  reclassifyOrUnarchiveWorkspaceForDirectory (already spread via
  ...input.workspace; behavior-identical).
- Add a direct test pinning createWorkspaceForDirectory's always-mint-fresh
  contract (call twice for the same cwd → two distinct workspace ids), the
  property that distinguishes it from findOrCreateWorkspaceForDirectory.
2026-06-25 03:36:14 +08:00
Mohamed Boudra
83123987d7 refactor(server): extract git-mutation primitives into git-mutation module (#1711)
* refactor(server): extract git-mutation primitives into git-mutation module

Move checkoutExistingBranch, createBranchFromBase and notifyGitMutation
(plus their internal ref-validation / clean-tree / branch-existence helpers)
out of the 6.5k-line Session class into
session/git-mutation/git-mutation-service.ts — a deep module with a
3-method interface and a createGitMutationService(deps) factory, mirroring
the git-metadata-generator port extracted in #1702.

These primitives were smeared across three sub-session boundaries as host
callbacks. CheckoutSession now takes gitMutation directly, shrinking
CheckoutSessionHost from 6 members to 4; the worktree session-config
builder and the auto-naming / worktree-creation paths call
this.gitMutation.* instead of private Session methods.

Adds git-mutation-service.test.ts: guard branches covered with in-memory
fakes, happy paths against a real temp git repo. The superseded
internal-mock tests in session.test.ts (which stubbed execCommand and the
git service) are removed in favour of the real-dependency coverage.

* refactor(server): address review on git-mutation-service

- Inline doesLocalBranchExist into its sole caller: once the redundant
  ref-validation is removed (createBranchFromBase already validates
  newBranchName with the "new branch" label), the helper is a pure
  passthrough to workspaceGitService.hasLocalBranch.
- Replace .some(...).toBe(true) snapshot assertions with toContainEqual so
  failures surface the full recorded call (and assert cwd too).
2026-06-25 01:01:40 +08:00
Mohamed Boudra
45a7a91768 Improve workspace names for slash-command prompts (#1709)
* fix(server): mark metadata prompts as naming input

* fix(app): add Japanese agent mode shortcut label

* fix(server): keep mock metadata prompt detection current

* test(app): retry e2e temp workspace cleanup
2026-06-25 00:00:43 +08:00
Mohamed Boudra
484ffc4334 refactor(server): extract git-metadata generators into checkout module (#1702)
The LLM-backed commit-message and PR-text generators lived in the 6.6k-line
session god-file but were consumed only by CheckoutSession, which reached them
through two host callbacks (the host comment even flagged that it "does not own
them"). The two methods were ~80% identical.

Lift them into session/checkout/git-metadata-generator.ts as a deep module with
a two-method interface, collapsing the duplicated diff -> fileList -> patch ->
prompt -> generate -> fallback scaffold. The LLM call is now an injected
StructuredTextGeneration port: production wires resolve-providers + structured
generation; the new unit test injects a fake and exercises success, both
fallback paths, and error rethrow without mocking any module.

CheckoutSession takes the generator as a typed collaborator instead of two host
callbacks; Session sheds ~160 lines plus five now-unused imports. Behaviour is
unchanged — the existing session.test.ts generation tests pass untouched.
2026-06-24 23:48:00 +08:00
sysCat64
507345dbee feat(i18n): add Japanese (ja) locale (#1694)
* feat(i18n): add Japanese (ja) locale

- Add packages/app/src/i18n/resources/ja.ts with 1287 keys matching en.ts
- Register ja in i18next.ts, locales.ts (SupportedLocale, LANGUAGE_OPTIONS,
  SUPPORTED_LANGUAGES, LANGUAGE_NATIVE_NAMES, LANGUAGE_NAMES_BY_LOCALE,
  resolveSupportedLocale)
- Add ja label key to all six existing locale files (ar, en, es, fr, ru, zh-CN)
- Update locales.test.ts to cover ja in all relevant assertions

Translation reviewed by Codex across four passes; terminology, placeholder
parity, and naturalness confirmed clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(i18n): add ja to resources.test.ts and fix test descriptions

- Import ja and add to key-parity, fallback-ratio, interpolation, and
  model-count assertions in resources.test.ts
- Update test descriptions to say "all supported language(s)" instead of
  "UN official language(s)" (ja is not a UN official language)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add Japanese README (README.ja.md)

Add README.ja.md translated from the English source, reviewed by Codex
across two passes. Link it in the language navigation of README.md and
README.zh-CN.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: apply oxfmt formatting to locales.ts and ja.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:11:35 +00:00
paseo-ai[bot]
70ea960153 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-24 10:46:37 +00:00
266 changed files with 25569 additions and 7753 deletions

View File

@@ -1,5 +1,29 @@
# Changelog
## 0.1.101 - 2026-06-26
### Added
- Copy a troubleshooting report from Settings when support needs host, daemon, provider, and log details ([#1728](https://github.com/getpaseo/paseo/pull/1728))
- Claude image tool results now render as images in chat ([#1717](https://github.com/getpaseo/paseo/pull/1717))
- Added Japanese ([#1694](https://github.com/getpaseo/paseo/pull/1694) by [@sysCat64](https://github.com/sysCat64))
- Added Brazilian Portuguese ([#1653](https://github.com/getpaseo/paseo/pull/1653) by [@Alcimerio](https://github.com/Alcimerio))
### Improved
- Provider diagnostics stay useful even when model discovery is slow ([#1724](https://github.com/getpaseo/paseo/pull/1724))
- Slow provider requests no longer make the app look disconnected ([#1723](https://github.com/getpaseo/paseo/pull/1723))
- Worktrees linked to differently named tracked branches find their PRs correctly ([#1718](https://github.com/getpaseo/paseo/pull/1718))
- Workspaces started from slash-command prompts get clearer names ([#1709](https://github.com/getpaseo/paseo/pull/1709))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Pi no longer creates empty sessions while loading new-agent options ([#1727](https://github.com/getpaseo/paseo/pull/1727))
- Windows daemon status finds the daemon process more reliably ([#1725](https://github.com/getpaseo/paseo/pull/1725))
- OpenAI voice credentials no longer affect other OpenAI-backed tools
- Provider model lists no longer disappear during refresh
## 0.1.100 - 2026-06-24
### Added

172
README.ja.md Normal file
View File

@@ -0,0 +1,172 @@
<p align="center">
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo logo">
</p>
<h1 align="center">Paseo</h1>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
</a>
<a href="https://github.com/getpaseo/paseo/releases">
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
</a>
<a href="https://x.com/moboudra">
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
</a>
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
<a href="https://www.reddit.com/r/PaseoAI/">
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
</a>
</p>
<p align="center">Claude Code、Codex、Copilot、OpenCode、Pi のエージェントを、ひとつのインターフェースで。</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo アプリのスクリーンショット" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo モバイルアプリ" width="100%">
</p>
> [!NOTE]
> 私はひとりでメンテナンスしているため、GitHub Issues を毎日確認できるとは限りません。
> 急ぎの問題や作業がブロックされている場合は、[Discord](https://discord.gg/jz8T2uahpH) から連絡するのが一番早いです。
---
自分のマシンでエージェントを並列実行。スマートフォンからでもデスクからでも、開発を進めてリリースできます。
- **セルフホスト:** エージェントはあなたのマシン上で動作し、完全な開発環境を使用します。自分のツール・設定・スキルをそのまま活用できます。
- **マルチプロバイダー:** Claude Code、Codex、Copilot、OpenCode、Pi を同一のインターフェースで利用。タスクに合ったモデルを選べます。
- **音声コントロール:** 音声モードでタスクを口述したり問題を話し合ったりできます。ハンズフリーが必要なときに便利です。
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
## はじめかた
Paseo はコーディングエージェントを管理するローカルサーバーデーモンを起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
### 前提条件
エージェント CLI をひとつ以上インストールし、認証情報を設定しておく必要があります。
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### デスクトップアプリ(推奨)
[paseo.sh/download](https://paseo.sh/download) または [GitHub のリリースページ](https://github.com/getpaseo/paseo/releases)からダウンロードしてください。アプリを開くとデーモンが自動的に起動します。追加のインストールは不要です。
スマートフォンから接続するには、Settings 画面に表示される QR コードをスキャンしてください。
### CLI / ヘッドレス
CLI をインストールして Paseo を起動します。
```bash
npm install -g @getpaseo/cli
paseo
```
ターミナルに QR コードが表示されます。どのクライアントからでも接続できます。サーバーやリモートマシンでの利用に適しています。
詳しいセットアップと設定については以下を参照してください。
- [ドキュメント](https://paseo.sh/docs)
- [設定リファレンス](https://paseo.sh/docs/configuration)
## CLI
アプリでできることはすべてターミナルからも実行できます。
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # 実行中のエージェントを一覧表示
paseo attach abc123 # ライブ出力をストリーミング
paseo send abc123 "also add tests" # 追加タスクを送信
# リモートデーモンで実行
paseo --host workstation.local:6767 run "run the full test suite"
```
詳細は[完全な CLI リファレンス](https://paseo.sh/docs/cli)を参照してください。
## スキル
スキルはエージェントに Paseo を使って他のエージェントをオーケストレーションする方法を教えます。
```bash
npx skills add getpaseo/paseo
```
どのエージェントとの会話でも使用できます。
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせますRalph loops とも呼ばれます)。検証役を追加することもできます。
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
## 開発
モノレポのパッケージ構成:
- `packages/server`: Paseo デーモンエージェントプロセスのオーケストレーション、WebSocket API、MCP サーバー)
- `packages/app`: Expo クライアントiOS、Android、Web
- `packages/cli`: デーモンおよびエージェントワークフロー向け `paseo` CLI
- `packages/desktop`: Electron デスクトップアプリ
- `packages/relay`: リモート接続用リレーパッケージ
- `packages/website`: マーケティングサイトとドキュメント(`paseo.sh`
よく使うコマンド:
```bash
# すべてのローカル開発サービスを起動
npm run dev
# 個別のサービスを起動
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# サーバースタックをビルド
npm run build:server
# リポジトリ全体のチェック
npm run typecheck
```
## コミュニティ
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## ライセンス
AGPL-3.0

View File

@@ -6,7 +6,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a>
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
</p>
<p align="center">

View File

@@ -6,7 +6,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-CN.md">简体中文</a>
<a href="README.zh-CN.md">简体中文</a> ·
<a href="README.ja.md">日本語</a>
</p>
<p align="center">

View File

@@ -48,8 +48,9 @@ The heart of Paseo. A Node.js process that:
- Listens for WebSocket connections from clients
- Manages agent lifecycle (create, run, stop, resume, archive)
- Streams agent output in real time via a timeline model
- Exposes an MCP server for agent-to-agent control
- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
- Optionally connects outbound to a relay for remote access
- Optionally serves the browser web client from the same HTTP server (self-hosting guide: [public-docs/web-ui.md](../public-docs/web-ui.md))
All paths are under `packages/server/src/`.
@@ -62,7 +63,8 @@ All paths are under `packages/server/src/`.
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
@@ -289,6 +291,8 @@ All providers:
- Map tool calls to a normalized `ToolCallDetail` type
- Expose provider-specific modes (plan, default, full-access)
Providers that can accept native tool definitions should set `supportsNativePaseoTools` and read `launchContext.paseoTools`. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`.
## Data flow: running an agent
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)

View File

@@ -438,7 +438,7 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
```json
{

View File

@@ -150,6 +150,7 @@ Single file, validated with `PersistedConfigSchema`.
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
@@ -163,7 +164,7 @@ Single file, validated with `PersistedConfigSchema`.
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { apiKey: string },
openai: { voice: { apiKey: string, baseUrl: string } },
local: { modelsDir: string }
},
agents: {
@@ -193,6 +194,37 @@ All fields are optional with sensible defaults.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
| Env var | Applies to |
| ------------------------------ | ------------------------------- |
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
OpenAI voice can be configured under `providers.openai`:
```json
{
"providers": {
"openai": {
"voice": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
---
## 3. Schedule

View File

@@ -236,6 +236,56 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
}
```
## Bundled daemon web UI
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it for a running daemon with:
```bash
paseo daemon start --web-ui
```
Or set the environment variable:
```bash
PASEO_WEB_UI_ENABLED=true paseo daemon start
```
Or persist it in `config.json`:
```json
{
"features": {
"webUi": {
"enabled": true
}
}
}
```
When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth.
The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step.
Build the artifact for packaging or measurement with:
```bash
npm run build:daemon-web-ui
```
This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`.
Measured bundle size for a standard Expo web export:
- raw: 10.77 MiB
- gzip: 2.55 MiB
- brotli: 1.93 MiB
The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app.
## Built workspace packages
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.

View File

@@ -8,10 +8,12 @@ Paseo client UI translations live in `packages/app/src/i18n`.
- `ar`
- `es`
- `fr`
- `ja`
- `pt-BR`
- `ru`
- `zh-CN`
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
## Translation Scope
@@ -33,7 +35,7 @@ Run:
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
```
The parity test catches missing keys between English and Simplified Chinese resources.
The parity test catches missing keys across English and every supported locale resource.
## Migration Order
@@ -77,3 +79,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.

View File

@@ -18,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
@@ -38,7 +40,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.

View File

@@ -27,6 +27,7 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Two paths

View File

@@ -67,6 +67,19 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
```json
{
"version": 1,
"daemon": {
"trustedProxies": ["loopback", "172.16.0.0/12"]
}
}
```
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
Nginx example:
```nginx
@@ -77,6 +90,7 @@ server {
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```

View File

@@ -1 +1 @@
sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=
sha256-7Xru5RdmXKX9U5tv4jn1wXChw3kfBuSnb2oRA+7HR7Q=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.100",
"version": "0.1.101",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.100",
"version": "0.1.101",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35243,7 +35243,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -35566,12 +35566,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/server": "0.1.100",
"@getpaseo/client": "0.1.101",
"@getpaseo/protocol": "0.1.101",
"@getpaseo/server": "0.1.101",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35817,10 +35817,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"@getpaseo/protocol": "0.1.101",
"@getpaseo/relay": "0.1.101",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -35831,7 +35831,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.100",
"version": "0.1.101",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36074,7 +36074,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.100",
"version": "0.1.101",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -36970,7 +36970,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37201,7 +37201,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37213,7 +37213,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37431,15 +37431,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.100",
"@getpaseo/highlight": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"@getpaseo/client": "0.1.101",
"@getpaseo/highlight": "0.1.101",
"@getpaseo/protocol": "0.1.101",
"@getpaseo/relay": "0.1.101",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -37848,7 +37848,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.100",
"version": "0.1.101",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,13 +1,12 @@
{
"name": "paseo",
"version": "0.1.100",
"version": "0.1.101",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
"development",
"mcp",
"openai",
"realtime",
"voice",
"voice-assistant"
],
@@ -56,6 +55,7 @@
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",

View File

@@ -403,10 +403,14 @@ async function waitForPairingOfferFromDaemon(args: {
);
}
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
@@ -440,7 +444,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise<void> {
}
}
async function resolveDictationConfig(): Promise<DictationConfig> {
async function logSpeechHarnessConfig(): Promise<void> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -451,23 +455,20 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
// Fork PRs run without secrets and usually without local models. Don't crash
// the whole Playwright run — disable dictation/voice and let tests that need
// them gate on PASEO_DICTATION_ENABLED.
// Default app E2E does not cover speech flows. Keep speech disabled here so
// unrelated tests never start background local-model downloads.
if (!openAiUsable && !hasDefaultLocalModelsDir) {
console.warn(
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
"[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " +
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
);
return { openAiUsable: false, localModelsDir: null };
return;
}
const dictationProvider = openAiUsable ? "openai" : "local";
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
`[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`,
);
return { openAiUsable, localModelsDir };
}
interface RelayStreamState {
@@ -656,39 +657,40 @@ interface DaemonSpawnArgs {
paseoHome: string;
fakeEditorBinDir: string;
editorRecordPath: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const env: NodeJS.ProcessEnv = {
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
// Default app E2E does not cover speech flows. Keep these disabled so
// unrelated tests never start background local-model downloads.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete env[key];
}
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_NODE_ENV: "development",
...(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",
},
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
@@ -720,6 +722,15 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
return child;
}
async function removeTempTree(targetPath: string): Promise<void> {
await rm(targetPath, {
recursive: true,
force: true,
maxRetries: 40,
retryDelay: 250,
});
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
@@ -731,13 +742,13 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
await removeTempTree(paseoHome);
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeEditorBinDir) {
await rm(fakeEditorBinDir, { recursive: true, force: true });
await removeTempTree(fakeEditorBinDir);
fakeEditorBinDir = null;
}
}
@@ -761,7 +772,7 @@ export default async function globalSetup() {
const cleanup = () => performCleanup(shouldRemovePaseoHome);
const dictation = await resolveDictationConfig();
await logSpeechHarnessConfig();
try {
const relayPort = await startRelay(new Set([port, metroPort]));
@@ -777,7 +788,6 @@ export default async function globalSetup() {
paseoHome,
fakeEditorBinDir,
editorRecordPath,
dictation,
buffer: daemonLineBuffer,
});

View File

@@ -3,11 +3,12 @@ import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { getE2EDaemonPort } from "./daemon-port";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
buildSessionsRoute,
} from "@/utils/host-routes";
export interface ArchiveTabAgent {
@@ -219,9 +220,7 @@ 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 expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});

View File

@@ -0,0 +1,17 @@
import { expect, type Page } from "@playwright/test";
export async function expectAppRoute(
page: Page,
expectedRoute: string,
options?: { timeout?: number },
): Promise<void> {
await expect
.poll(
() => {
const current = new URL(page.url());
return `${current.pathname}${current.search}`;
},
{ timeout: options?.timeout },
)
.toBe(expectedRoute);
}

View File

@@ -1,7 +1,13 @@
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import {
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
@@ -33,12 +39,12 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
if (section === "projects") {
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await expectAppRoute(page, buildProjectsSettingsRoute());
return;
}
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
@@ -55,9 +61,7 @@ export async function openSettingsHostSection(
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, section));
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -84,13 +88,13 @@ export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
export async function openCompactSettings(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
export async function openCompactSettings(page: Page, expectedStartRoute: string): Promise<void> {
await expectAppRoute(page, expectedStartRoute, { timeout: 15_000 });
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
@@ -149,7 +153,7 @@ export async function expectSettingsHostPickerLabel(page: Page, label: string):
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
@@ -189,7 +193,7 @@ export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
await page.getByTestId("host-page-remove-host-button").click();
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
await page.getByTestId("remove-host-confirm").click();
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
}
export async function expectSettingsBackButton(page: Page): Promise<void> {
@@ -201,9 +205,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
}
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
);
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "connections"));
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
@@ -249,6 +251,7 @@ export async function expectDirectHostUriHidden(page: Page): Promise<void> {
}
export async function expectDiagnosticsContent(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Run" })).toBeVisible();
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
}

View File

@@ -156,14 +156,15 @@ class StartupAssertions {
this.page = page;
}
async expectsSavedHostShell(input: { label: string }): Promise<this> {
async expectsSavedHostShell(input: { serverId: string; label: string }): Promise<this> {
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
await this.page.getByTestId("sidebar-hosts-trigger").click();
const hostRow = this.page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(hostRow).toBeVisible({ timeout: 15_000 });
await expect(hostRow).toContainText(input.label);
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
return this;
}

View File

@@ -1,7 +1,7 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
import { escapeRegex } from "./regex";
import { expectAppRoute } from "./route-assertions";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
@@ -59,9 +59,7 @@ export async function switchWorkspaceViaSidebar(input: {
await row.click();
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
timeout: 30_000,
});
await expectAppRoute(input.page, targetWorkspaceRoute, { timeout: 30_000 });
}
/**

View File

@@ -3,6 +3,9 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const TEMP_CLEANUP_RETRIES = 5;
const TEMP_CLEANUP_RETRY_DELAY_MS = 100;
interface TempRepo {
path: string;
branchHeads: Record<string, string>;
@@ -126,7 +129,12 @@ export const createTempGitRepo = async (
path: repoPath,
branchHeads,
cleanup: async () => {
await rm(repoPath, { recursive: true, force: true });
await rm(repoPath, {
recursive: true,
force: true,
maxRetries: TEMP_CLEANUP_RETRIES,
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
});
},
};
};
@@ -141,7 +149,12 @@ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<Te
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, { recursive: true, force: true });
await rm(dirPath, {
recursive: true,
force: true,
maxRetries: TEMP_CLEANUP_RETRIES,
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
});
},
};
}

View File

@@ -1,5 +1,10 @@
import { test, expect } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
buildHostWorkspaceRoute,
buildOpenProjectRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
@@ -36,6 +41,7 @@ import {
removeCurrentHostFromSettings,
} from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { expectAppRoute } from "./helpers/route-assertions";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -121,7 +127,7 @@ test.describe("Settings — compact master-detail", () => {
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
await expectCompactSettingsList(page);
@@ -133,10 +139,10 @@ test.describe("Settings — compact master-detail", () => {
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "diagnostics");
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
await expectDiagnosticsContent(page);
await expectSettingsSidebarHidden(page);
await expectSettingsBackButton(page);
@@ -144,10 +150,10 @@ test.describe("Settings — compact master-detail", () => {
test("back from a section detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "about");
await expect(page).toHaveURL(/\/settings\/about$/);
await expectAppRoute(page, buildSettingsSectionRoute("about"));
await goBackInSettings(page);
await expectCompactSettingsList(page);
@@ -158,7 +164,7 @@ test.describe("Settings — compact master-detail", () => {
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettingsHost(page);
await expectSettingsBackButton(page);
@@ -167,11 +173,11 @@ test.describe("Settings — compact master-detail", () => {
test("back from a host detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettingsHost(page);
await goBackInSettings(page);
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expectSettingsSidebarVisible(page);
});
@@ -188,11 +194,11 @@ test.describe("Settings — compact master-detail", () => {
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
]);
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await selectSettingsHost(page, secondaryServerId);
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expectSettingsSidebarVisible(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
@@ -206,7 +212,7 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
await openWorkspace(page, workspace);
await openCompactSettings(page);
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await openSettingsHostSection(page, getServerId(), "host");
await removeCurrentHostFromSettings(page);
await closeCompactSettings(page);

View File

@@ -14,7 +14,7 @@ test.describe("Startup loading presentation", () => {
})
.openRoot();
await startup.expectsSavedHostShell({ label: "Dev" });
await startup.expectsSavedHostShell({ serverId: "srv_unreachable_mobile", label: "Dev" });
await startup.expectsNoSavedHostErrorStatus();
await startup.expectsNoLocalServerStartupCopy();
});

View File

@@ -33,6 +33,7 @@ import {
import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -105,12 +106,8 @@ async function expectWorkspaceLocation(
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
},
): Promise<void> {
await expect(page).toHaveURL(
buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId),
{
timeout: 30_000,
},
);
const workspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId);
await expectAppRoute(page, workspaceRoute, { timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: input.workspace.workspaceName,
subtitle: input.workspace.projectDisplayName,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.100",
"version": "0.1.101",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -34,6 +34,7 @@ import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
@@ -79,6 +80,7 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHosts,
@@ -88,11 +90,10 @@ import { applyAppearance } from "@/screens/settings/appearance/apply-appearance"
import { usePanelStore } from "@/stores/panel-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostRootRoute,
buildOpenProjectRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
@@ -427,12 +428,8 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname();
const activeServerId = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
[daemons, pathname],
);
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState();
@@ -494,12 +491,10 @@ function AppContainer({
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenter />
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
@@ -731,7 +726,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId));
router.replace(buildOpenProjectRoute());
return;
})
.catch((error) => {
@@ -761,48 +756,87 @@ interface OpenProjectEventPayload {
path?: unknown;
}
function OpenProjectListener() {
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
interface PendingOpenProjectRequest {
id: number;
serverId: string;
path: string;
}
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
let nextOpenProjectRequestId = 1;
function OpenProjectListener() {
const chooseHost = useHostChooser();
const hostRegistryLoaded = useHostRegistryLoaded();
const [request, setRequest] = useState<PendingOpenProjectRequest | null>(null);
const [pendingPath, setPendingPath] = useState<string | null>(null);
const openProject = useOpenProject(request?.serverId ?? null);
const openPathOnChosenHost = useCallback(
(path: string) => {
const nextPath = path.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
if (!hostRegistryLoaded) {
setPendingPath(nextPath);
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
setRequest({
id: nextOpenProjectRequestId++,
serverId,
path: nextPath,
});
},
});
},
[chooseHost, hostRegistryLoaded],
);
useEffect(() => {
if (!hostRegistryLoaded || !pendingPath) {
return;
}
const nextPath = pendingPath;
setPendingPath(null);
openPathOnChosenHost(nextPath);
}, [hostRegistryLoaded, openPathOnChosenHost, pendingPath]);
useEffect(() => {
if (!request) {
return;
}
let cancelled = false;
void openProject(request.path).then((result) => {
if (cancelled) {
return null;
}
void openProject(pathToOpen).catch(() => undefined);
if (!result.ok) {
setRequest((current) => (current?.id === request.id ? null : current));
return null;
}
setRequest((current) => (current?.id === request.id ? null : current));
return null;
});
return () => {
cancelled = true;
};
}, [openProject, request]);
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
openPathOnChosenHost(pending);
}
return;
})
@@ -814,7 +848,7 @@ function OpenProjectListener() {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
maybeOpenProject(nextPath);
openPathOnChosenHost(nextPath);
})
.then((dispose) => {
if (disposed) {
@@ -826,13 +860,11 @@ function OpenProjectListener() {
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [client, openProject, serverId]);
}, [openPathOnChosenHost]);
return null;
}
@@ -842,9 +874,11 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
const storeReady = useStoreReady();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const routeHasKnownHost =
routeServerId !== null && hosts.some((host) => host.serverId === routeServerId);
const shouldShowAppChrome =
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
storeReady && (pathname === "/open-project" || pathname === "/sessions" || routeHasKnownHost);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
@@ -899,6 +933,8 @@ function RootStack() {
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />

View File

@@ -1,39 +1,10 @@
import { Redirect } from "expo-router";
import { useHostRouteServerId } from "@/navigation/host-route-context";
import {
resolveHostIndexRoute,
resolveWorkspaceSelectionStatus,
} from "@/navigation/host-runtime-bootstrap";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export default function HostIndexRoute() {
const serverId = useHostRouteServerId();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionWorkspaceId =
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
if (!serverId || !isWorkspaceSelectionLoaded) {
return <StartupSplashScreen />;
}
return (
<Redirect
href={resolveHostIndexRoute({
serverId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces,
workspaceExists: workspaceSelectionExists,
}),
})}
/>
);
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
if (!serverId) return null;
// COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildOpenProjectRoute()} />;
}

View File

@@ -1,18 +1,12 @@
import { useLocalSearchParams } from "expo-router";
import { Redirect } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export default function HostOpenProjectRoute() {
// COMPAT(hostOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
return (
<HostRouteBootstrapBoundary>
<HostOpenProjectRouteContent />
<Redirect href={buildOpenProjectRoute()} />
</HostRouteBootstrapBoundary>
);
}
function HostOpenProjectRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <OpenProjectScreen serverId={serverId} />;
}

View File

@@ -1,18 +1,7 @@
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
import { Redirect } from "expo-router";
import { buildSessionsRoute } from "@/utils/host-routes";
export default function HostAgentsRoute() {
return (
<HostRouteBootstrapBoundary>
<HostAgentsRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentsRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <SessionsScreen serverId={serverId} />;
export default function HostSessionsRoute() {
// COMPAT(hostSessionsRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildSessionsRoute()} />;
}

View File

@@ -24,6 +24,10 @@ import {
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
@@ -63,7 +67,7 @@ function stripOpenSearchParamFromBrowserUrl() {
return;
}
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
replaceBrowserRouteWithCanonicalHostWorkspaceRoute(`${url.pathname}${url.search}${url.hash}`);
}
function clearConsumedOpenIntent(input: {
@@ -102,6 +106,13 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
}
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useEffect(() => {
if (!openValue) {
return;

View File

@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function OpenProjectRoute() {
return (
<HostRouteBootstrapBoundary>
<OpenProjectScreen />
</HostRouteBootstrapBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function SessionsRoute() {
return (
<HostRouteBootstrapBoundary>
<SessionsScreen />
</HostRouteBootstrapBoundary>
);
}

View File

@@ -4,10 +4,11 @@ import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string }>();
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
return <SettingsScreen view={view} />;
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
}

View File

@@ -33,6 +33,7 @@ interface AgentListProps {
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
showHostColumn?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
@@ -152,11 +153,62 @@ function WorkspaceTitlePrefix({
);
}
function SessionRowBadges({
agent,
archivedIcon,
pendingPermissionCount,
showDesktopAttention,
}: {
agent: AggregatedAgent;
archivedIcon: ReactElement;
pendingPermissionCount: number;
showDesktopAttention: boolean;
}) {
const { t } = useTranslation();
return (
<>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{showDesktopAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</>
);
}
function SessionRowTrailingAttention({
isMobile,
showAttentionIndicator,
requiresAttention,
}: {
isMobile: boolean;
showAttentionIndicator: boolean;
requiresAttention: boolean | undefined;
}) {
const { t } = useTranslation();
if (!isMobile || !showAttentionIndicator || !requiresAttention) {
return null;
}
return (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
);
}
function SessionRow({
agent,
isMobile,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
onPress,
onLongPress,
}: {
@@ -164,6 +216,7 @@ function SessionRow({
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
showHostColumn: boolean;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}) {
@@ -200,6 +253,8 @@ function SessionRow({
() => <Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />,
[theme.fontSize.xs, theme.colors.foregroundMuted],
);
const showDesktopAttention =
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
return (
<Pressable
@@ -223,20 +278,14 @@ function SessionRow({
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || t("agentList.fallbackTitle")}
</Text>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
pendingPermissionCount={pendingPermissionCount}
showDesktopAttention={showDesktopAttention}
/>
</View>
{isMobile && (
{isMobile ? (
<View style={styles.rowMetaRow}>
<Text
style={styles.sessionMetaText}
@@ -263,10 +312,18 @@ function SessionRow({
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
)}
) : null}
</View>
{!isMobile && (
{!isMobile ? (
<View style={styles.rowColumns}>
<Text
style={styles.columnMeta}
@@ -275,6 +332,11 @@ function SessionRow({
>
{projectName}
</Text>
{showHostColumn ? (
<Text style={styles.columnMetaHost} numberOfLines={1}>
{agent.serverLabel}
</Text>
) : null}
<Text
style={styles.columnMeta}
numberOfLines={1}
@@ -286,12 +348,12 @@ function SessionRow({
{timeAgo}
</Text>
</View>
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
) : null}
<SessionRowTrailingAttention
isMobile={isMobile}
showAttentionIndicator={showAttentionIndicator}
requiresAttention={agent.requiresAttention}
/>
</Pressable>
);
}
@@ -304,6 +366,7 @@ export function AgentList({
onAgentSelect,
listFooterComponent,
showAttentionIndicator = true,
showHostColumn = false,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -428,12 +491,21 @@ export function AgentList({
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
showHostColumn={showHostColumn}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
[
handleAgentLongPress,
handleAgentPress,
isMobile,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
t,
],
);
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
@@ -640,6 +712,14 @@ const styles = StyleSheet.create((theme) => ({
width: 72,
textAlign: "right" as const,
},
columnMetaHost: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 120,
marginLeft: theme.spacing[4],
textAlign: "right" as const,
},
badge: {
flexDirection: "row",
alignItems: "center",

View File

@@ -0,0 +1,412 @@
import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Platform, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Copy, RotateCw } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import {
formatAppDiagnosticHeader,
formatDiagnosticSection,
formatHostRuntimeSection,
formatServerInfoSection,
redactAppDiagnosticReport,
} from "@/diagnostics/app-diagnostic-report";
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
interface AppDiagnosticSheetProps {
visible: boolean;
onClose: () => void;
appVersion: string | null;
isDesktopApp: boolean;
}
type ProgressStatus = "pending" | "running" | "done" | "failed";
interface ProgressRow {
id: string;
label: string;
status: ProgressStatus;
}
interface DiagnosticCollectionResult {
sections: string[];
status: ProgressStatus;
}
const SNAP_POINTS = ["55%", "88%"];
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function AppDiagnosticSheet({
visible,
onClose,
appVersion,
isDesktopApp,
}: AppDiagnosticSheetProps) {
const { t } = useTranslation();
const toast = useToast();
const hosts = useHosts();
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState<ProgressRow[]>([]);
const runIdRef = useRef(0);
const updateProgress = useCallback((id: string, label: string, status: ProgressStatus) => {
setProgress((current) => {
const existing = current.find((row) => row.id === id);
if (existing) {
return current.map((row) => (row.id === id ? { ...row, label, status } : row));
}
return [...current, { id, label, status }];
});
}, []);
const runDiagnostics = useCallback(async () => {
const runId = runIdRef.current + 1;
runIdRef.current = runId;
const isCurrentRun = () => runIdRef.current === runId;
const updateRunProgress = (id: string, label: string, status: ProgressStatus) => {
if (isCurrentRun()) {
updateProgress(id, label, status);
}
};
setLoading(true);
setDiagnostic(null);
setProgress([]);
const sections: string[] = [];
try {
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "running");
sections.push(
formatAppDiagnosticHeader({
appVersion,
platform: Platform.OS,
isDesktopApp,
hostCount: hosts.length,
}),
);
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "done");
if (isDesktopApp) {
const desktopLabel = t("settings.diagnostics.app.progress.desktop");
updateRunProgress("desktop", desktopLabel, "running");
const desktopResult = await collectDesktopDiagnosticSections();
sections.push(...desktopResult.sections);
updateRunProgress("desktop", desktopLabel, desktopResult.status);
}
const store = getHostRuntimeStore();
for (const host of hosts) {
const hostProgressId = `host:${host.serverId}`;
updateRunProgress(hostProgressId, host.label, "running");
const snapshot = store.getSnapshot(host.serverId);
const hostResult = await collectHostDiagnosticSections(host, snapshot);
sections.push(...hostResult.sections);
updateRunProgress(hostProgressId, host.label, hostResult.status);
}
if (isCurrentRun()) {
setDiagnostic(redactAppDiagnosticReport(sections.join("\n\n"), hosts));
}
} finally {
if (isCurrentRun()) {
setLoading(false);
}
}
}, [appVersion, hosts, isDesktopApp, t, updateProgress]);
useEffect(() => {
if (visible) {
void runDiagnostics();
} else {
runIdRef.current += 1;
setLoading(false);
setDiagnostic(null);
setProgress([]);
}
}, [visible, runDiagnostics]);
const handleRefreshPress = useCallback(() => {
void runDiagnostics();
}, [runDiagnostics]);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
.then(() => toast.copied(t("settings.diagnostics.app.copyLabel")))
.catch(() => toast.error(t("settings.diagnostics.app.copyFailed")));
}, [diagnostic, t, toast]);
const iconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && styles.iconButtonHovered,
],
[],
);
const disabledIconButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && styles.iconButtonHovered,
diagnostic ? null : styles.disabled,
],
[diagnostic],
);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.diagnostics.app.title"),
actions: (
<View style={styles.headerActions}>
<Pressable
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={disabledIconButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.diagnostics.app.copyAccessibility")}
>
<ThemedCopy size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.diagnostics.app.refreshingAccessibility")
: t("settings.diagnostics.app.refreshAccessibility")
}
>
{loading ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</Pressable>
</View>
),
}),
[
diagnostic,
disabledIconButtonStyle,
handleCopyPress,
handleRefreshPress,
iconButtonStyle,
loading,
t,
],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}
scrollable={false}
testID="app-diagnostic-sheet"
>
{diagnostic ? (
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={520}>
{diagnostic}
</ScrollableCodeSurface>
) : (
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={styles.progressContent}>
{progress.length === 0 ? (
<View style={styles.progressRow}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
<Text style={styles.mutedText}>{t("settings.diagnostics.app.running")}</Text>
</View>
) : (
progress.map((row) => (
<View key={row.id} style={styles.progressRow}>
{row.status === "running" || row.status === "pending" ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedColorMapping}
/>
) : (
<View
style={row.status === "failed" ? styles.statusDotFailed : styles.statusDot}
/>
)}
<Text
style={styles.mutedText}
>{`${row.label}: ${formatProgressStatus(row.status)}`}</Text>
</View>
))
)}
</View>
</SurfaceCard>
)}
</AdaptiveModalSheet>
);
}
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
try {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return {
status: "done",
sections: [
formatDiagnosticSection("Desktop", [
{ label: "Daemon status", value: status.status },
{ label: "Desktop managed", value: String(status.desktopManaged) },
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
{ label: "Daemon version", value: status.version ?? "unknown" },
{ label: "Daemon home", value: status.home || "unknown" },
{ label: "Log path", value: logs.logPath || "unknown" },
{ label: "Error", value: status.error ?? "none" },
]),
[
"Desktop daemon log tail",
logs.contents ? indentBlock(logs.contents) : " No log lines found",
].join("\n"),
],
};
} catch (error) {
return {
status: "failed",
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
};
}
}
async function collectHostDiagnosticSections(
host: HostProfile,
snapshot: HostRuntimeSnapshot | null,
): Promise<DiagnosticCollectionResult> {
const sections = [formatHostRuntimeSection({ host, snapshot })];
const client = snapshot?.client ?? null;
if (snapshot?.connectionStatus !== "online" || !client) {
sections.push(
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
{ label: "Status", value: "host is not connected" },
]),
);
return { sections, status: "done" };
}
try {
const serverInfo = client.getLastServerInfoMessage();
sections.push(formatServerInfoSection(serverInfo));
const rttMs = await client.measureLatency({ timeoutMs: 5000 });
sections.push(
formatDiagnosticSection(`Host latency: ${host.label}`, [
{ label: "Active RTT", value: `${Math.round(rttMs)}ms` },
]),
);
if (serverInfo?.features?.daemonDiagnostics === true) {
const result = await client.collectDiagnostics();
sections.push(result.diagnostic);
} else {
sections.push(
formatDiagnosticSection(`Daemon diagnostics: ${host.label}`, [
{ label: "Status", value: "unsupported by this daemon" },
]),
);
}
return { sections, status: "done" };
} catch (error) {
sections.push(
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
{ label: "Error", value: toMessage(error) },
]),
);
return { sections, status: "failed" };
}
}
function indentBlock(value: string): string {
return value
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n");
}
function toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function formatProgressStatus(status: ProgressStatus): string {
switch (status) {
case "done":
return "done";
case "failed":
return "failed";
case "running":
case "pending":
return "running";
}
}
const styles = StyleSheet.create((theme) => ({
progressContent: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[3],
},
progressRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 24,
},
mutedText: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
iconButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
},
statusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.success,
},
statusDotFailed: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.destructive,
},
}));

View File

@@ -0,0 +1,31 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type HostRuntimeConnectionStatus,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) {
if (status === "online") return theme.colors.palette.green[400];
if (status === "connecting") return theme.colors.palette.amber[500];
return theme.colors.palette.red[500];
}
export function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const status = useHostRuntimeConnectionStatus(serverId);
const backgroundColor = hostStatusDotColor(status, theme);
const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]);
return <View style={dotStyle} />;
}
const styles = StyleSheet.create(() => ({
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
}));

View File

@@ -0,0 +1,19 @@
export const ADD_HOST_OPTION_ID = "__add_host__";
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
export function getHostPickerLabel(
hosts: Array<{ label: string; serverId: string }>,
value: string,
config?: { includeAllHost?: boolean; includeAddHost?: boolean },
): string {
if (config?.includeAllHost && value === ALL_HOSTS_OPTION_ID) {
return "All hosts";
}
if (config?.includeAddHost && value === ADD_HOST_OPTION_ID) {
return "Add host";
}
return (
hosts.find((host) => host.serverId === value)?.label ??
(config?.includeAllHost ? "All hosts" : "Host")
);
}

View File

@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
const hosts = [{ serverId: "host-a", label: "Host A" }];
it.each([
["host-a", undefined, "Host A"],
["missing", undefined, "Host"],
[ALL_HOSTS_OPTION_ID, { includeAllHost: true }, "All hosts"],
["missing", { includeAllHost: true }, "All hosts"],
[ALL_HOSTS_OPTION_ID, undefined, "Host"],
[ADD_HOST_OPTION_ID, { includeAddHost: true }, "Add host"],
])("resolves %s", (value, config, expected) => {
expect(getHostPickerLabel(hosts, value, config)).toBe(expected);
});

View File

@@ -0,0 +1,302 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Plus, Server, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDot } from "@/components/host-status-dot";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { orderHostsLocalFirst } from "@/types/host-connection";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
interface HostPickerHost {
serverId: string;
label: string;
}
export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElement {
return (
<View style={styles.statusDotSlot}>
<HostStatusDot serverId={serverId} />
</View>
);
}
export interface HostPickerOptionProps {
serverId: string;
label: string;
isLocal: boolean;
selected?: boolean;
active: boolean;
onPress: () => void;
onOpenHostSettings?: (serverId: string) => void;
localMarkerTestID?: string;
testID?: string;
}
export function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
onOpenHostSettings,
localMarkerTestID,
testID,
}: HostPickerOptionProps): ReactElement {
const { theme } = useUnistyles();
const leadingSlot = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
const handleSettingsPress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onOpenHostSettings?.(serverId);
},
[onOpenHostSettings, serverId],
);
const trailingSlot = useMemo(() => {
if (!isLocal && !onOpenHostSettings) return undefined;
return (
<>
{isLocal ? (
<Text style={styles.localMarker} testID={localMarkerTestID}>
Local
</Text>
) : null}
{onOpenHostSettings ? (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</>
);
}, [
handleSettingsPress,
isLocal,
label,
localMarkerTestID,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
);
}
function SystemHostPickerOption({
active,
selected,
onPress,
kind,
testID,
}: {
active: boolean;
selected?: boolean;
onPress: () => void;
kind: "add" | "all";
testID?: string;
}): ReactElement {
const { theme } = useUnistyles();
const Icon = kind === "add" ? Plus : Server;
const label = kind === "add" ? "Add host" : "All hosts";
const leadingSlot = useMemo(
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
);
}
export interface HostPickerProps {
hosts: HostPickerHost[];
value: string;
onSelect: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
anchorRef: React.RefObject<View | null>;
includeAllHost?: boolean;
includeAddHost?: boolean;
onAddHost?: () => void;
showLocalMarker?: boolean;
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
title?: string;
desktopPlacement?: "top-start" | "bottom-start";
desktopMinWidth?: number;
addHostTestID?: string;
hostOptionTestID?: (serverId: string) => string;
hostLocalMarkerTestID?: (serverId: string) => string;
children: ReactNode;
}
export function HostPicker({
hosts,
value,
onSelect,
open,
onOpenChange,
anchorRef,
includeAllHost,
includeAddHost,
onAddHost,
showLocalMarker,
onOpenHostSettings,
searchable,
title,
desktopPlacement = "top-start",
desktopMinWidth,
addHostTestID,
hostOptionTestID,
hostLocalMarkerTestID,
children,
}: HostPickerProps): ReactElement {
const localServerId = useLocalDaemonServerId();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
const options = useMemo(() => {
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
return hostOptions;
}, [orderedHosts, includeAllHost, includeAddHost]);
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost?.();
} else {
onSelect(id);
}
onOpenChange(false);
},
[onAddHost, onOpenChange, onSelect],
);
const handleOpenHostSettings = useCallback(
(serverId: string) => {
onOpenHostSettings?.(serverId);
onOpenChange(false);
},
[onOpenHostSettings, onOpenChange],
);
const renderOption = useCallback<RenderHostOption>(
({ option, selected, active, onPress }) => {
if (option.id === ADD_HOST_OPTION_ID) {
return (
<SystemHostPickerOption
kind="add"
active={active}
onPress={onPress}
testID={addHostTestID}
/>
);
}
if (option.id === ALL_HOSTS_OPTION_ID) {
return (
<SystemHostPickerOption
kind="all"
active={active}
selected={selected}
onPress={onPress}
/>
);
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={showLocalMarker === true && localServerId === option.id}
selected={selected}
active={active}
onPress={onPress}
onOpenHostSettings={onOpenHostSettings ? handleOpenHostSettings : undefined}
localMarkerTestID={hostLocalMarkerTestID?.(option.id)}
testID={hostOptionTestID?.(option.id)}
/>
);
},
[
addHostTestID,
hostLocalMarkerTestID,
hostOptionTestID,
localServerId,
onOpenHostSettings,
showLocalMarker,
handleOpenHostSettings,
],
);
return (
<>
{children}
<Combobox
options={options}
value={value}
onSelect={handleSelect}
renderOption={renderOption}
searchable={isSearchable}
searchPlaceholder="Search hosts"
title={title ?? "Host"}
open={open}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
/>
</>
);
}
const styles = StyleSheet.create((theme) => ({
statusDotSlot: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
alignItems: "center",
justifyContent: "center",
},
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
}));

View File

@@ -1,18 +1,7 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, History, Home, Plus, Search, Settings, X } from "lucide-react-native";
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
type Dispatch,
memo,
type ReactElement,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
@@ -20,8 +9,6 @@ import {
useWindowDimensions,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -34,40 +21,42 @@ import Animated, {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useHostChooser } from "@/hosts/host-chooser";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
useSidebarWorkspacesList,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
selectIsAgentListOpen,
usePanelStore,
} from "@/stores/panel-store";
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostOpenProjectRoute,
buildOpenProjectRoute,
buildHostNewWorkspaceRoute,
buildHostSessionsRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
mapPathnameToServer,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -85,14 +74,9 @@ interface LeftSidebarProps {
interface SidebarSharedProps {
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
@@ -101,19 +85,14 @@ interface SidebarSharedProps {
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleNewWorkspaceNavigate: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: SidebarLabels;
renderHostOption: (input: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
newWorkspaceKeys: ShortcutKey[][] | null;
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}
interface SidebarLabels {
@@ -154,70 +133,24 @@ export const LeftSidebar = memo(function LeftSidebar({
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname();
const daemons = useHosts();
const activeDaemon = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname }),
[daemons, pathname],
);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return t("sidebar.host.noHost");
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon, t]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId
? (activeHostSnapshot?.connectionStatus ?? "connecting")
: "idle";
let activeHostStatusColor: string;
if (activeHostStatus === "online") activeHostStatusColor = theme.colors.palette.green[400];
else if (activeHostStatus === "connecting")
activeHostStatusColor = theme.colors.palette.amber[500];
else activeHostStatusColor = theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
})),
[daemons],
);
const renderHostOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<HostSwitchOption
serverId={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
/>
),
[],
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
const {
workspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarWorkspacesList({
enabled: isCompactLayout || isOpen,
});
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects });
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
);
const groupMode = useSidebarViewStore((state) => state.groupMode);
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -232,7 +165,8 @@ export const LeftSidebar = memo(function LeftSidebar({
}
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker(activeServerId);
const openProjectPicker = useOpenProjectPicker();
const chooseHost = useHostChooser();
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
@@ -244,9 +178,13 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
if (!activeServerId) return;
router.navigate(buildHostNewWorkspaceRoute(activeServerId));
}, [activeServerId]);
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
router.push(buildHostNewWorkspaceRoute(serverId));
},
});
}, [chooseHost]);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
@@ -257,38 +195,41 @@ export const LeftSidebar = memo(function LeftSidebar({
router.push(buildSettingsRoute());
}, []);
const handleHomeMobile = useCallback(() => {
if (!activeServerId) return;
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId, showMobileAgent]);
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
if (!activeServerId) return;
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSessionsRoute(activeServerId));
}, [activeServerId]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath);
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
showMobileAgent();
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
},
[pathname],
[showMobileAgent],
);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
}, []);
const handleHomeMobile = useCallback(() => {
showMobileAgent();
router.push(buildOpenProjectRoute());
}, [showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
router.push(buildOpenProjectRoute());
}, []);
const handleViewMoreNavigate = useCallback(() => {
router.push(buildSessionsRoute());
}, []);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
@@ -305,14 +246,9 @@ export const LeftSidebar = memo(function LeftSidebar({
const sharedProps = {
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
statusWorkspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -321,8 +257,6 @@ export const LeftSidebar = memo(function LeftSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
labels,
newWorkspaceKeys,
};
@@ -339,6 +273,8 @@ export const LeftSidebar = memo(function LeftSidebar({
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
);
@@ -353,91 +289,41 @@ export const LeftSidebar = memo(function LeftSidebar({
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
);
});
interface HostPickerTriggerProps {
triggerRef: React.Ref<View>;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
hostOptionsEmpty: boolean;
hostStatusDotStyle: StyleProp<ViewStyle>;
activeHostLabel: string;
function sidebarHostOptionTestID(serverId: string): string {
return `sidebar-host-row-${serverId}`;
}
function HostPickerTrigger({
triggerRef,
setIsHostPickerOpen,
hostOptionsEmpty,
hostStatusDotStyle,
activeHostLabel,
}: HostPickerTriggerProps) {
const pressableStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
],
[],
);
const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]);
return (
<Pressable
ref={triggerRef}
style={pressableStyle}
onPress={handlePress}
disabled={hostOptionsEmpty}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
);
}
function HostSwitchOption({
serverId,
label,
selected,
active,
onPress,
}: {
serverId: string;
label: string;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const snapshot = useHostRuntimeSnapshot(serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
return (
<ComboboxItem
label={label}
description={formatConnectionStatus(connectionStatus)}
selected={selected}
active={active}
onPress={onPress}
/>
);
function sidebarHostLocalMarkerTestID(serverId: string): string {
return `sidebar-host-local-marker-${serverId}`;
}
function FooterIconButton({
buttonRef,
onPress,
testID,
accessibilityLabel,
icon: Icon,
iconSize,
theme,
}: {
onPress: () => void;
testID: string;
accessibilityLabel: string;
icon: typeof FolderPlus;
iconSize?: number;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
return (
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
@@ -449,7 +335,7 @@ function FooterIconButton({
>
{({ hovered }) => (
<Icon
size={theme.iconSize.md}
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -457,6 +343,59 @@ function FooterIconButton({
);
}
function SidebarHostPicker({
theme,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}) {
const hosts = useHosts();
const triggerRef = useRef<View | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
onOpenHostSettings(id);
},
[onOpenHostSettings],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
return (
<HostPicker
hosts={hosts}
value=""
onSelect={handleSelect}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
onOpenHostSettings={onOpenHostSettings}
searchable
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
hostLocalMarkerTestID={sidebarHostLocalMarkerTestID}
>
<FooterIconButton
buttonRef={triggerRef}
onPress={handleOpen}
testID="sidebar-hosts-trigger"
accessibilityLabel="Hosts"
icon={Server}
iconSize={theme.iconSize.sm}
theme={theme}
/>
</HostPicker>
);
}
function AddProjectTooltipContent({
newAgentKeys,
label,
@@ -489,30 +428,14 @@ function HeaderIconTooltipContent({
function SidebarFooter({
theme,
activeServerId,
activeHostLabel,
hostStatusDotStyle,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
}: {
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
hostStatusDotStyle: StyleProp<ViewStyle>;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
handleHostSelect: (nextServerId: string) => void;
renderHostOption: SidebarSharedProps["renderHostOption"];
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
@@ -523,20 +446,19 @@ function SidebarFooter({
switchHost: string;
searchHosts: string;
};
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
return (
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<HostPickerTrigger
triggerRef={hostTriggerRef}
setIsHostPickerOpen={setIsHostPickerOpen}
hostOptionsEmpty={hostOptions.length === 0}
hostStatusDotStyle={hostStatusDotStyle}
activeHostLabel={activeHostLabel}
/>
</View>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<FooterIconButton
@@ -566,33 +488,15 @@ function SidebarFooter({
theme={theme}
/>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title={labels.switchHost}
searchPlaceholder={labels.searchHosts}
desktopMinWidth={280}
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
);
}
function MobileSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
statusWorkspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -601,14 +505,14 @@ function MobileSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
insetsBottom,
isOpen,
@@ -638,21 +542,11 @@ function MobileSidebar({
}, [closeSidebar, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return;
}
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
closeSidebar,
handleViewMoreNavigate,
translateX,
windowWidth,
]);
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
@@ -752,11 +646,6 @@ function MobileSidebar({
[windowWidth, insetsTop, insetsBottom],
);
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
@@ -826,7 +715,7 @@ function MobileSidebar({
variant="compact"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -851,12 +740,13 @@ function MobileSidebar({
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
@@ -867,19 +757,12 @@ function MobileSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</Animated.View>
@@ -890,14 +773,9 @@ function MobileSidebar({
function DesktopSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
statusWorkspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -906,14 +784,14 @@ function DesktopSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
isOpen,
handleViewMore,
@@ -924,10 +802,6 @@ function DesktopSidebar({
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor],
);
const startWidthRef = useRef(sidebarWidth);
const resizeWidth = useSharedValue(sidebarWidth);
@@ -1007,18 +881,19 @@ function DesktopSidebar({
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
@@ -1029,19 +904,12 @@ function DesktopSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
{/* Resize handle - absolutely positioned over right border */}
@@ -1053,7 +921,7 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
function WorkspacesSectionHeader() {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
@@ -1096,7 +964,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu serverId={serverId} />
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
@@ -1208,45 +1076,15 @@ const styles = StyleSheet.create((theme) => ({
sidebarDragArea: {
position: "relative",
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
hostTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostTriggerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
justifyContent: "flex-start",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
footerHostSlot: {
flexGrow: 0,
flexShrink: 1,
minWidth: 0,
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
@@ -1261,34 +1099,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
hostPickerList: {
gap: theme.spacing[2],
},
hostPickerOption: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
hostPickerOptionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostPickerCancel: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -361,8 +361,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
overflowWrap: "anywhere",
...(isWeb ? { lineHeight: 22, overflowWrap: "anywhere" as const } : {}),
},
imagePreviewContainer: {
flexDirection: "row",

View File

@@ -8,57 +8,29 @@ import {
View,
type PressableStateCallbackType,
} from "react-native";
import { Folder, FolderPlus } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { getOpenProjectFailureReason, type OpenProjectFailureReason } from "@/hooks/open-project";
import { useOpenProject } from "@/hooks/use-open-project";
import type { OpenProjectFailure, OpenProjectResult } from "@/hooks/open-project";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProjectPickerStore } from "@/stores/project-picker-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { shortenPath } from "@/utils/shorten-path";
import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
buildProjectPickerOptions,
isOpenableProjectPath,
type ProjectPickerOption,
} from "./project-picker-options";
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
interface PathRowProps {
option: ProjectPickerOption;
active: boolean;
openPathLabel: string;
onSelect: (path: string) => void;
}
type ProjectPickerErrorCode = NonNullable<OpenProjectFailure["errorCode"]>;
function getProjectPickerErrorMessage(
result: OpenProjectResult | undefined,
thrownError: Error | null,
translateErrorCode: (errorCode: ProjectPickerErrorCode) => string,
translateOpenFailed: () => string,
): string | null {
if (result?.ok === false) {
if (result.errorCode) {
return translateErrorCode(result.errorCode);
}
return result.error;
}
if (thrownError) {
return thrownError.message || translateOpenFailed();
}
return null;
}
function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
function PathRow({ option, active, onSelect }: PathRowProps) {
const { theme } = useUnistyles();
const Icon = option.kind === "path" ? FolderPlus : Folder;
const { t } = useTranslation();
const path = option.path;
const displayPath = shortenPath(path);
const label = option.kind === "path" ? `${openPathLabel}: ${displayPath}` : displayPath;
const handlePress = useCallback(() => {
onSelect(path);
}, [onSelect, path]);
@@ -75,15 +47,22 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
() => [styles.rowText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const rowActionTextStyle = useMemo(
() => [styles.rowActionText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<Pressable style={pressableStyle} onPress={handlePress}>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Icon size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
</View>
<Text style={rowTextStyle} numberOfLines={1}>
{label}
{shortenPath(path)}
</Text>
{option.kind === "path" ? (
<Text style={rowActionTextStyle}>{t("projectPicker.openPath")}</Text>
) : null}
</View>
</Pressable>
);
@@ -92,10 +71,10 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const request = useProjectPickerStore((state) => state.request);
const close = useProjectPickerStore((state) => state.close);
const serverId = request?.serverId ?? null;
const open = request !== null;
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
@@ -104,25 +83,10 @@ export function ProjectPickerModal() {
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [openErrorReason, setOpenErrorReason] = useState<OpenProjectFailureReason | null>(null);
const openProject = useOpenProject(serverId);
const openProjectMutation = useMutation({
mutationFn: (path: string) => openProject(path),
onSuccess: (result) => {
if (result.ok) {
setOpen(false);
}
},
});
const { mutate: submitPath, reset: resetSubmit, isPending: isSubmitting } = openProjectMutation;
const submitResult = openProjectMutation.data;
const errorMessage = getProjectPickerErrorMessage(
submitResult,
openProjectMutation.error,
(errorCode) => t(`projectPicker.errors.${errorCode}`),
() => t("projectPicker.errors.open_failed"),
);
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, query],
queryFn: async () => {
@@ -142,56 +106,72 @@ export function ProjectPickerModal() {
retry: false,
});
const options = useMemo(() => {
return buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
});
}, [query, directorySuggestionsQuery.data, recommendedPaths]);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const handleSelectPath = useCallback(
(path: string) => {
const trimmed = path.trim();
if (!trimmed || !client || !serverId) return;
submitPath(trimmed);
},
[client, serverId, submitPath],
const options = useMemo(
() =>
buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[directorySuggestionsQuery.data, query, recommendedPaths],
);
const handleSubmitCustom = useCallback(() => {
const trimmed = query.trim();
if (!isOpenableProjectPath(trimmed)) return;
handleSelectPath(trimmed);
}, [handleSelectPath, query]);
const handleChangeQuery = useCallback(
(text: string) => {
setQuery(text);
setActiveIndex(0);
resetSubmit();
},
[resetSubmit],
);
// Reset state when opening/closing
useEffect(() => {
resetSubmit();
if (!open) {
return;
const openErrorMessage = useMemo(() => {
if (!openErrorReason) {
return null;
}
setQuery("");
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [open, resetSubmit]);
return t(`projectPicker.errors.${openErrorReason}`);
}, [openErrorReason, t]);
const handleClose = useCallback(() => {
close();
}, [close]);
const handleSelectPath = useCallback(
async (path: string) => {
const trimmed = path.trim();
if (!trimmed || !client || !serverId) return;
setOpenErrorReason(null);
setIsSubmitting(true);
try {
const result = await openProject(trimmed);
if (result.ok) {
close();
return;
}
setOpenErrorReason(getOpenProjectFailureReason(result));
} finally {
setIsSubmitting(false);
}
},
[client, close, openProject, serverId],
);
const submitActiveOption = useCallback(() => {
const option = options[activeIndex];
if (!option) return;
void handleSelectPath(option.path);
}, [activeIndex, handleSelectPath, options]);
const handleChangeQuery = useCallback((text: string) => {
setQuery(text);
setActiveIndex(0);
setOpenErrorReason(null);
}, []);
useEffect(() => {
if (open) {
setQuery("");
setActiveIndex(0);
setOpenErrorReason(null);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}
}, [open]);
// Clamp active index
useEffect(() => {
if (!open) return;
if (activeIndex >= options.length) {
@@ -199,7 +179,6 @@ export function ProjectPickerModal() {
}
}, [activeIndex, options.length, open]);
// Keyboard navigation
useEffect(() => {
if (!open || isNative) return;
@@ -209,17 +188,13 @@ export function ProjectPickerModal() {
if (key === "Escape") {
event.preventDefault();
handleClose();
close();
return;
}
if (key === "Enter") {
event.preventDefault();
if (options.length > 0 && activeIndex < options.length) {
handleSelectPath(options[activeIndex].path);
} else if (query.trim()) {
handleSubmitCustom();
}
submitActiveOption();
return;
}
@@ -238,7 +213,7 @@ export function ProjectPickerModal() {
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelectPath, handleSubmitCustom, open, options, query]);
}, [close, open, options.length, submitActiveOption]);
const panelStyle = useMemo(
() => [
@@ -258,14 +233,14 @@ export function ProjectPickerModal() {
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const errorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const emptyTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const errorTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
if (!serverId) return null;
@@ -288,9 +263,8 @@ export function ProjectPickerModal() {
autoFocus
editable={!isSubmitting}
returnKeyType="go"
onSubmitEditing={handleSubmitCustom}
onSubmitEditing={submitActiveOption}
/>
{errorMessage ? <Text style={errorTextStyle}>{errorMessage}</Text> : null}
</View>
<ScrollView
@@ -300,17 +274,19 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && options.length === 0 ? (
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && options.length > 0 ? (
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
openPathLabel={t("projectPicker.openPath")}
onSelect={handleSelectPath}
/>
))}
@@ -353,11 +329,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
errorText: {
marginTop: theme.spacing[2],
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
results: {
flexGrow: 0,
},
@@ -383,8 +354,13 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: 20,
flex: 1,
flexShrink: 1,
},
rowActionText: {
fontSize: theme.fontSize.xs,
fontWeight: "500",
},
emptyText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import {
resolveProviderDiscoveredModels,
type ProviderDiscoveredModelsCache,
} from "./provider-diagnostic-models";
const piModel: AgentModelDefinition = {
provider: "pi",
id: "pi/model",
label: "Pi Model",
};
const grokModel: AgentModelDefinition = {
provider: "grok",
id: "grok-build",
label: "Grok Build",
};
function resolveModels(input: {
serverId?: string;
provider: string;
currentModels?: AgentModelDefinition[];
loading?: boolean;
cache?: ProviderDiscoveredModelsCache | null;
}) {
return resolveProviderDiscoveredModels({
serverId: input.serverId ?? "local",
provider: input.provider,
currentModels: input.currentModels,
providerSnapshotRefreshing: input.loading === true,
previousCache: input.cache ?? null,
});
}
describe("resolveProviderDiscoveredModels", () => {
it("keeps a provider's cached discovered models visible while that provider refreshes", () => {
const ready = resolveModels({ provider: "grok", currentModels: [grokModel] });
const refreshing = resolveModels({ provider: "grok", loading: true, cache: ready.cache });
expect(refreshing.models).toEqual([grokModel]);
});
it("does not show one provider's cached models while another provider loads", () => {
const ready = resolveModels({ provider: "pi", currentModels: [piModel] });
const refreshing = resolveModels({ provider: "grok", loading: true, cache: ready.cache });
expect(refreshing.models).toEqual([]);
});
it("does not show another server's cached models while the same provider loads", () => {
const ready = resolveModels({
serverId: "server-a",
provider: "grok",
currentModels: [grokModel],
});
const refreshing = resolveModels({
serverId: "server-b",
provider: "grok",
loading: true,
cache: ready.cache,
});
expect(refreshing.models).toEqual([]);
});
});

View File

@@ -0,0 +1,43 @@
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
export interface ProviderDiscoveredModelsCache {
serverId: string;
provider: string;
models: AgentModelDefinition[];
}
export interface ResolveProviderDiscoveredModelsInput {
serverId: string;
provider: string;
currentModels: AgentModelDefinition[] | undefined;
providerSnapshotRefreshing: boolean;
previousCache: ProviderDiscoveredModelsCache | null;
}
export interface ResolveProviderDiscoveredModelsResult {
models: AgentModelDefinition[];
cache: ProviderDiscoveredModelsCache | null;
}
export function resolveProviderDiscoveredModels({
serverId,
provider,
currentModels,
providerSnapshotRefreshing,
previousCache,
}: ResolveProviderDiscoveredModelsInput): ResolveProviderDiscoveredModelsResult {
if (currentModels && currentModels.length > 0) {
const cache = { serverId, provider, models: currentModels };
return { models: currentModels, cache };
}
if (
providerSnapshotRefreshing &&
previousCache?.serverId === serverId &&
previousCache.provider === provider
) {
return { models: previousCache.models, cache: previousCache };
}
return { models: [], cache: previousCache };
}

View File

@@ -7,7 +7,6 @@ import {
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
ScrollView,
Text,
View,
} from "react-native";
@@ -19,6 +18,7 @@ import {
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
@@ -32,6 +32,10 @@ import { formatTimeAgo } from "@/utils/time";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
import {
resolveProviderDiscoveredModels,
type ProviderDiscoveredModelsCache,
} from "./provider-diagnostic-models";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -359,26 +363,26 @@ function DiagnosticSubSheet({
let body: React.ReactNode;
if (loading && !diagnostic) {
body = (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
</SurfaceCard>
);
} else if (diagnostic) {
body = (
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
<ScrollView horizontal showsHorizontalScrollIndicator>
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={480}>
{diagnostic}
</ScrollableCodeSurface>
);
} else {
body = (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
</SurfaceCard>
);
}
@@ -391,7 +395,7 @@ function DiagnosticSubSheet({
scrollable={false}
testID="provider-diagnostic-sheet"
>
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
{body}
</AdaptiveModalSheet>
);
}
@@ -599,21 +603,16 @@ export function ProviderDiagnosticSheet({
: null;
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
const stableDiscoveredRef = useRef<ProviderDiscoveredModelsCache | null>(null);
const currentModels = providerEntry?.models;
if (currentModels && currentModels.length > 0) {
stableDiscoveredRef.current = currentModels;
}
const discoveredModels = useMemo(() => {
if (currentModels && currentModels.length > 0) {
return currentModels;
}
if (providerSnapshotRefreshing) {
return stableDiscoveredRef.current;
}
return [];
}, [currentModels, providerSnapshotRefreshing]);
const { models: discoveredModels, cache: nextDiscoveredCache } = resolveProviderDiscoveredModels({
serverId,
provider,
currentModels,
providerSnapshotRefreshing,
previousCache: stableDiscoveredRef.current,
});
stableDiscoveredRef.current = nextDiscoveredCache;
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
@@ -863,22 +862,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
justifyContent: "flex-end",
gap: theme.spacing[2],
},
diagnosticCard: {
overflow: "hidden",
},
codeScroll: {
maxHeight: 480,
},
codeContent: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
codeText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
@@ -894,4 +877,3 @@ const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFo
const MAIN_SNAP_POINTS = ["65%", "92%"];
const ADD_SNAP_POINTS = ["40%"];
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, sheetStyles.diagnosticCard];

View File

@@ -18,6 +18,7 @@ export function ProviderSettingsHost() {
return (
<ProviderDiagnosticSheet
key={`${serverId}:${provider}`}
provider={provider}
serverId={serverId}
visible={visible}

View File

@@ -152,29 +152,27 @@ function makeHost(): HostProfile {
};
}
function setHostProfiles(hosts: HostProfile[]): void {
(
getHostRuntimeStore() as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
getHostRuntimeStore().syncHosts([makeHost()]);
setHostProfiles([makeHost()]);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrderByServerId: {
[SERVER_ID]: ["project-a", "project-b"],
},
workspaceOrderByServerAndProject: {
[`${SERVER_ID}::project-a`]: [
`${SERVER_ID}:a-main`,
`${SERVER_ID}:a-one`,
`${SERVER_ID}:a-two`,
],
[`${SERVER_ID}::project-b`]: [
`${SERVER_ID}:b-main`,
`${SERVER_ID}:b-one`,
`${SERVER_ID}:b-two`,
],
projectOrder: ["project-a", "project-b"],
workspaceOrderByProject: {
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
},
});
});
@@ -258,7 +256,7 @@ function WorkspaceSelectionProbe({
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
return (
<>
@@ -345,11 +343,11 @@ describe("sidebar workspace render isolation", () => {
container = null;
act(() => {
pathnameState.value = "/";
getHostRuntimeStore().syncHosts([]);
setHostProfiles([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
projectOrder: [],
workspaceOrderByProject: {},
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,16 @@ import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -32,22 +34,29 @@ interface DisplayPreferenceOption<Value extends string> {
label: string;
}
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelect = useCallback(
const handleSelectMode = useCallback(
(mode: SidebarGroupMode) => {
if (!serverId) return;
setGroupMode(serverId, mode);
setGroupMode(mode);
},
[serverId, setGroupMode],
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
@@ -65,6 +74,8 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
[],
);
const showHostFilter = hosts.length > 1;
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -75,7 +86,7 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
@@ -85,9 +96,33 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelect}
onSelect={handleSelectMode}
/>
))}
{showHostFilter ? (
<>
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
))}
</>
) : null}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
@@ -129,6 +164,33 @@ function DisplayPreferenceMenuItem<Value extends string>({
);
}
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,

View File

@@ -1,9 +1,14 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
useSidebarWorkspaceEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
@@ -20,12 +25,44 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -40,11 +77,21 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
workspaces: SidebarStatusWorkspacePlacement[];
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -53,7 +100,6 @@ interface StatusWorkspaceListProps {
export function SidebarStatusWorkspaceList({
workspaces,
projectNamesByKey,
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -89,7 +135,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -106,7 +151,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -121,7 +165,6 @@ function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
serverId,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -129,7 +172,6 @@ function StatusGroupList({
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -149,7 +191,6 @@ function StatusGroupList({
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
serverId={serverId}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -248,45 +289,473 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
projectName,
serverId,
shortcutNumber,
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
workspace: SidebarStatusWorkspacePlacement;
projectName: string;
serverId: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
const handlePress = useCallback(() => {
if (!serverId) return;
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!hydratedWorkspace) return null;
if (!workspaceEntry) return null;
return (
<SidebarWorkspaceRow
workspace={hydratedWorkspace}
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
onPress={handlePress}
subtitle={projectName}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) return;
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
function StatusWorkspaceRowInner({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const isTouchPlatform = platformIsNative;
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive?: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
}: {
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -350,4 +819,37 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -230,6 +230,7 @@ export interface ComboboxItemProps {
disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
interactiveFeedback?: boolean;
onPress: () => void;
testID?: string;
}
@@ -244,6 +245,7 @@ export function ComboboxItem({
active,
disabled,
elevated,
interactiveFeedback = true,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -269,12 +271,16 @@ export function ComboboxItem({
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.comboboxItem,
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
interactiveFeedback &&
hovered &&
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
interactiveFeedback &&
pressed &&
(elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
interactiveFeedback && active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
],
[elevated, active, disabled],
[elevated, active, disabled, interactiveFeedback],
);
const itemContentStyle = useMemo(

View File

@@ -992,5 +992,6 @@ const styles = StyleSheet.create((theme) => ({
},
itemContent: {
flexShrink: 1,
minWidth: 0,
},
}));

View File

@@ -0,0 +1,210 @@
import { useMemo, type ReactNode } from "react";
import {
ScrollView,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { isWeb } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
type CodeSurfaceTone = "surface0" | "surface1" | "surface2";
interface ScrollableCodeSurfaceProps {
children: ReactNode;
maxHeight?: number;
horizontal?: boolean;
selectable?: boolean;
tone?: CodeSurfaceTone;
bordered?: boolean;
style?: StyleProp<ViewStyle>;
scrollStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
testID?: string;
accessibilityLabel?: string;
}
interface SurfaceCardProps {
children: ReactNode;
tone?: CodeSurfaceTone;
bordered?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
accessibilityLabel?: string;
codeSurface?: boolean;
}
export function SurfaceCard({
children,
tone = "surface1",
bordered = true,
style,
testID,
accessibilityLabel,
codeSurface = false,
}: SurfaceCardProps) {
const surfaceStyle = getSurfaceStyle(tone);
const containerStyle = useMemo(
() => [styles.container, surfaceStyle, bordered && styles.bordered, style],
[bordered, style, surfaceStyle],
);
const surface = (
<View
style={containerStyle}
dataSet={codeSurface ? CODE_SURFACE_DATASET : undefined}
testID={testID}
accessible={accessibilityLabel != null}
accessibilityLabel={accessibilityLabel}
>
{children}
</View>
);
return <AppearanceStyleBoundary>{surface}</AppearanceStyleBoundary>;
}
export function ScrollableCodeSurface({
children,
maxHeight,
horizontal = true,
selectable = true,
tone = "surface1",
bordered = true,
style,
scrollStyle,
contentContainerStyle,
textStyle,
testID,
accessibilityLabel,
}: ScrollableCodeSurfaceProps) {
const maxHeightStyle = useMemo(
() => (maxHeight == null ? null : inlineUnistylesStyle({ maxHeight })),
[maxHeight],
);
const outerScrollStyle = useMemo(
() => [maxHeightStyle, scrollStyle],
[maxHeightStyle, scrollStyle],
);
const contentStyle = useMemo(
() => [styles.content, contentContainerStyle],
[contentContainerStyle],
);
const codeTextStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
const codeContent =
typeof children === "string" ? (
<PlainCodeText text={children} selectable={selectable} textStyle={codeTextStyle} />
) : (
<Text selectable={selectable} style={codeTextStyle} dataSet={CODE_SURFACE_DATASET}>
{children}
</Text>
);
return (
<SurfaceCard
tone={tone}
bordered={bordered}
style={style}
testID={testID}
accessibilityLabel={accessibilityLabel}
codeSurface
>
<ScrollView
style={outerScrollStyle}
contentContainerStyle={contentStyle}
nestedScrollEnabled
showsVerticalScrollIndicator
>
{horizontal ? (
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
{codeContent}
</ScrollView>
) : (
codeContent
)}
</ScrollView>
</SurfaceCard>
);
}
function PlainCodeText({
text,
selectable,
textStyle,
}: {
text: string;
selectable: boolean;
textStyle: StyleProp<TextStyle>;
}) {
const lines = useMemo(() => {
let offset = 0;
return text.split("\n").map((line) => {
const key = `${offset}:${line.length}`;
offset += line.length + 1;
return { key, line };
});
}, [text]);
return (
<View dataSet={CODE_SURFACE_DATASET}>
{lines.map(({ key, line }) => (
<Text key={key} selectable={selectable} style={textStyle} dataSet={CODE_SURFACE_DATASET}>
{line.length === 0 ? "\u200B" : line}
</Text>
))}
</View>
);
}
function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp<ViewStyle> {
switch (tone) {
case "surface0":
return styles.surface0;
case "surface2":
return styles.surface2;
case "surface1":
return styles.surface1;
}
}
const styles = StyleSheet.create((theme) => ({
container: {
overflow: "hidden",
},
bordered: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
surface0: {
backgroundColor: theme.colors.surface0,
},
surface1: {
backgroundColor: theme.colors.surface1,
},
surface2: {
backgroundColor: theme.colors.surface2,
},
content: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
text: {
backgroundColor: "transparent",
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
}));

View File

@@ -12,7 +12,7 @@ import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
@@ -170,15 +170,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
useEffect(() => {
if (!anyOnlineServerId) return;
router.replace(buildHostRootRoute(anyOnlineServerId));
router.replace(buildOpenProjectRoute());
}, [anyOnlineServerId, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId));
},
[router],
);
const finishOnboarding = useCallback(() => {
router.replace(buildOpenProjectRoute());
}, [router]);
const handleOpenPaseoSite = useCallback(() => {
void openExternalUrl("https://paseo.sh");
@@ -197,9 +194,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, [router]);
const handleHostSaved = useCallback(
({ profile, serverId }: { profile: HostProfile; serverId: string }) => {
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);

View File

@@ -39,6 +39,8 @@ import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
interface Rect {
x: number;
@@ -284,6 +286,7 @@ function WorkspaceHoverCardContent({
{workspace.name}
</Text>
</View>
<HostRow serverId={workspace.serverId} />
{workspace.currentBranch ? (
<CopyableInfoRow
icon={ThemedGitBranch}
@@ -327,6 +330,22 @@ function WorkspaceHoverCardContent({
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedFolder = withUnistyles(Folder);
function HostRow({ serverId }: { serverId: string }): ReactElement | null {
const hosts = useHosts();
const host = hosts.find((h) => h.serverId === serverId);
const snapshot = useHostRuntimeSnapshot(serverId);
const label = host?.label?.trim() || serverId;
const status = formatConnectionStatus(snapshot?.connectionStatus ?? "idle");
return (
<View style={styles.hostRow}>
<Text style={styles.hostRowLabel}>{label}</Text>
<Text style={styles.hostRowStatus}>{status}</Text>
</View>
);
}
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
@@ -548,6 +567,22 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
hostRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hostRowLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hostRowStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
cardMetaRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -6,11 +6,13 @@ import { act } from "@testing-library/react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import type { HostProfile } from "@/types/host-connection";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
@@ -42,6 +44,27 @@ function workspaceDescriptor(input: {
};
}
function hostProfile(serverId = "srv"): HostProfile {
const now = "2026-04-19T00:00:00.000Z";
return {
serverId,
label: "Shortcut Host",
lifecycle: {},
connections: [],
preferredConnectionId: null,
createdAt: now,
updatedAt: now,
};
}
function setHostProfiles(hosts: HostProfile[]): void {
(
getHostRuntimeStore() as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
describe("WorkspaceShortcutTargetsSubscriber", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -58,14 +81,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
collapsedProjectKeys: new Set(),
});
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
projectOrder: [],
workspaceOrderByProject: {},
});
useSidebarViewStore.setState({
groupModeByServerId: {},
groupMode: "project",
hostFilter: null,
});
act(() => {
setHostProfiles([hostProfile()]);
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
"srv",
@@ -88,13 +113,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
container?.remove();
container = null;
act(() => {
setHostProfiles([]);
useSessionStore.getState().clearSession("srv");
useSessionStore.getState().clearSession("host-a");
useSessionStore.getState().clearSession("host-b");
});
});
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -105,7 +133,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("srv", "status");
useSidebarViewStore.getState().setGroupMode("status");
useSessionStore.getState().setWorkspaces(
"srv",
new Map([
@@ -158,7 +186,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -169,13 +197,52 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
it("publishes shortcut targets from the visible host filter in project and status modes", async () => {
act(() => {
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
useSessionStore
.getState()
.setWorkspaces(
"host-b",
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().setHostFilter("host-b");
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "host-b", workspaceId: "b-1" },
]);
await act(async () => {
useSidebarViewStore.getState().setGroupMode("status");
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "host-b", workspaceId: "b-1" },
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);

View File

@@ -1,9 +1,6 @@
import { useEffect, useMemo } from "react";
import {
useProjectNamesMap,
useStatusModeWorkspaceEntries,
} from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
@@ -12,28 +9,16 @@ import {
buildStatusSidebarShortcutModel,
} from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({
enabled,
serverId,
}: {
enabled: boolean;
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
// groupMode must be resolved before gating the status-mode subscriptions below.
const groupMode = useSidebarViewStore((state) =>
enabled && serverId ? state.getGroupMode(serverId) : "project",
);
// Only subscribe to agents/workspaces when the status-group view is actually active.
// In project mode (the default), these subscriptions would fire on every agent update
// (agents Map identity is replaced on every status transition) with no effect on
// the shortcut targets, causing ~15-46 wasted re-renders per agent switch.
const isStatusMode = enabled && groupMode === "status";
const statusWorkspaces = useStatusModeWorkspaceEntries({
serverId: isStatusMode ? serverId : null,
projects,
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({
enabled,
});
const groupMode = useSidebarViewStore((state) => state.groupMode);
const isStatusMode = enabled && groupMode === "status";
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
enabled: isStatusMode,
});
const projectNamesByKey = useProjectNamesMap(isStatusMode ? serverId : null);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
@@ -47,7 +32,7 @@ export function WorkspaceShortcutTargetsSubscriber({
const shortcutModel = useMemo(() => {
if (groupMode === "status") {
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces,
workspaces: statusWorkspacePlacements,
projectNamesByKey,
collapsedStatusGroupKeys,
});
@@ -63,17 +48,17 @@ export function WorkspaceShortcutTargetsSubscriber({
groupMode,
projectNamesByKey,
projects,
statusWorkspaces,
statusWorkspacePlacements,
]);
useEffect(() => {
if (!enabled || !serverId) {
if (!enabled) {
setSidebarShortcutWorkspaceTargets([]);
return;
}
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
}, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
}, [enabled, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
useEffect(() => {
return () => {

View File

@@ -1,7 +1,12 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
const pendingWorkspaceArchivesByServer = new Map<string, Set<string>>();
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
@@ -10,6 +15,7 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
@@ -17,8 +23,11 @@ export function markWorkspaceArchivePending(input: {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Set<string>();
archives.add(pendingArchiveKey({ serverId, workspaceId }));
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
@@ -45,15 +54,34 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
if (!serverId) {
return false;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
return archives?.has(pendingArchiveKey({ serverId, workspaceId })) ?? false;
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -63,5 +91,6 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}

View File

@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.109.3",
version: "2.110.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.3", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.110.0", "--acp"],
},
{
id: "codewhale",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.9",
version: "0.2.11",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.9", "acp"],
command: ["npx", "-y", "dimcode@0.2.11", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.7",
version: "0.4.9",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.7", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.9", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.157.1",
version: "0.158.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.157.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.158.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.21",
version: "0.7.22",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.7.21", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.7.22", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.19",
version: "1.1.21",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.19", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.26",
version: "1.0.30",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.26", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.30", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.1",
version: "0.19.2",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.1", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.2", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -1,23 +0,0 @@
import { getDesktopHost } from "@/desktop/host";
export async function pickDirectory(): Promise<string | null> {
const open = getDesktopHost()?.dialog?.open;
if (typeof open !== "function") {
throw new Error("Desktop dialog open() is unavailable in this environment.");
}
const selection = await open({
directory: true,
multiple: false,
});
if (selection === null) {
return null;
}
if (typeof selection === "string") {
return selection;
}
throw new Error("Unexpected directory picker response.");
}

View File

@@ -0,0 +1,104 @@
import { describe, expect, test } from "vitest";
import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
function makeHost(): HostProfile {
return {
serverId: "srv-secret",
label: "Secret host",
lifecycle: {},
preferredConnectionId: "direct:secret.example.test:6767",
createdAt: "2026-06-25T00:00:00.000Z",
updatedAt: "2026-06-25T00:00:00.000Z",
connections: [
{
id: "direct:secret.example.test:6767",
type: "directTcp",
endpoint: "secret.example.test:6767",
useTls: true,
password: "tcp-password",
},
{
id: "relay:relay.secret.test:443",
type: "relay",
relayEndpoint: "relay.secret.test:443",
useTls: true,
daemonPublicKeyB64: "daemon-public-key-secret",
},
{
id: "socket:/tmp/paseo-secret.sock",
type: "directSocket",
path: "/tmp/paseo-secret.sock",
},
{
id: "pipe:\\\\.\\pipe\\paseo-secret",
type: "directPipe",
path: "\\\\.\\pipe\\paseo-secret",
},
],
};
}
describe("app diagnostics report", () => {
test("formats connection rows without raw connection details", () => {
const host = makeHost();
const snapshot: HostRuntimeSnapshot = {
serverId: host.serverId,
activeConnectionId: "relay:relay.secret.test:443",
activeConnection: {
type: "relay",
endpoint: "relay.secret.test:443",
display: "relay",
},
connectionStatus: "online",
client: null,
lastError: null,
lastOnlineAt: "2026-06-25T00:00:00.000Z",
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map([
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
};
const report = formatHostRuntimeSection({ host, snapshot });
expect(report).toContain("direct TCP");
expect(report).toContain("relay");
expect(report).toContain("local socket");
expect(report).toContain("local pipe");
expect(report).not.toContain("secret.example.test");
expect(report).not.toContain("relay.secret.test");
expect(report).not.toContain("daemon-public-key-secret");
expect(report).not.toContain("/tmp/paseo-secret.sock");
expect(report).not.toContain("tcp-password");
});
test("redacts saved connection secrets from collected daemon and desktop text", () => {
const host = makeHost();
const redacted = redactAppDiagnosticReport(
[
"secret.example.test:6767",
"relay.secret.test:443",
"daemon-public-key-secret",
"/tmp/paseo-secret.sock",
"\\\\.\\pipe\\paseo-secret",
"password=tcp-password",
"paseo://pairing-secret",
].join("\n"),
[host],
);
expect(redacted).not.toContain("secret.example.test");
expect(redacted).not.toContain("relay.secret.test");
expect(redacted).not.toContain("daemon-public-key-secret");
expect(redacted).not.toContain("/tmp/paseo-secret.sock");
expect(redacted).not.toContain("\\\\.\\pipe\\paseo-secret");
expect(redacted).not.toContain("tcp-password");
expect(redacted).not.toContain("pairing-secret");
});
});

View File

@@ -0,0 +1,133 @@
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import type { HostConnection, HostProfile } from "@/types/host-connection";
interface DiagnosticEntry {
label: string;
value: string;
}
export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
}
export function formatAppDiagnosticHeader(input: {
appVersion: string | null;
platform: string;
isDesktopApp: boolean;
hostCount: number;
}): string {
return formatDiagnosticSection("Paseo app diagnostics", [
{ label: "Collected at", value: new Date().toISOString() },
{ label: "App version", value: input.appVersion ?? "unknown" },
{ label: "Platform", value: input.platform },
{ label: "Desktop app", value: String(input.isDesktopApp) },
{ label: "Saved hosts", value: String(input.hostCount) },
]);
}
export function formatHostRuntimeSection(input: {
host: HostProfile;
snapshot: HostRuntimeSnapshot | null;
}): string {
const { host, snapshot } = input;
const entries: DiagnosticEntry[] = [
{ label: "Server ID", value: host.serverId },
{ label: "Status", value: snapshot?.connectionStatus ?? "not started" },
{
label: "Active connection",
value: snapshot?.activeConnection
? describeConnectionKind(snapshot.activeConnection.type)
: "none",
},
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
{ label: "Last error", value: snapshot?.lastError ?? "none" },
{
label: "Agent directory",
value: snapshot?.agentDirectoryStatus ?? "unknown",
},
];
const connectionRows = host.connections.map((connection, index) => {
const probe = snapshot?.probeByConnectionId.get(connection.id) ?? null;
const isActive = snapshot?.activeConnectionId === connection.id;
return {
label: `Connection ${index + 1}`,
value: [
describeConnectionKind(connection.type),
isActive ? "active" : "inactive",
probe ? `probe=${probe.status}` : "probe=unknown",
probe?.status === "available" ? `latency=${Math.round(probe.latencyMs)}ms` : null,
]
.filter(Boolean)
.join(", "),
};
});
return formatDiagnosticSection(`Host: ${host.label}`, [...entries, ...connectionRows]);
}
export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | null): string {
if (!serverInfo) {
return formatDiagnosticSection("Server info", [{ label: "Status", value: "not received" }]);
}
const features = serverInfo.features ? Object.keys(serverInfo.features).sort() : [];
return formatDiagnosticSection("Server info", [
{ label: "Server ID", value: serverInfo.serverId },
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" },
{ label: "Version", value: serverInfo.version ?? "unknown" },
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
]);
}
export function describeConnectionKind(type: HostConnection["type"] | string): string {
switch (type) {
case "directTcp":
return "direct TCP";
case "directSocket":
return "local socket";
case "directPipe":
return "local pipe";
case "relay":
return "relay";
default:
return "unknown";
}
}
export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]): string {
let redacted = report;
for (const value of collectSensitiveHostValues(hosts)) {
redacted = redacted.split(value).join("[redacted]");
}
return redacted
.replace(/paseo:\/\/\S+/gi, "paseo://[redacted]")
.replace(
/([?&](?:password|token|secret|key|publicKey|daemonPublicKeyB64)=)[^&\s"']+/gi,
"$1[redacted]",
)
.replace(
/((?:password|token|secret|authorization|api[_-]?key|daemonPublicKeyB64|relayKey)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi,
"$1[redacted]",
);
}
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
const values = new Set<string>();
for (const host of hosts) {
for (const connection of host.connections) {
values.add(connection.id);
if (connection.type === "directTcp") {
values.add(connection.endpoint);
if (connection.password) values.add(connection.password);
} else if (connection.type === "relay") {
values.add(connection.relayEndpoint);
values.add(connection.daemonPublicKeyB64);
} else if (connection.type === "directSocket" || connection.type === "directPipe") {
values.add(connection.path);
}
}
}
return [...values].filter((value) => value.trim().length > 0);
}

View File

@@ -0,0 +1,57 @@
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
interface PrStatusLike {
url: string;
state: string;
isMerged: boolean;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
let state: "merged" | "open" | "closed";
if (status.isMerged || status.state === "merged") state = "merged";
else if (status.state === "open") state = "open";
else state = "closed";
return {
url: status.url,
number,
state,
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
};
}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
interface UseCheckoutPrStatusQueryOptions {
serverId: string;
@@ -11,64 +12,7 @@ interface UseCheckoutPrStatusQueryOptions {
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
interface PrStatusLike {
url: string;
state: string;
isMerged: boolean;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
let state: "merged" | "open" | "closed";
if (status.isMerged || status.state === "merged") state = "merged";
else if (status.state === "open") state = "open";
else state = "closed";
return {
url: status.url,
number,
state,
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
};
}
export { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
return selectPrHintFromStatus(payload.status);

View File

@@ -1,3 +1,13 @@
export function agentHistoryQueryKey(serverId: string | null) {
return ["agentHistory", serverId] as const;
}
const ALL_AGENT_HISTORY_QUERY_ROOT = ["allAgentHistory"] as const;
export function allAgentHistoryQueryRootKey() {
return ALL_AGENT_HISTORY_QUERY_ROOT;
}
export function allAgentHistoryQueryKey(serverIds: readonly string[]) {
return [...ALL_AGENT_HISTORY_QUERY_ROOT, ...[...serverIds].sort()] as const;
}

View File

@@ -19,6 +19,21 @@ export interface OpenProjectFailure {
}
export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure;
export type OpenProjectFailureReason = "directory_not_found" | "open_failed";
export function getOpenProjectFailureReason(
result: OpenProjectResult,
): OpenProjectFailureReason | null {
if (result.ok) {
return null;
}
if (result.errorCode === "directory_not_found") {
return "directory_not_found";
}
return "open_failed";
}
export interface OpenProjectDirectlyInput {
serverId: string;

View File

@@ -15,6 +15,7 @@ function ws(
serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj",
projectName: input.projectName ?? "Project",
projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory,
projectKind: input.projectKind ?? "git",

View File

@@ -1,6 +1,6 @@
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
"needs_input",
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
export interface StatusGroup {
bucket: StatusBucket;
label: string;
rows: SidebarWorkspaceEntry[];
rows: SidebarStatusWorkspacePlacement[];
}
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
workspaces: SidebarStatusWorkspacePlacement[],
projectNamesByKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
for (const ws of workspaces) {
const bucket: StatusBucket = ws.statusBucket;
@@ -54,8 +54,8 @@ export function buildStatusGroups(
}
function compareStatusRows(
a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry,
a: SidebarStatusWorkspacePlacement,
b: SidebarStatusWorkspacePlacement,
projectNamesByKey: Map<string, string>,
): number {
const aTime = a.statusEnteredAt?.getTime() ?? null;

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarStatusWorkspacePlacements,
buildSidebarWorkspacePlacementModel,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
@@ -23,12 +26,20 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
hosts?: WorkspaceStructureProject["hosts"];
}): WorkspaceStructureProject {
return {
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
hosts: input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys,
};
}
@@ -36,10 +47,8 @@ function project(input: {
function sidebarProject(input: {
projectKey: string;
workspaceKeys: string[];
serverId?: string;
}): SidebarProjectEntry {
const projects = buildSidebarProjectsFromStructure({
serverId: input.serverId ?? "srv",
projects: [project({ projectKey: input.projectKey, workspaceKeys: input.workspaceKeys })],
});
const result = projects[0];
@@ -49,6 +58,31 @@ function sidebarProject(input: {
return result;
}
function workspace(input: {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
projectKind: "git",
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
@@ -107,7 +141,6 @@ describe("appendMissingOrderKeys", () => {
describe("buildSidebarProjectsFromStructure", () => {
it("creates structural workspace rows from ordered workspace keys", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [
project({
projectKey: "project-1",
@@ -131,7 +164,6 @@ describe("buildSidebarProjectsFromStructure", () => {
it("preserves the structure hook project order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
@@ -143,15 +175,126 @@ describe("buildSidebarProjectsFromStructure", () => {
it("preserves the structure hook workspace order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
});
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
expect(projects[0]?.workspaces.map((placement) => placement.workspaceId)).toEqual([
"feature",
"main",
]);
});
it("resolves workspace keys by known host prefix when server ids contain colons", () => {
const projects = buildSidebarProjectsFromStructure({
projects: [
project({
projectKey: "project-1",
hosts: [
{
serverId: "relay:paseo-host",
iconWorkingDir: "/repo/project-1",
canCreateWorktree: true,
},
],
workspaceKeys: ["relay:paseo-host:ws-main"],
}),
],
});
expect(projects[0]?.workspaces[0]).toMatchObject({
workspaceKey: "relay:paseo-host:ws-main",
serverId: "relay:paseo-host",
workspaceId: "ws-main",
});
});
});
describe("shared sidebar workspace model", () => {
it("feeds project placement and status grouping from the same cross-host workspace identities", () => {
const model = buildSidebarWorkspacePlacementModel({
projects: [
project({
projectKey: "getpaseo/paseo",
projectName: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
],
workspaceKeys: ["host-a:main", "host-b:feature"],
}),
],
});
const statusRows = buildSidebarStatusWorkspacePlacements({
placements: model.workspaces,
sessions: [
{
serverId: "host-a",
workspaces: new Map([
[
"main",
workspace({
id: "main",
name: "main",
projectId: "getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
status: "done",
}),
],
]),
},
{
serverId: "host-b",
workspaces: new Map([
[
"feature",
workspace({
id: "feature",
name: "feature/status-flow",
projectId: "getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
status: "running",
statusEnteredAt: new Date("2026-06-10T00:00:00.000Z"),
}),
],
]),
},
],
});
expect(model.workspaces.map((entry) => entry.workspaceKey)).toEqual([
"host-a:main",
"host-b:feature",
]);
expect(model.projects).toEqual([
expect.objectContaining({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
],
workspaces: [
expect.objectContaining({
workspaceKey: "host-a:main",
serverId: "host-a",
name: "main",
}),
expect.objectContaining({
workspaceKey: "host-b:feature",
serverId: "host-b",
name: "feature",
}),
],
}),
]);
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
[
["host-a:main", "done", "main"],
["host-b:feature", "running", "feature/status-flow"],
],
);
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
});
});
describe("computeSidebarOrderUpdates", () => {
@@ -206,8 +349,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: false,
}),
).toEqual({ isLoading: true, isInitialLoad: true, isRevalidating: false });
@@ -217,8 +360,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: true,
}),
).toEqual({ isLoading: true, isInitialLoad: false, isRevalidating: false });
@@ -228,8 +371,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: true,
serverIds: ["srv"],
hydratedServerIds: ["srv"],
hasProjects: true,
}),
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
@@ -239,8 +382,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: false,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: false,
}),
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });

View File

@@ -1,38 +1,44 @@
import type { PrHint } from "@/git/use-pr-status-query";
import {
canCreateWorktreeForProjectKind,
type HostProjectListItem,
} from "@/projects/host-project-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import type { PrHint } from "@/git/pr-hint";
import { selectPrHintFromStatus } from "@/git/pr-hint";
import { type HostProjectListItem } from "@/projects/host-project-model";
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
function workspaceNameFromDirectory(directory: string): string {
const trimmed = directory.trim().replace(/[\\/]+$/g, "");
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
}
export type SidebarStateBucket = WorkspaceDescriptor["status"];
export interface SidebarWorkspaceEntry {
export interface SidebarWorkspacePlacement {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKey: string;
projectName: string;
projectRootPath?: string;
workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
}
export interface SidebarStatusWorkspacePlacement extends SidebarWorkspacePlacement {
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
}
export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
// Raw user-set title (null when the name is derived from branch/directory).
// Prefills the rename input and signals whether a reset is available.
title: string | null;
// Checkout branch (null when not a git checkout or detached HEAD).
currentBranch: string | null;
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
prHint: PrHint | null;
@@ -47,52 +53,278 @@ export interface SidebarProjectEntry {
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
canCreateWorktree: boolean;
workspaces: SidebarWorkspaceEntry[];
hosts: WorkspaceStructureHostPlacement[];
workspaces: SidebarWorkspacePlacement[];
}
export interface SidebarWorkspacePlacementModel {
workspaces: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
}
export interface SidebarStatusWorkspaceSession {
serverId: string;
workspaces: Map<string, WorkspaceDescriptor>;
agents?: Map<string, Agent>;
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
return (
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey)
);
}
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
if (!currentBranch) {
return null;
}
const trimmed = currentBranch.trim();
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
}
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey,
projectName: projectNameForWorkspace(input.workspace, projectKey),
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
title: input.workspace.title ?? null,
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};
}
function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
pendingCreateAttempts: input.pendingCreateAttempts,
});
if (pendingStartedAt) {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
function getPendingInitialAgentCreateStartedAt(input: {
serverId: string;
workspaceId: string;
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
}): Date | null {
let latestStartedAt: Date | null = null;
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
if (pending.serverId !== input.serverId) continue;
if (pending.workspaceId !== input.workspaceId) continue;
if (pending.lifecycle === "abandoned") continue;
const startedAt = new Date(pending.timestamp);
if (!latestStartedAt || startedAt > latestStartedAt) {
latestStartedAt = startedAt;
}
}
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents?: Map<string, Agent>;
}): WorkspaceAgentActivity | null {
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function buildSidebarWorkspacePlacementModel(input: {
projects: readonly HostProjectListItem[];
}): SidebarWorkspacePlacementModel {
const projects = buildSidebarProjectsFromHostProjects({ projects: input.projects });
return {
projects,
workspaces: projects.flatMap((project) => project.workspaces),
projectNamesByKey: new Map(
projects.map((project) => [project.projectKey, project.projectName]),
),
};
}
function createStructuralWorkspaceEntry(input: {
serverId: string;
project: HostProjectListItem;
workspaceId: string;
}): SidebarWorkspaceEntry {
workspaceKey: string;
}): SidebarWorkspacePlacement {
const identity = resolveStructuralWorkspaceIdentity({
project: input.project,
workspaceKey: input.workspaceKey,
});
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
workspaceKey: identity.workspaceKey,
serverId: identity.serverId,
workspaceId: identity.workspaceId,
projectKey: input.project.projectKey,
projectName: input.project.projectName,
projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined,
projectKind: input.project.projectKind,
workspaceKind: "checkout",
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
title: null,
currentBranch: null,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
name: identity.workspaceId,
};
}
export function buildSidebarProjectsFromStructure(input: {
function resolveStructuralWorkspaceIdentity(input: {
project: HostProjectListItem;
workspaceKey: string;
}): {
workspaceKey: string;
serverId: string;
workspaceId: string;
} {
const hostsByLongestPrefix = [...input.project.hosts].sort(
(left, right) => right.serverId.length - left.serverId.length,
);
for (const host of hostsByLongestPrefix) {
const prefix = `${host.serverId}:`;
if (!input.workspaceKey.startsWith(prefix)) continue;
const workspaceId = input.workspaceKey.slice(prefix.length);
if (!workspaceId) continue;
return {
workspaceKey: input.workspaceKey,
serverId: host.serverId,
workspaceId,
};
}
const separatorIndex = input.workspaceKey.indexOf(":");
if (separatorIndex > 0) {
return {
workspaceKey: input.workspaceKey,
serverId: input.workspaceKey.slice(0, separatorIndex),
workspaceId: input.workspaceKey.slice(separatorIndex + 1),
};
}
const serverId = input.project.hosts[0]?.serverId ?? input.workspaceKey;
return {
workspaceKey: `${serverId}:${input.workspaceKey}`,
serverId,
workspaceId: input.workspaceKey,
};
}
export function buildSidebarStatusWorkspacePlacements(input: {
placements: readonly SidebarWorkspacePlacement[];
sessions: SidebarStatusWorkspaceSession[];
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
}): SidebarStatusWorkspacePlacement[] {
if (input.placements.length === 0 || input.sessions.length === 0) {
return [];
}
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
const rows: SidebarStatusWorkspacePlacement[] = [];
for (const placement of input.placements) {
const session = sessionByServerId.get(placement.serverId);
if (!session) continue;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: session.workspaces,
workspaceId: placement.workspaceId,
});
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
if (!workspace) continue;
const effectiveStatus = deriveEffectiveWorkspaceStatus({
serverId: placement.serverId,
workspace,
pendingCreateAttempts: input.pendingCreateAttempts,
agents: session.agents,
});
rows.push({
...placement,
name: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
});
}
return rows;
}
export function buildSidebarProjectsFromStructure(input: {
projects: WorkspaceStructureProject[];
}): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
})),
});
}
@@ -109,12 +341,11 @@ export function buildSidebarProjectsFromHostProjects(input: {
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
canCreateWorktree: project.canCreateWorktree,
workspaces: project.workspaceKeys.map((workspaceId) =>
hosts: project.hosts,
workspaces: project.workspaceKeys.map((workspaceKey) =>
createStructuralWorkspaceEntry({
serverId: project.serverId,
project,
workspaceId,
workspaceKey,
}),
),
}));
@@ -227,11 +458,14 @@ export interface SidebarLoadingState {
export function deriveSidebarLoadingState(input: {
isActive: boolean;
serverId: string | null;
hasHydratedWorkspaces: boolean;
serverIds: string[];
hydratedServerIds: string[];
hasProjects: boolean;
}): SidebarLoadingState {
const isLoading = input.isActive && Boolean(input.serverId) && !input.hasHydratedWorkspaces;
const hasRegisteredHosts = input.serverIds.length > 0;
const allHydrated =
input.serverIds.length > 0 && input.serverIds.length === input.hydratedServerIds.length;
const isLoading = input.isActive && hasRegisteredHosts && !allHydrated;
const isInitialLoad = isLoading && !input.hasProjects;
return { isLoading, isInitialLoad, isRevalidating: false };
}

View File

@@ -1,15 +0,0 @@
import { useMemo } from "react";
import { usePathname } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { resolveActiveHost } from "@/utils/active-host";
export function useActiveHost() {
const pathname = usePathname();
const hosts = useHosts();
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
}
export function useActiveServerId(): string | null {
return useActiveHost()?.serverId ?? null;
}

View File

@@ -1,10 +1,28 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type {
DaemonClient,
FetchAgentHistoryEntry,
FetchAgentHistoryOptions,
} from "@getpaseo/client/internal/daemon-client";
import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history";
import type { AgentHistoryClient, AgentHistoryHost } from "./use-agent-history";
import { allAgentHistoryQueryKey } from "./agent-history-query-key";
(
globalThis as unknown as {
__DEV__: boolean;
}
).__DEV__ = false;
type UseAgentHistoryModule = typeof import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
});
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
@@ -30,6 +48,17 @@ function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient
};
}
function createFailingClient(): FakeAgentHistoryClient {
const calls: FetchAgentHistoryOptions[] = [];
return {
calls,
fetchAgentHistory: async (options) => {
calls.push(options ?? {});
throw new Error("Host history failed");
},
};
}
function historyPayload(input: {
entries: FetchAgentHistoryEntry[];
hasMore?: boolean;
@@ -105,6 +134,12 @@ function historyEntry(input: {
}
describe("fetchAgentHistoryPage", () => {
it("builds the all-host query key independent of host order", () => {
expect(allAgentHistoryQueryKey(["server-b", "server-a"])).toEqual(
allAgentHistoryQueryKey(["server-a", "server-b"]),
);
});
it("requests the first page with the default limit and updated_at descending sort", async () => {
const client = createClient([
historyPayload({
@@ -206,4 +241,134 @@ describe("fetchAgentHistoryPage", () => {
expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
});
it("fetches and sorts history across hosts with host labels", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "older-a",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Older A",
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "newer-b",
cwd: "/repo/b",
updatedAt: "2026-04-02T10:00:00.000Z",
title: "Newer B",
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
});
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
"Linux box:newer-b",
"MacBook:older-a",
]);
});
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "next-a",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "next-b",
cwd: "/repo/b",
updatedAt: "2026-04-02T10:00:00.000Z",
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: { "server-b": "cursor-b" },
});
expect(page.agents.map((agent) => agent.id)).toEqual(["next-b"]);
expect(serverAClient.calls).toEqual([]);
expect(serverBClient.calls).toEqual([
{
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-b" },
} satisfies FetchAgentHistoryOptions,
]);
});
it("keeps fulfilled host history when another host fails", async () => {
const failedClient = createFailingClient();
const healthyClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "healthy-history",
cwd: "/repo/healthy",
updatedAt: "2026-04-02T10:00:00.000Z",
}),
],
hasMore: true,
nextCursor: "healthy-cursor",
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "failed-host", serverLabel: "Failed", client: failedClient },
{ serverId: "healthy-host", serverLabel: "Healthy", client: healthyClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
});
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
"Healthy:healthy-history",
]);
expect(page.pageInfoByServerId).toEqual({
"healthy-host": {
nextCursor: "healthy-cursor",
prevCursor: null,
hasMore: true,
},
});
});
it("throws when every requested host history fetch fails", async () => {
await expect(
fetchAgentHistoryBatch({
hosts: [
{ serverId: "failed-a", serverLabel: "Failed A", client: createFailingClient() },
{ serverId: "failed-b", serverLabel: "Failed B", client: createFailingClient() },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
}),
).rejects.toThrow("No connected hosts could load agent history");
});
});

View File

@@ -4,26 +4,28 @@ import type {
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
const AGENT_HISTORY_PAGE_LIMIT = 200;
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
const AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE = "No connected hosts could load agent history";
export interface AgentHistoryResult {
agents: AggregatedAgent[];
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
isError: boolean;
hasMore: boolean;
isLoadingMore: boolean;
refreshAll: () => void;
refreshAll: () => Promise<void>;
loadMore: () => void;
}
@@ -34,6 +36,19 @@ export interface AgentHistoryPage {
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
export interface AgentHistoryHost {
serverId: string;
serverLabel: string;
client: AgentHistoryClient;
}
interface AgentHistoryBatchPage {
agents: AggregatedAgent[];
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
}
type AgentHistoryCursorByServerId = Record<string, string | null>;
export async function fetchAgentHistoryPage(input: {
client: AgentHistoryClient;
serverId: string;
@@ -75,70 +90,167 @@ export async function fetchAgentHistoryPage(input: {
};
}
function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}
function getNextAgentHistoryPageParam(
page: AgentHistoryBatchPage,
): AgentHistoryCursorByServerId | null {
const cursorByServerId: AgentHistoryCursorByServerId = {};
for (const [serverId, pageInfo] of Object.entries(page.pageInfoByServerId)) {
if (pageInfo.hasMore && pageInfo.nextCursor) {
cursorByServerId[serverId] = pageInfo.nextCursor;
}
}
return Object.keys(cursorByServerId).length > 0 ? cursorByServerId : null;
}
export async function fetchAgentHistoryBatch(input: {
hosts: readonly AgentHistoryHost[];
cursorByServerId: AgentHistoryCursorByServerId | null;
}): Promise<AgentHistoryBatchPage> {
const cursorByServerId = input.cursorByServerId ?? {};
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
const hostsToFetch = hasCursorFilter
? input.hosts.filter((host) => Object.hasOwn(cursorByServerId, host.serverId))
: input.hosts;
const settledPages = await Promise.allSettled(
hostsToFetch.map(async (host) => {
const page = await fetchAgentHistoryPage({
client: host.client,
serverId: host.serverId,
cursor: cursorByServerId[host.serverId] ?? null,
});
return { host, page };
}),
);
const pages = settledPages.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
);
if (pages.length === 0) {
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
}
const agents = pages.flatMap(({ host, page }) =>
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
);
const pageInfoByServerId = Object.fromEntries(
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
);
return {
agents: sortByLatestActivity(agents),
pageInfoByServerId,
};
}
export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]);
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
const targetHosts = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
if (!client || !isHostRuntimeConnected(snapshot)) {
continue;
}
hosts.push({
serverId: targetServerId,
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
client,
});
}
return hosts;
}, [daemons, runtime, runtimeVersion, serverId]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
const queryKey = useMemo(
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
[serverId, targetServerIds],
);
const serverLabelById = useMemo(
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
[daemons],
);
const historyQuery = useInfiniteQuery<
AgentHistoryPage,
AgentHistoryBatchPage,
Error,
{ pages: AgentHistoryPage[] },
ReturnType<typeof agentHistoryQueryKey>,
string | null
{ pages: AgentHistoryBatchPage[] },
readonly unknown[],
AgentHistoryCursorByServerId | null
>({
queryKey,
enabled: Boolean(enabled && serverId && client && isConnected),
enabled: Boolean(enabled && targetHosts.length > 0),
staleTime: 30_000,
initialPageParam: null as string | null,
getNextPageParam: (lastPage) =>
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
initialPageParam: null,
getNextPageParam: getNextAgentHistoryPageParam,
queryFn: async ({ pageParam }) => {
if (!serverId || !client) {
if (targetHosts.length === 0) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
return fetchAgentHistoryBatch({
hosts: targetHosts,
cursorByServerId: pageParam,
});
},
});
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
historyQuery;
const {
data,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
refetch,
} = historyQuery;
const refreshAll = useCallback(() => {
if (!serverId || !client || !isConnected) {
const refreshAll = useCallback(async () => {
if (!enabled || targetHosts.length === 0) {
return;
}
void refetch();
}, [client, isConnected, refetch, serverId]);
await refetch();
}, [enabled, refetch, targetHosts.length]);
const loadMore = useCallback(() => {
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
if (!enabled || targetHosts.length === 0 || !hasNextPage || isFetchingNextPage) {
return;
}
void fetchNextPage();
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
const agents = useMemo(
() =>
(data?.pages ?? [])
.flatMap((page) => page.agents)
.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabel ?? agent.serverLabel,
}),
),
[data?.pages, serverLabel],
);
const agents = useMemo(() => {
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
const labelledAgents = historyAgents.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
}),
);
return sortByLatestActivity(labelledAgents);
}, [data?.pages, serverLabelById]);
const isInitialLoad = isLoading && agents.length === 0;
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
@@ -147,6 +259,7 @@ export function useAgentHistory(options: {
isLoading,
isInitialLoad,
isRevalidating,
isError,
hasMore: hasNextPage,
isLoadingMore: isFetchingNextPage,
refreshAll,

View File

@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-all-agents-list";
import type { Agent } from "@/stores/session-store";
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "idle",
createdAt: AGENT_TIMESTAMP,
updatedAt: AGENT_TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: AGENT_TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/tmp/project",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input?: Partial<Agent>): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
describe("useAllAgentsList", () => {
it("excludes archived agents by default", () => {
const visibleAgent = makeAgent({ id: "visible" });
const archivedAgent = makeAgent({
id: "archived",
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
});
const result = __private__.buildAllAgentsList({
agents: [visibleAgent, archivedAgent],
serverId: "server-1",
serverLabel: "Local",
includeArchived: false,
});
expect(result.map((agent) => agent.id)).toEqual(["visible"]);
});
it("includes archived agents when requested", () => {
const visibleAgent = makeAgent({ id: "visible" });
const archivedAgent = makeAgent({
id: "archived",
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
});
const result = __private__.buildAllAgentsList({
agents: [visibleAgent, archivedAgent],
serverId: "server-1",
serverLabel: "Local",
includeArchived: true,
});
expect(result.map((agent) => agent.id)).toEqual(["visible", "archived"]);
expect(result[1]?.archivedAt).toEqual(archivedAgent.archivedAt);
});
});

View File

@@ -1,125 +0,0 @@
import { useCallback, useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatus,
useHostRuntimeIsDirectoryLoading,
} from "@/runtime/host-runtime";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: {
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
const source = params.source;
return {
id: source.id,
serverId: params.serverId,
serverLabel: params.serverLabel,
title: source.title ?? null,
status: source.status,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,
provider: source.provider,
pendingPermissionCount: source.pendingPermissions.length,
requiresAttention: source.requiresAttention,
attentionReason: source.attentionReason,
attentionTimestamp: source.attentionTimestamp ?? null,
archivedAt: source.archivedAt ?? null,
createdAt: source.createdAt,
labels: source.labels,
};
}
function buildAllAgentsList(params: {
agents: Iterable<Agent>;
serverId: string;
serverLabel: string;
includeArchived: boolean;
}): AggregatedAgent[] {
const list: AggregatedAgent[] = [];
for (const agent of params.agents) {
const aggregated = toAggregatedAgent({
source: agent,
serverId: params.serverId,
serverLabel: params.serverLabel,
});
if (!params.includeArchived && aggregated.archivedAt) {
continue;
}
list.push(aggregated);
}
list.sort((left, right) => {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
if (leftRunning && !rightRunning) {
return -1;
}
if (!leftRunning && rightRunning) {
return 1;
}
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
});
return list;
}
export function useAllAgentsList(options?: {
serverId?: string | null;
includeArchived?: boolean;
}): AggregatedAgentsResult {
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const includeArchived = options?.includeArchived ?? false;
const liveAgents = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.agents ?? null) : null,
);
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
const refreshAll = useCallback(() => {
if (!serverId || connectionStatus !== "online") {
return;
}
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, connectionStatus]);
const agents = useMemo(() => {
if (!serverId || !liveAgents) {
return [];
}
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
return buildAllAgentsList({
agents: liveAgents.values(),
serverId,
serverLabel,
includeArchived,
});
}, [daemons, includeArchived, liveAgents, serverId]);
const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? "");
const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isRevalidating = isDirectoryLoading && agents.length > 0;
return {
agents,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
};
}
export const __private__ = {
buildAllAgentsList,
toAggregatedAgent,
};

View File

@@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
import {
applyArchivedAgentCloseResults,
isAgentArchiving,
@@ -144,6 +144,17 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]), {
pages: [
{
agents: [
{ id: "agent-1", serverId: "server-a", archivedAt: null },
{ id: "agent-1", serverId: "server-b", archivedAt: null },
],
},
],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -174,6 +185,24 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
expect(
queryClient.getQueryState(allAgentHistoryQueryKey(["server-a", "server-b"]))?.isInvalidated,
).toBe(true);
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]))).toEqual({
pages: [
{
agents: [
{
id: "agent-1",
serverId: "server-a",
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
},
{ id: "agent-1", serverId: "server-b", archivedAt: null },
],
},
],
pageParams: [null],
});
});
it("can apply archived agent close results without invalidating cached lists", () => {
@@ -194,6 +223,10 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a"]), {
pages: [{ agents: [{ id: "agent-1", serverId: "server-a", archivedAt: null }] }],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -205,6 +238,9 @@ describe("useArchiveAgent", () => {
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(allAgentHistoryQueryKey(["server-a"]))?.isInvalidated).toBe(
false,
);
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
pages: [
{
@@ -213,5 +249,19 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a"]))).toEqual({
pages: [
{
agents: [
{
id: "agent-1",
serverId: "server-a",
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
},
],
},
],
pageParams: [null],
});
});
});

View File

@@ -1,8 +1,14 @@
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
const EMPTY_PENDING_ARCHIVE_AGENT_IDS = new Set<string>();
@@ -29,6 +35,7 @@ export interface AgentsListQueryData {
export interface AgentHistoryQueryAgent {
id?: string | null;
serverId?: string | null;
archivedAt?: Date | null;
}
@@ -175,7 +182,10 @@ export function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryDat
let pageChanged = false;
const agents = page.agents.map((agent) => {
if (agent.id !== input.agentId) {
if (
agent.id !== input.agentId ||
(agent.serverId != null && agent.serverId !== input.serverId)
) {
return agent;
}
pageChanged = true;
@@ -200,6 +210,10 @@ export function markAgentArchivedInHistoryCache(
agentHistoryQueryKey(input.serverId),
(current) => markAgentArchivedInHistoryPayload(current, input),
);
queryClient.setQueriesData<AgentHistoryQueryData | undefined>(
{ queryKey: allAgentHistoryQueryRootKey() },
(current) => markAgentArchivedInHistoryPayload(current, input),
);
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
@@ -218,6 +232,7 @@ interface ArchivedAgentListCacheSnapshot {
sidebarAgentsList: AgentsListQueryData | undefined;
allAgents: AgentsListQueryData | undefined;
agentHistory: AgentHistoryQueryData | undefined;
allAgentHistory: Array<[QueryKey, AgentHistoryQueryData | undefined]>;
}
interface ArchiveAgentMutationContext {
@@ -268,6 +283,9 @@ function getArchivedAgentListCacheSnapshot(
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
agentHistoryQueryKey(serverId),
),
allAgentHistory: queryClient.getQueriesData<AgentHistoryQueryData | undefined>({
queryKey: allAgentHistoryQueryRootKey(),
}),
};
}
@@ -295,6 +313,9 @@ function restoreArchivedAgentListCacheSnapshot(
);
restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
for (const [queryKey, querySnapshot] of snapshot.allAgentHistory) {
restoreCachedQuerySnapshot(queryClient, queryKey, querySnapshot);
}
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
@@ -360,6 +381,9 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
void input.queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void input.queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
}
}
@@ -449,6 +473,9 @@ export function useArchiveAgent() {
void queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
},
});

View File

@@ -4,14 +4,13 @@ import { router, usePathname, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -20,7 +19,6 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { isWeb } from "@/constants/platform";
const EMPTY_AGENTS: AggregatedAgent[] = [];
@@ -139,7 +137,6 @@ function resolveActionShortcutKeys(
export function useCommandCenter() {
const { t } = useTranslation();
const pathname = usePathname();
const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -153,11 +150,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = open ? routeActiveServerId : null;
const { agents } = useAllAgentsList({
serverId: activeServerId,
});
const { agents } = useAggregatedAgents();
const agentResults = useMemo(() => {
if (!open || agents.length === 0) {
@@ -172,10 +165,7 @@ export function useCommandCenter() {
return buildSettingsRoute();
}, []);
const homeRoute = useMemo<Href | undefined>(() => {
if (!routeActiveServerId) return undefined;
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
}, [routeActiveServerId]);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute() as Href, []);
const actionItems = useMemo(() => {
if (!open) {
@@ -239,7 +229,7 @@ export function useCommandCenter() {
[pathname, setOpen],
);
const openProjectPicker = useOpenProjectPicker(activeServerId);
const openProjectPicker = useOpenProjectPicker();
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {

View File

@@ -23,7 +23,6 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
import { isNative } from "@/constants/platform";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
type ActiveWorkspaceSelection,
navigateToLastWorkspace,
@@ -55,8 +54,7 @@ export function useKeyboardShortcuts({
step: 0,
timeoutId: null,
});
const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
const openProjectPickerAction = useOpenProjectPicker();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);

View File

@@ -1,30 +1,15 @@
import { useCallback } from "react";
import { pickDirectory } from "@/desktop/pick-directory";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useIsLocalDaemon } from "./use-is-local-daemon";
import { useOpenProject } from "./use-open-project";
import { useHostChooser } from "@/hosts/host-chooser";
import { useProjectPickerStore } from "@/stores/project-picker-store";
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
const normalizedServerId = serverId?.trim() ?? "";
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
const openProject = useOpenProject(serverId);
export function useOpenProjectPicker(): () => void {
const chooseHost = useHostChooser();
const openProjectPicker = useProjectPickerStore((state) => state.open);
return useCallback(async () => {
if (!normalizedServerId) {
return;
}
if (!isLocalDaemon) {
setProjectPickerOpen(true);
return;
}
const path = await pickDirectory();
if (path === null) {
return;
}
await openProject(path);
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
return useCallback(() => {
chooseHost({
title: "Choose host",
onChooseHost: openProjectPicker,
});
}, [chooseHost, openProjectPicker]);
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { openProjectDirectly } from "@/hooks/open-project";
import { getOpenProjectFailureReason, openProjectDirectly } from "@/hooks/open-project";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
@@ -132,3 +132,21 @@ describe("openProjectDirectly", () => {
expect(session.hydrated).toEqual([]);
});
});
describe("getOpenProjectFailureReason", () => {
it("keeps the known directory-not-found failure reason", () => {
expect(
getOpenProjectFailureReason({
ok: false,
errorCode: "directory_not_found",
error: "Directory not found: /missing",
}),
).toBe("directory_not_found");
});
it("uses the generic failure reason for untyped project-open failures", () => {
expect(getOpenProjectFailureReason({ ok: false, errorCode: null, error: "boom" })).toBe(
"open_failed",
);
});
});

View File

@@ -18,6 +18,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
serverId: "srv",
workspaceId,
projectKey,
projectName: projectKey,
projectRootPath: `/repo/${projectKey}`,
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
projectKind: "git",
@@ -43,7 +44,7 @@ function project(projectKey: string): SidebarProjectEntry {
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/repo/${projectKey}`,
canCreateWorktree: true,
hosts: [],
workspaces: [workspace(projectKey, `${projectKey}-main`)],
};
}

View File

@@ -1,22 +1,24 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo } from "react";
import equal from "fast-deep-equal";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarProjectsFromHostProjects,
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
} from "./sidebar-workspaces-view-model";
export {
@@ -24,131 +26,21 @@ export {
applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure,
buildSidebarStatusWorkspacePlacements,
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
type SidebarLoadingState,
type SidebarOrderUpdates,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspacePlacement,
type SidebarWorkspacePlacementModel,
type SidebarProjectEntry,
type SidebarStateBucket,
type SidebarWorkspaceEntry,
} from "./sidebar-workspaces-view-model";
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
if (!currentBranch) {
return null;
}
const trimmed = currentBranch.trim();
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
}
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): SidebarWorkspaceEntry {
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
title: input.workspace.title ?? null,
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
pendingCreateAttempts: input.pendingCreateAttempts,
});
if (pendingStartedAt) {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
function getPendingInitialAgentCreateStartedAt(input: {
serverId: string;
workspaceId: string;
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
}): Date | null {
let latestStartedAt: Date | null = null;
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
if (pending.serverId !== input.serverId) continue;
if (pending.workspaceId !== input.workspaceId) continue;
if (pending.lifecycle === "abandoned") continue;
const startedAt = new Date(pending.timestamp);
if (!latestStartedAt || startedAt > latestStartedAt) {
latestStartedAt = startedAt;
}
}
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents: Map<string, Agent> | undefined;
}): WorkspaceAgentActivity | null {
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
@@ -182,9 +74,13 @@ export function useSidebarWorkspaceEntry(
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
const EMPTY_PROJECT_NAMES = new Map<string, string>();
export interface SidebarWorkspacesListResult {
workspacePlacements: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
@@ -192,121 +88,122 @@ export interface SidebarWorkspacesListResult {
}
export function useSidebarWorkspacesList(options?: {
serverId?: string | null;
hostFilter?: string | null;
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
const allHosts = useHosts();
const hostRegistryLoaded = useHostRegistryLoaded();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const isActive = Boolean(serverId) && options?.enabled !== false;
const persistedProjectOrder = useSidebarOrderStore((state) =>
isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const hostProjects = useHostProjects(isActive ? serverId : null);
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilter = options?.hostFilter ?? storeHostFilter;
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
const effectiveHostFilter =
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
const isActive = options?.enabled !== false;
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
);
const projects = useMemo(() => {
if (!serverId || hostProjects.length === 0) {
return EMPTY_PROJECTS;
const serverIds = useMemo(() => {
if (effectiveHostFilter) {
return allServerIds.filter((id) => id === effectiveHostFilter);
}
return buildSidebarProjectsFromHostProjects({
projects: hostProjects,
});
}, [hostProjects, serverId]);
return allServerIds;
}, [allServerIds, effectiveHostFilter]);
useEffect(() => {
if (!serverId) {
if (!hostRegistryLoaded) {
return;
}
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
reconcileHostFilter(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useStoreWithEqualityFn(
useSessionStore,
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
shallow,
);
const hostProjects = useHostProjects(serverIds);
const sidebarModel = useMemo(
() =>
buildSidebarWorkspacePlacementModel({
projects: hostProjects,
}),
[hostProjects],
);
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
const workspacePlacements =
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
const projectNamesByKey =
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES;
useEffect(() => {
if (!serverId) {
return;
}
const orderStore = useSidebarOrderStore.getState();
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey),
getWorkspaceOrder: (projectKey) =>
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER,
});
if (updates.projectOrder) {
orderStore.setProjectOrder(serverId, updates.projectOrder);
orderStore.setProjectOrder(updates.projectOrder);
}
for (const { projectKey, order } of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(serverId, projectKey, order);
orderStore.setWorkspaceOrder(projectKey, order);
}
}, [persistedProjectOrder, projects, serverId]);
}, [persistedProjectOrder, projects]);
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== "online") {
return;
}
const client = runtime.getClient(serverId);
if (!client) {
return;
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
if (!isActive) return;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
if (snapshot?.connectionStatus !== "online") continue;
const client = runtime.getClient(serverId);
if (!client) continue;
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
next.set(workspace.id, workspace);
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
}, [connectionStatus, isActive, runtime, serverId]);
})();
}
}, [isActive, runtime, serverIds]);
const loadingState = deriveSidebarLoadingState({
isActive,
serverId,
hasHydratedWorkspaces,
serverIds,
hydratedServerIds,
hasProjects: projects.length > 0,
});
return {
workspacePlacements,
projects,
projectNamesByKey,
...loadingState,
refreshAll,
};

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
areStatusModeSessionsEqual,
selectStatusModeSessions,
type StatusModeSession,
} from "./use-status-mode-workspaces";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
function workspaceMap(): Map<string, WorkspaceDescriptor> {
return new Map();
}
function agentMap(): Map<string, Agent> {
return new Map();
}
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
return {
workspaces: input?.workspaces ?? workspaceMap(),
agents: input?.agents ?? agentMap(),
};
}
describe("status mode session selection", () => {
it("selects only sessions needed by visible placements", () => {
const hostA = statusSession();
const hostB = statusSession();
const unusedHost = statusSession();
expect(
selectStatusModeSessions(
{
"host-a": hostA,
"host-b": hostB,
unused: unusedHost,
},
["host-b", "missing", "host-a"],
),
).toEqual([
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
]);
});
it("keeps selector output equal when only wrapper objects change", () => {
const workspaces = workspaceMap();
const agents = agentMap();
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
expect(previous).not.toBe(next);
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
});
it("detects workspace or agent map changes for selected hosts", () => {
const agents = agentMap();
const previous = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
["host-a"],
);
const next = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
["host-a"],
);
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
});
});

View File

@@ -1,63 +1,100 @@
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
buildSidebarStatusWorkspacePlacements,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspacePlacement,
} from "./use-sidebar-workspaces-list";
const EMPTY_WORKSPACES: SidebarWorkspaceEntry[] = [];
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
typeof useCreateFlowStore.getState
>["pendingByDraftId"] = {};
export function useStatusModeWorkspaceEntries(input: {
serverId: string | null;
projects: SidebarProjectEntry[];
}): SidebarWorkspaceEntry[] {
const workspaces = useSessionStore((state) =>
input.serverId ? state.sessions[input.serverId]?.workspaces : undefined,
interface StatusModeSessionSource {
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
}
export interface StatusModeSession {
serverId: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
}
export function selectStatusModeSessions(
sessions: Record<string, StatusModeSessionSource | undefined>,
serverIds: readonly string[],
): StatusModeSession[] {
const statusSessions: StatusModeSession[] = [];
for (const serverId of serverIds) {
const session = sessions[serverId];
if (!session) {
continue;
}
statusSessions.push({
serverId,
workspaces: session.workspaces,
agents: session.agents,
});
}
return statusSessions;
}
export function areStatusModeSessionsEqual(
left: readonly StatusModeSession[],
right: readonly StatusModeSession[],
): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
const leftSession = left[index];
const rightSession = right[index];
if (
!leftSession ||
!rightSession ||
leftSession.serverId !== rightSession.serverId ||
leftSession.workspaces !== rightSession.workspaces ||
leftSession.agents !== rightSession.agents
) {
return false;
}
}
return true;
}
export function useStatusModeWorkspacePlacements(input: {
placements: SidebarWorkspacePlacement[];
enabled?: boolean;
}): SidebarStatusWorkspacePlacement[] {
const isEnabled = input.enabled !== false && input.placements.length > 0;
const serverIds = useMemo(
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
[input.placements],
);
const agents = useSessionStore((state) =>
input.serverId ? state.sessions[input.serverId]?.agents : undefined,
const statusSessions = useStoreWithEqualityFn(
useSessionStore,
(state) =>
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
areStatusModeSessionsEqual,
);
const pendingCreateAttempts = useCreateFlowStore((state) =>
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
return useMemo(() => {
if (!input.serverId || input.projects.length === 0 || !workspaces) {
if (!isEnabled) {
return EMPTY_WORKSPACES;
}
const entries: SidebarWorkspaceEntry[] = [];
for (const placedWorkspace of input.projects.flatMap((project) => project.workspaces)) {
const workspace = workspaces.get(placedWorkspace.workspaceId);
entries.push(
workspace
? createSidebarWorkspaceEntry({
serverId: input.serverId,
workspace,
pendingCreateAttempts,
agents,
})
: placedWorkspace,
);
}
return entries;
}, [agents, input.projects, input.serverId, pendingCreateAttempts, workspaces]);
}
export function useProjectNamesMap(serverId: string | null): Map<string, string> {
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
return useMemo(() => {
const map = new Map<string, string>();
if (!serverId || !workspaces) return map;
for (const workspace of workspaces.values()) {
const key = workspace.project?.projectKey ?? workspace.projectId;
if (!map.has(key)) {
map.set(key, workspace.projectCustomName ?? workspace.projectDisplayName);
}
}
return map;
}, [serverId, workspaces]);
return buildSidebarStatusWorkspacePlacements({
placements: input.placements,
sessions: statusSessions,
pendingCreateAttempts,
});
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
}

View File

@@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { isNative } from "@/constants/platform";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
export interface ChooseHostInput {
title?: string;
filter?: HostFilter;
onChooseHost: HostChoiceHandler;
onNoHosts?: () => void;
}
interface HostChoiceRequest {
id: number;
title: string;
serverIds: string[];
onChooseHost: HostChoiceHandler;
}
interface HostChooserState {
request: HostChoiceRequest | null;
open: (request: Omit<HostChoiceRequest, "id">) => void;
close: () => void;
}
let nextRequestId = 1;
const useHostChooserStore = create<HostChooserState>((set) => ({
request: null,
open: (request) => {
set({ request: { ...request, id: nextRequestId++ } });
},
close: () => set({ request: null }),
}));
function matchesHostQuery(host: HostProfile, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return (
host.label.toLowerCase().includes(normalized) ||
host.serverId.toLowerCase().includes(normalized)
);
}
export function useHostChooser() {
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
return useCallback(
(input: ChooseHostInput) => {
const availableHosts = orderHostsLocalFirst(hosts, localServerId).filter(
input.filter ?? (() => true),
);
if (availableHosts.length === 0) {
(input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))();
return false;
}
if (availableHosts.length === 1) {
void input.onChooseHost(availableHosts[0].serverId);
return true;
}
open({
title: input.title ?? "Choose host",
serverIds: availableHosts.map((host) => host.serverId),
onChooseHost: input.onChooseHost,
});
return true;
},
[hosts, localServerId, open],
);
}
function HostChooserRow({
host,
active,
onChooseHost,
}: {
host: HostProfile;
active: boolean;
onChooseHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => onChooseHost(host.serverId), [host.serverId, onChooseHost]);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(active || hovered || pressed) && styles.rowActive,
],
[active],
);
return (
<Pressable
accessibilityRole="button"
onPress={handlePress}
style={rowStyle}
testID={`host-chooser-row-${host.serverId}`}
>
<View style={styles.rowIconSlot}>
<HostStatusDotSlot serverId={host.serverId} />
</View>
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{host.label}
</Text>
<Text style={styles.rowSubtitle} numberOfLines={1}>
{host.serverId}
</Text>
</View>
<Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
export function HostChooserModal() {
const { theme } = useUnistyles();
const hosts = useHosts();
const request = useHostChooserStore((state) => state.request);
const close = useHostChooserStore((state) => state.close);
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const requestHosts = useMemo(() => {
if (!request) return [];
const hostByServerId = new Map(hosts.map((host) => [host.serverId, host] as const));
return request.serverIds.flatMap((serverId) => {
const host = hostByServerId.get(serverId);
return host ? [host] : [];
});
}, [hosts, request]);
const options = useMemo(
() => requestHosts.filter((host) => matchesHostQuery(host, query)),
[query, requestHosts],
);
const activeOptionIndex = options.length === 0 ? 0 : Math.min(activeIndex, options.length - 1);
useEffect(() => {
if (!request) return;
setQuery("");
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [request]);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(0);
}, []);
const chooseHost = useCallback(
(serverId: string) => {
const currentRequest = request;
close();
if (!currentRequest) return;
void currentRequest.onChooseHost(serverId);
},
[close, request],
);
useEffect(() => {
if (!request || isNative || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key === "Enter") {
const host = options[activeOptionIndex];
if (!host) return;
event.preventDefault();
chooseHost(host.serverId);
return;
}
if (options.length === 0) return;
event.preventDefault();
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
if (next < 0) {
setActiveIndex(options.length - 1);
return;
}
if (next >= options.length) {
setActiveIndex(0);
return;
}
setActiveIndex(next);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [activeOptionIndex, chooseHost, close, options, request]);
if (!request) return null;
return (
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={close} />
<View style={styles.panel}>
<View style={styles.header}>
<Text style={styles.title}>{request.title}</Text>
<TextInput
ref={inputRef}
value={query}
onChangeText={handleQueryChange}
placeholder="Search hosts..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
testID="host-chooser-search"
/>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
{options.map((host, index) => (
<HostChooserRow
key={host.serverId}
host={host}
active={index === activeOptionIndex}
onChooseHost={chooseHost}
/>
))}
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
width: 640,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
input: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
padding: 0,
outlineStyle: "none",
} as object,
results: {
maxHeight: 420,
},
resultsContent: {
paddingVertical: theme.spacing[2],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
minHeight: 56,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
rowActive: {
backgroundColor: theme.colors.surface1,
},
rowIconSlot: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
rowText: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
}));

View File

@@ -5,6 +5,8 @@ import { ar } from "./resources/ar";
import { en } from "./resources/en";
import { es } from "./resources/es";
import { fr } from "./resources/fr";
import { ja } from "./resources/ja";
import { ptBR } from "./resources/pt-BR";
import { ru } from "./resources/ru";
import { zhCN } from "./resources/zh-CN";
@@ -20,6 +22,8 @@ observeI18nInit(
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
ja: { translation: ja },
"pt-BR": { translation: ptBR },
ru: { translation: ru },
"zh-CN": { translation: zhCN },
},

View File

@@ -7,16 +7,10 @@ import {
} from "./locales";
describe("parseAppLanguage", () => {
it("accepts system and all UN official language locales", () => {
expect(["system", "ar", "en", "es", "fr", "ru", "zh-CN"].map(parseAppLanguage)).toEqual([
"system",
"ar",
"en",
"es",
"fr",
"ru",
"zh-CN",
]);
it("accepts system and all supported language locales", () => {
expect(
["system", "ar", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"].map(parseAppLanguage),
).toEqual(["system", "ar", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"]);
});
it("returns null for unknown values", () => {
@@ -24,13 +18,15 @@ describe("parseAppLanguage", () => {
expect(parseAppLanguage(null)).toBeNull();
});
it("offers system plus the six UN official languages", () => {
it("offers system plus all supported languages", () => {
expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual([
"system",
"ar",
"en",
"es",
"fr",
"ja",
"pt-BR",
"ru",
"zh-CN",
]);
@@ -40,14 +36,24 @@ describe("parseAppLanguage", () => {
describe("formatLanguageOptionLabel", () => {
it("shows the native language name and English name in English UI", () => {
const arabic = LANGUAGE_OPTIONS.find((option) => option.value === "ar");
const japanese = LANGUAGE_OPTIONS.find((option) => option.value === "ja");
const portuguese = LANGUAGE_OPTIONS.find((option) => option.value === "pt-BR");
const spanish = LANGUAGE_OPTIONS.find((option) => option.value === "es");
const chinese = LANGUAGE_OPTIONS.find((option) => option.value === "zh-CN");
expect([
formatLanguageOptionLabel(arabic!, "en", "System"),
formatLanguageOptionLabel(japanese!, "en", "System"),
formatLanguageOptionLabel(portuguese!, "en", "System"),
formatLanguageOptionLabel(spanish!, "en", "System"),
formatLanguageOptionLabel(chinese!, "en", "System"),
]).toEqual(["العربية - Arabic", "Español - Spanish", "简体中文 - Simplified Chinese"]);
]).toEqual([
"العربية - Arabic",
"日本語 - Japanese",
"Português brasileiro - Brazilian Portuguese",
"Español - Spanish",
"简体中文 - Simplified Chinese",
]);
});
it("shows the native language name and Chinese name in Chinese UI", () => {
@@ -64,8 +70,12 @@ describe("formatLanguageOptionLabel", () => {
it("uses a single label when both language names match", () => {
const english = LANGUAGE_OPTIONS.find((option) => option.value === "en");
const japanese = LANGUAGE_OPTIONS.find((option) => option.value === "ja");
const portuguese = LANGUAGE_OPTIONS.find((option) => option.value === "pt-BR");
expect(formatLanguageOptionLabel(english!, "en", "System")).toBe("English");
expect(formatLanguageOptionLabel(japanese!, "ja", "システム")).toBe("日本語");
expect(formatLanguageOptionLabel(portuguese!, "pt-BR", "Sistema")).toBe("Português brasileiro");
});
it("uses the active-language name for System", () => {
@@ -81,18 +91,28 @@ describe("resolveSupportedLocale", () => {
expect(resolveSupportedLocale("en", ["zh-CN"])).toBe("en");
expect(resolveSupportedLocale("es", ["en-US"])).toBe("es");
expect(resolveSupportedLocale("fr", ["en-US"])).toBe("fr");
expect(resolveSupportedLocale("ja", ["en-US"])).toBe("ja");
expect(resolveSupportedLocale("pt-BR", ["en-US"])).toBe("pt-BR");
expect(resolveSupportedLocale("ru", ["en-US"])).toBe("ru");
expect(resolveSupportedLocale("zh-CN", ["en-US"])).toBe("zh-CN");
});
it("maps UN official system locales", () => {
it("maps supported system locales", () => {
expect(resolveSupportedLocale("system", ["ar-EG"])).toBe("ar");
expect(resolveSupportedLocale("system", ["en-US"])).toBe("en");
expect(resolveSupportedLocale("system", ["es-MX"])).toBe("es");
expect(resolveSupportedLocale("system", ["fr-CA"])).toBe("fr");
expect(resolveSupportedLocale("system", ["ja-JP"])).toBe("ja");
expect(resolveSupportedLocale("system", ["pt-BR"])).toBe("pt-BR");
expect(resolveSupportedLocale("system", ["pt"])).toBe("pt-BR");
expect(resolveSupportedLocale("system", ["ru-RU"])).toBe("ru");
});
it("does not map non-Brazilian Portuguese system locales to Brazilian Portuguese", () => {
expect(resolveSupportedLocale("system", ["pt-PT"])).toBe("en");
expect(resolveSupportedLocale("system", ["pt-AO"])).toBe("en");
});
it("keeps English when Spanish is a secondary system language", () => {
expect(resolveSupportedLocale("system", ["en-US", "es-GB"])).toBe("en");
});

View File

@@ -1,4 +1,4 @@
export type SupportedLocale = "ar" | "en" | "es" | "fr" | "ru" | "zh-CN";
export type SupportedLocale = "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN";
export type AppLanguage = "system" | SupportedLocale;
export interface LanguageOption {
@@ -14,16 +14,30 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [
{ value: "en", labelKey: "settings.general.language.options.en" },
{ value: "es", labelKey: "settings.general.language.options.es" },
{ value: "fr", labelKey: "settings.general.language.options.fr" },
{ value: "ja", labelKey: "settings.general.language.options.ja" },
{ value: "pt-BR", labelKey: "settings.general.language.options.ptBR" },
{ value: "ru", labelKey: "settings.general.language.options.ru" },
{ value: "zh-CN", labelKey: "settings.general.language.options.zhCN" },
];
const SUPPORTED_LANGUAGES = new Set<AppLanguage>(["system", "ar", "en", "es", "fr", "ru", "zh-CN"]);
const SUPPORTED_LANGUAGES = new Set<AppLanguage>([
"system",
"ar",
"en",
"es",
"fr",
"ja",
"pt-BR",
"ru",
"zh-CN",
]);
const LANGUAGE_NATIVE_NAMES: Record<SupportedLocale, string> = {
ar: "العربية",
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
"pt-BR": "Português brasileiro",
ru: "Русский",
"zh-CN": "简体中文",
};
@@ -33,6 +47,8 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
en: "الإنجليزية",
es: "الإسبانية",
fr: "الفرنسية",
ja: "اليابانية",
"pt-BR": "البرتغالية البرازيلية",
ru: "الروسية",
"zh-CN": "الصينية المبسطة",
},
@@ -41,6 +57,8 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
en: "English",
es: "Spanish",
fr: "French",
ja: "Japanese",
"pt-BR": "Brazilian Portuguese",
ru: "Russian",
"zh-CN": "Simplified Chinese",
},
@@ -49,6 +67,8 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
en: "inglés",
es: "español",
fr: "francés",
ja: "japonés",
"pt-BR": "portugués brasileño",
ru: "ruso",
"zh-CN": "chino simplificado",
},
@@ -57,14 +77,38 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
en: "anglais",
es: "espagnol",
fr: "français",
ja: "japonais",
"pt-BR": "portugais brésilien",
ru: "russe",
"zh-CN": "chinois simplifié",
},
ja: {
ar: "アラビア語",
en: "英語",
es: "スペイン語",
fr: "フランス語",
ja: "日本語",
"pt-BR": "ブラジルポルトガル語",
ru: "ロシア語",
"zh-CN": "簡体字中国語",
},
"pt-BR": {
ar: "árabe",
en: "inglês",
es: "espanhol",
fr: "francês",
ja: "japonês",
"pt-BR": "Português brasileiro",
ru: "russo",
"zh-CN": "chinês simplificado",
},
ru: {
ar: "арабский",
en: "английский",
es: "испанский",
fr: "французский",
ja: "японский",
"pt-BR": "бразильский португальский",
ru: "русский",
"zh-CN": "упрощенный китайский",
},
@@ -73,6 +117,8 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
en: "英语",
es: "西班牙语",
fr: "法语",
ja: "日语",
"pt-BR": "巴西葡萄牙语",
ru: "俄语",
"zh-CN": "简体中文",
},
@@ -124,6 +170,12 @@ export function resolveSupportedLocale(
if (normalized === "fr" || normalized.startsWith("fr-")) {
return "fr";
}
if (normalized === "ja" || normalized.startsWith("ja-")) {
return "ja";
}
if (normalized === "pt" || normalized === "pt-br") {
return "pt-BR";
}
if (normalized === "ru" || normalized.startsWith("ru-")) {
return "ru";
}

View File

@@ -5,6 +5,8 @@ import { ar } from "./resources/ar";
import { en } from "./resources/en";
import { es } from "./resources/es";
import { fr } from "./resources/fr";
import { ja } from "./resources/ja";
import { ptBR } from "./resources/pt-BR";
import { ru } from "./resources/ru";
import { zhCN } from "./resources/zh-CN";
@@ -101,28 +103,35 @@ function findUntranslatedConnectionErrors(): string[] {
}
describe("translation resources", () => {
it("keeps UN official language keys in sync with English", () => {
it("keeps all supported language keys in sync with English", () => {
const englishKeys = flattenKeys(en).sort();
expect(flattenKeys(ar).sort()).toEqual(englishKeys);
expect(flattenKeys(es).sort()).toEqual(englishKeys);
expect(flattenKeys(fr).sort()).toEqual(englishKeys);
expect(flattenKeys(ja).sort()).toEqual(englishKeys);
expect(flattenKeys(ptBR).sort()).toEqual(englishKeys);
expect(flattenKeys(ru).sort()).toEqual(englishKeys);
expect(flattenKeys(zhCN).sort()).toEqual(englishKeys);
});
it("keeps non-English UN official languages translated beyond fallback labels", () => {
it("keeps non-English supported languages translated beyond fallback labels", () => {
const totalStrings = Object.keys(flattenStrings(en)).length;
const maxFallbackStrings = Math.floor(totalStrings * 0.25);
expect(countMatchingEnglishStrings(ar)).toBeLessThan(maxFallbackStrings);
expect(countMatchingEnglishStrings(es)).toBeLessThan(maxFallbackStrings);
expect(countMatchingEnglishStrings(fr)).toBeLessThan(maxFallbackStrings);
expect(countMatchingEnglishStrings(ja)).toBeLessThan(maxFallbackStrings);
expect(countMatchingEnglishStrings(ptBR)).toBeLessThan(maxFallbackStrings);
expect(countMatchingEnglishStrings(ru)).toBeLessThan(maxFallbackStrings);
expect(countMatchingEnglishStrings(zhCN)).toBeLessThan(maxFallbackStrings);
});
it("preserves interpolation placeholders in every language", () => {
expect(findInterpolationMismatches(ar)).toEqual([]);
expect(findInterpolationMismatches(es)).toEqual([]);
expect(findInterpolationMismatches(fr)).toEqual([]);
expect(findInterpolationMismatches(ja)).toEqual([]);
expect(findInterpolationMismatches(ptBR)).toEqual([]);
expect(findInterpolationMismatches(ru)).toEqual([]);
expect(findInterpolationMismatches(zhCN)).toEqual([]);
});
@@ -137,11 +146,15 @@ describe("translation resources", () => {
expect(ar.modelSelector.modelCountPlural).toBe("{{count}} نماذج");
expect(es.modelSelector.modelCountPlural).toBe("{{count}} modelos");
expect(fr.modelSelector.modelCountPlural).toBe("{{count}} modèles");
expect(ja.modelSelector.modelCountPlural).toBe("{{count}}つのモデル");
expect(ptBR.modelSelector.modelCountPlural).toBe("{{count}} modelos");
expect(ru.modelSelector.modelCountPlural).toBe("{{count}} моделей");
expect(zhCN.modelSelector.modelCountPlural).toBe("{{count}} 个模型");
expect(ar.settings.providers.models.many).toBe("{{count}} نماذج");
expect(es.settings.providers.models.many).toBe("{{count}} modelos");
expect(fr.settings.providers.models.many).toBe("{{count}} modèles");
expect(ja.settings.providers.models.many).toBe("{{count}}つのモデル");
expect(ptBR.settings.providers.models.many).toBe("{{count}} modelos");
expect(ru.settings.providers.models.many).toBe("{{count}} моделей");
expect(zhCN.settings.providers.models.many).toBe("{{count}} 个 Model");
});

View File

@@ -1438,6 +1438,8 @@ export const ar: TranslationResources = {
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
ptBR: "Português brasileiro",
ru: "Русский",
zhCN: "中文",
},
@@ -1449,6 +1451,22 @@ export const ar: TranslationResources = {
playTest: "لعب الاختبار",
playing: "جارٍ اللعب...",
playbackFailed: "فشل التشغيل:{{message}}",
app: {
title: "App diagnostic",
rowTitle: "App diagnostic",
rowHint: "Collect connection, daemon, provider, desktop, and log details",
run: "Run",
running: "Running diagnostic...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
progress: {
client: "Client",
desktop: "Desktop",
},
},
},
about: {
title: "عن",
@@ -1727,6 +1745,30 @@ export const ar: TranslationResources = {
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "منطقة الخطر",
remove: {
title: "إزالة المضيف",

View File

@@ -1444,6 +1444,8 @@ export const en = {
en: "English",
es: "Spanish",
fr: "French",
ja: "Japanese",
ptBR: "Brazilian Portuguese",
ru: "Russian",
zhCN: "Simplified Chinese",
},
@@ -1455,6 +1457,22 @@ export const en = {
playTest: "Play test",
playing: "Playing...",
playbackFailed: "Playback failed: {{message}}",
app: {
title: "App diagnostic",
rowTitle: "App diagnostic",
rowHint: "Collect connection, daemon, provider, desktop, and log details",
run: "Run",
running: "Running diagnostic...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
progress: {
client: "Client",
desktop: "Desktop",
},
},
},
about: {
title: "About",
@@ -1735,6 +1753,30 @@ export const en = {
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Danger zone",
remove: {
title: "Remove host",

View File

@@ -1475,6 +1475,8 @@ export const es: TranslationResources = {
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
ptBR: "Português brasileiro",
ru: "Русский",
zhCN: "中文",
},
@@ -1486,6 +1488,22 @@ export const es: TranslationResources = {
playTest: "Prueba de juego",
playing: "Jugando...",
playbackFailed: "Error de reproducción:{{message}}",
app: {
title: "App diagnostic",
rowTitle: "App diagnostic",
rowHint: "Collect connection, daemon, provider, desktop, and log details",
run: "Run",
running: "Running diagnostic...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
progress: {
client: "Client",
desktop: "Desktop",
},
},
},
about: {
title: "Acerca de",
@@ -1768,6 +1786,30 @@ export const es: TranslationResources = {
dialogFailedMessage:
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zona de peligro",
remove: {
title: "Eliminar host",

View File

@@ -1478,6 +1478,8 @@ export const fr: TranslationResources = {
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
ptBR: "Português brasileiro",
ru: "Русский",
zhCN: "中文",
},
@@ -1489,6 +1491,22 @@ export const fr: TranslationResources = {
playTest: "Jouer à l'essai",
playing: "Jouant...",
playbackFailed: "Échec de la lecture:{{message}}",
app: {
title: "App diagnostic",
rowTitle: "App diagnostic",
rowHint: "Collect connection, daemon, provider, desktop, and log details",
run: "Run",
running: "Running diagnostic...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
progress: {
client: "Client",
desktop: "Desktop",
},
},
},
about: {
title: "À propos",
@@ -1773,6 +1791,30 @@ export const fr: TranslationResources = {
dialogFailedMessage:
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zone dangereuse",
remove: {
title: "Supprimer l'hôte",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1465,6 +1465,8 @@ export const ru: TranslationResources = {
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
ptBR: "Português brasileiro",
ru: "Русский",
zhCN: "中文",
},
@@ -1476,6 +1478,22 @@ export const ru: TranslationResources = {
playTest: "Игровой тест",
playing: "Игра...",
playbackFailed: "Ошибка воспроизведения:{{message}}",
app: {
title: "App diagnostic",
rowTitle: "App diagnostic",
rowHint: "Collect connection, daemon, provider, desktop, and log details",
run: "Run",
running: "Running diagnostic...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
progress: {
client: "Client",
desktop: "Desktop",
},
},
},
about: {
title: "О",
@@ -1759,6 +1777,30 @@ export const ru: TranslationResources = {
"Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.",
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Опасная зона",
remove: {
title: "Удалить хост",

View File

@@ -1420,6 +1420,8 @@ export const zhCN: TranslationResources = {
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
ptBR: "Português brasileiro",
ru: "Русский",
zhCN: "简体中文",
},
@@ -1431,6 +1433,22 @@ export const zhCN: TranslationResources = {
playTest: "播放测试",
playing: "正在播放...",
playbackFailed: "播放失败:{{message}}",
app: {
title: "App diagnostic",
rowTitle: "App diagnostic",
rowHint: "Collect connection, daemon, provider, desktop, and log details",
run: "Run",
running: "Running diagnostic...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
progress: {
client: "Client",
desktop: "Desktop",
},
},
},
about: {
title: "关于",
@@ -1704,6 +1722,30 @@ export const zhCN: TranslationResources = {
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
dialogFailedMessage: "无法打开重启确认对话框。",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "危险区域",
remove: {
title: "移除 Host",

View File

@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveHostIndexRoute,
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRoute,
@@ -339,14 +338,14 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "render" });
});
it("sends removed host routes to a saved host instead of welcome", () => {
it("sends removed host routes to global project selection instead of welcome", () => {
expect(
resolveStartupRoute({
...baseHostInput,
route: { kind: "host", serverId: "server-removed" },
hosts: [{ serverId: "server-next" }],
}),
).toEqual({ kind: "redirect", href: "/h/server-next/open-project" });
).toEqual({ kind: "redirect", href: "/open-project" });
});
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
@@ -358,45 +357,3 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "redirect", href: "/welcome" });
});
});
describe("resolveHostIndexRoute", () => {
it("restores the remembered workspace when the host index is reopened for the same host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "unknown",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("opens project selection when the remembered workspace is proven missing", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual("/h/server-saved/open-project");
});
it("opens project selection when the remembered workspace belongs to another host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/open-project");
});
});

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