Compare commits

...
35 Commits
Author SHA1 Message Date
Mohamed Boudra 2f913736fa chore(release): cut 0.2.4 2026-07-30 01:48:57 +02:00
Mohamed Boudra 8f8a373eea docs(changelog): 0.2.4 2026-07-30 01:45:18 +02:00
Mohamed Boudra c6df5ffd4d Revert "Carry local files and shared state into new worktrees (#2419)"
This reverts commit cbbf6c1684.
2026-07-30 00:37:52 +02:00
Mohamed Boudra 20e46e3e3f Restore project grouping across hosts (#2565)
* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
2026-07-29 23:36:31 +02:00
nikuscsandMohamed Boudra 9e5accee3c fix: reject oversized checkout diffs (#2488)
* fix: reject oversized checkout diffs

* fix(diff): keep large previews from disconnecting clients

The physical socket limit is an OOM backstop, not a frame-size contract. Bound structured diff construction incrementally and report oversized previews without closing the client's socket.

* fix(app): preserve oversized diff state

* fix(diff): stay within relay frame limit

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-30 00:48:05 +08:00
Mohamed Boudra 16bbead5a4 Stop Windows server CI from flaking on stderr timeouts (#2613)
* test(server): make stderr timeout coverage deterministic

The timeout assertion raced Windows child-process scheduling and stderr pipe delivery. Buffer stderr before starting the timed request so the test isolates the transport behavior it intends to verify.

* test(server): synchronize stderr timeout test in parent

Use the injected process adapter to populate stderr through the parent-side stream before starting the timeout. This removes cross-pipe ordering from the test.
2026-07-29 18:11:47 +02:00
ee3a9b013b Support repository search on older GitHub CLI versions (#2611)
* fix(github): support repository lists from older gh versions

GitHub CLI versions before 2.28 reject the visibility JSON field before returning repository data. Retry that specific failure with the older isPrivate field while preserving exact visibility on newer clients.

* fix(github): stop requesting repository visibility

Repository cloning does not use visibility. Leave the released response field optional and keep repository discovery on JSON fields supported by older GitHub CLI versions.

* fix(github): keep the visibility enum on the wire, derive it from isPrivate

Restores the required visibility enum in the search-repositories response
schema. Omitting it broke old clients, which reject the whole response.

The daemon still never asks gh for visibility -- that JSON field does not
exist before gh 2.28 and Debian Bookworm ships 2.23. It asks for isPrivate,
supported on every version, and maps it to public/private.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:05:59 +02:00
Mohamed Boudra 891dd5a813 feat(providers): expose agent cwd to provider processes (#2563) 2026-07-29 16:42:42 +02:00
Mohamed Boudra 68a215d40c Prevent restored file trees from crashing desktop startup (#2595)
* fix(explorer): prevent persisted trees from crashing startup

Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack.

* fix(explorer): preserve expansion changes during restore

Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten.

* fix(explorer): show hidden files before restoration

* fix(explorer): apply directory toggles atomically
2026-07-29 22:10:51 +08:00
Slava Goltserandpaseo-bot[bot] b897c59e9c fix: preserve context window data when turn_completed lacks usage (#2494)
* fix: preserve context window data when turn_completed lacks usage

Merge incoming usage into agent.lastUsage instead of replacing it
wholesale. Some providers (Claude autonomous turns, OpenCode) emit
turn_completed without a usage payload, which previously wiped the
contextWindowMaxTokens/contextWindowUsedTokens accumulated during
streaming usage_updated events. The result was the context window
meter disappearing from the composer once the agent finished work.

* test(server): preserve usage after autonomous completion

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-07-29 22:10:18 +08:00
Mohamed Boudra acb8506d1f Revert the timeline optimistic and pagination rework (#2596)
* Add npm release track invariant and beta dist-tag procedure

* Revert "Keep older chat history and image previews stable (#2490)"

The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework.

* Revert "Stop completed turns from appearing stuck (#2484)"

See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
2026-07-29 13:35:43 +02:00
Matt Cowger ab24070075 fix(server): suppress Pi interruption stream error (#2311)
* Handle Pi aborted responses after interruption

* fix(server): suppress late Pi interruption terminal response
2026-07-29 18:41:09 +08:00
Mohamed Boudra fab975a059 Keep idle agents and their background work alive (#2590)
* fix(server): keep idle agents resident

Remove time-based runtime collection so background work and subsequent prompts are not destroyed during idle periods. Runtimes now close only through explicit lifecycle actions.

* fix(server): preserve explicit close coordination

* fix(server): support partial agent manager adapters
2026-07-29 18:36:44 +08:00
Mohamed Boudra 504b687f89 Keep older chat history and image previews stable (#2490)
* fix(app): keep timeline history and previews stable

Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.

Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.

* fix(app): keep file image previews current

* fix(app): stabilize history pagination edges

* fix(app): recover transient timeline loads

* fix(app): retain assistant image previews

* Stabilize history pagination lifecycle

* Stabilize history settlement and image previews

* Retry file images after reconnect

* Keep active previews and pagination requests alive

* Replace image hook tests with typed ports

* Integrate timeline hydration with submission authority

* Harden pre-hydration timeline reconciliation

* Make rewind E2E use real scroll intent

* Preserve live timeline state through hydration

* Keep mounted image attachments retained

* Protect preview persistence and lifecycle hydration

* Preserve idless live assistant continuations

* Preserve timeline rows across delayed hydration

* test(app): cover real assistant image files

* Preserve assistant tool ordering during hydration
2026-07-28 22:29:03 +02:00
Mohamed Boudra bbf3d0f9cc Keep plan approval focused on the latest proposal (#2534)
* fix(server): replace stale plan approvals

Synthetic plan approvals accumulated across planning turns because each proposal used a new permission ID. Dismiss the current proposal only after a follow-up prompt is accepted, and enforce one pending proposal when a newer plan arrives.

* fix(server): close plan approval submission race

Deactivate the existing proposal before starting a revision so it cannot be implemented concurrently. Restore it when submission fails, while preserving any newer approval emitted during the request.

* fix(server): deactivate plans before prompt setup

Start the plan-dismissal transaction before any asynchronous prompt setup so a stale proposal cannot be implemented from another client while the revision is preparing.

* fix(server): make prompt dismissal final

Treat prompt submission as the dismissal event instead of compensating after failures. This avoids resurrecting stale plans across ambiguous provider outcomes and manager failure cleanup.
2026-07-28 17:10:32 +02:00
Mohamed Boudra 963d4f9240 Configure agent thinking from the CLI (#2533)
* feat(cli): update agent thinking from the CLI

* fix(cli): harden agent thinking updates

* feat(cli): configure thinking for schedules

* fix(cli): report applied thinking updates

* fix(cli): report current agent thinking state
2026-07-28 22:30:50 +08:00
nllptrxandMohamed Boudra e241e02afb feat(app): dismiss the chat keyboard on a fast upward flick (#2417)
* feat(app): dismiss the chat keyboard on a fast upward flick

Scrolling the history to read earlier messages left the keyboard up, so the
visible transcript stayed cramped and the keyboard had to be closed by hand
first — two steps for what should be one.

React Native's keyboardDismissMode cannot express the wanted behaviour:
"on-drag" fires on the first pixel and kills the keyboard on any peek-scroll,
and "interactive" is broken on inverted lists. So the gesture is measured
here: samples are taken from the scroll events and, at release, the speed over
the drag's final stretch decides. Measuring at release rather than averaging
the whole drag is what keeps a fast but controlled read-scroll from counting
as a flick, since such a gesture decelerates before the finger lifts.

Timestamps and offsets come from the events, never from a clock: with a busy
JS thread the callbacks arrive in a burst long after the gesture, and
wall-clock spacing then reads a calm scroll as a flick. The release offset
comes from the end-drag event for the same reason — the last onScroll can be
stale by the time a short flick lands.

Android needs both the blur and the dismiss. Dismissing alone leaves the
input focused and the keyboard inset applied, so the layout stays shifted
with an empty gap where the keyboard was; blurring alone releases focus but
leaves the IME on screen.

Verified on an Android device with a Release build: a slow drag and a
0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the
composer settles back with no leftover gap. iOS was exercised by hand on
device only, without automated coverage of the gesture itself.

* refactor(app): isolate keyboard flick dismissal

* fix(app): isolate keyboard shift context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 22:29:33 +08:00
Mohamed Boudra fdee3236f7 Speed up server CI tests (#2537)
* perf(ci): reduce server test latency

Server test files use isolated resources, so they do not require suite-wide serialization.

* fix(ci): scope server test parallelism to unit suite

Keep real-provider and local-resource suites serialized because they may share user configuration and account limits.

* fix(terminal): isolate zsh runtimes by process

Prevent concurrent daemon and test processes from deleting or replacing shell integration files used by another process.

* fix(ci): preserve required matrix check names

GitHub evaluates job conditions before expanding a matrix. Expand active matrices first and gate their expensive steps so required check contexts are always reported. Allow superseded runs to cancel while retaining fail-open path detection.
2026-07-28 21:51:12 +08:00
stonegray fd7061a8b2 Fix AppImage launches from Linux desktops (#2439) 2026-07-28 13:59:26 +02:00
Mohamed Boudra 76e336a1be Run only relevant CI checks for each pull request (#2500)
* perf(ci): skip unaffected test jobs

Keep required checks present as skipped jobs and run the full matrix whenever change detection cannot produce a trustworthy result.

* fix(ci): harden change-based job gating

Include shared build inputs in packaged desktop smoke selection and pin the path filter action that controls job execution.

* fix(ci): run CLI checks for Nix packaging changes

The CLI supervision regression suite reads the Nix package definition directly, so include that external dependency in its path filter.

* fix(ci): track external test inputs

Select server and CLI suites when their narrowly scoped cross-package fixtures and source assertions change.

* fix(ci): track server test CLI imports

Run server tests for CLI source changes because the Hub relationship harness executes the CLI command graph directly.

* fix(ci): pin gating checkout action

Keep every third-party action that controls change detection pinned to a verified commit SHA.
2026-07-28 19:18:56 +08:00
Jason@HND f0d7eeb98c fix(quota): restore Grok Settings usage for current CLI auth/billing (#2353)
* fix(quota): restore Grok Settings usage for current CLI auth/billing

Grok CLI no longer stores a top-level access_token or usage.creditUsage.
Read nested auth key tokens and config.used.val so Settings → Usage
shows monthly credits again. Keep legacy shapes and env tokens working.

Fixes #2352

* fix(quota): make Grok auth-file tests work on Windows

Inject homeDir into GrokQuotaProvider like Kimi so nested
~/.grok/auth.json tests do not depend on os.homedir() which
ignores $HOME on Windows (USERPROFILE).
2026-07-28 18:49:21 +08:00
Mohamed Boudra f91a984348 fix(claude): show a single 1M-context Opus 5 model (#2497) 2026-07-28 18:18:21 +08:00
Michael WuandMohamed Boudra cbbf6c1684 Carry local files and shared state into new worktrees (#2419)
* feat(server): support symlink worktree includes

* refactor(server): simplify worktree include handling

* fix(server): constrain worktree include traversal

* fix(server): clean up failed worktree branches

* fix(server): skip missing worktree include entries

* fix(server): make worktree includes best effort

* fix(server): skip failed worktree includes

* fix(server): harden worktree include planning

* fix(server): preserve reused worktree result shape

Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape.

* fix(server): harden worktree include boundaries

Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation.

* fix(server): follow safe include aliases

Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips.

* fix(server): make include preflight race safe

Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base.

* fix(server): preserve staged recovery state

Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width.

* fix(server): preserve partial include progress

Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 03:18:40 +02:00
KamilandMohamed Boudra 89c2fac3c6 Open project and workspace folders from the sidebar (#2491)
* feat(app): open project folder from project context menu (#2487)

* refactor(app): give file manager action a home

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 00:48:29 +02:00
Saravjeet 'Aman' SinghandMohamed Boudra f8dd0fc2e0 Switch projects from New Workspace with ⌘P/Ctrl+P (#2110)
* feat(app): add ⌘P shortcut to switch project on New Workspace screen

Opens the existing project picker with its search focused so the project
can be switched from the keyboard (type + Enter) instead of clicking the
badge and then the project.

Wires a new "workspace.project.pick" action through the standard keyboard
pipeline (binding -> route passthrough -> dispatcher), handled by a
screen-scoped handler on the New Workspace screen that is only registered
while the screen is mounted and there are projects to pick. Because
preventDefault only fires when a handler handles the key, ⌘P/Ctrl+P still
triggers native print everywhere else. Adds a Settings -> Shortcuts help
row (rebindable) and the "Switch project" label across all locales.

* test(app): cover project picker shortcut

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 00:32:41 +02:00
Dmitry Sinev b55acfc60f fix(omp): accept nullable model context windows (#2406) 2026-07-28 00:28:30 +02:00
黄黄汪 a6fdcb469c docs: list paseo-skins as a community project (#2343) 2026-07-28 00:08:25 +02:00
Derek Perez 5bd317205c fix(omp): expose injected Paseo tools directly (#2418) 2026-07-27 23:44:25 +02:00
Matt Cowger e59c94812b perf(build): parallelize server dependencies (#2434) 2026-07-28 04:12:39 +08:00
维她命@ 1c8fabd293 fix(server): discover Codex project skills from cwd (#2423) 2026-07-27 22:12:22 +02:00
Matt Cowger 717f195f0c fix(app): render HTML in PR comments (#2432) 2026-07-28 04:01:43 +08:00
Li Mu ZhiandClaude Opus 4.8 869edcbf11 fix(forge): preserve non-default port in forge web URLs (#2478)
"Open in browser" links for a self-hosted forge served on a non-standard
port (e.g. Forgejo/Gitea on :60443) dropped the port, producing
https://host/owner/repo/... instead of https://host:60443/owner/repo/...,
which 404s or hits the wrong service.

parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no
port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the
origin from the portless host. Preserve the port on GitRemoteLocation and
reattach it in the web-URL builders, only for self-hosted http(s) origins
(an SSH port isn't the web port; a canonicalized cloud host uses the
default port). Host-identity matching (forge detection, cloud-host checks)
stays port-agnostic.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 04:00:46 +08:00
c596e058cd fix(dev): make worktree setup run on Windows (#2431)
`worktree.setup` ran two POSIX-only command strings, but lifecycle commands
go through PowerShell on Windows, so worktree creation failed at:

    PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=... ./scripts/dev-home.sh
    -> PASEO_DEV_MANAGED_HOME=1 : The term ... is not recognized

PowerShell has no `VAR=value cmd` prefix syntax. The `cp` entry was broken the
same way: `$PASEO_SOURCE_CHECKOUT_PATH` is an undefined *PowerShell* variable,
not an env var, so it expanded to empty and the copy resolved to
`/packages/server/.env`.

Neither entry can be expressed portably in a single shell string, and `bash` is
not guaranteed on Windows, so move both steps into a Node script that reads its
inputs from `process.env` — matching the existing
`node ./scripts/seed-ios-native-cache.mjs` entry. One code path, no platform
branching.

`scripts/dev-home.sh` is unchanged and still sourced by the bash service
scripts; only the setup-time seeding is ported.

One behavior change: a missing `packages/server/.env` in the source checkout is
now skipped with a log instead of aborting setup. It is untracked local config,
and the old `cp` hard-failed worktree creation for anyone without one.

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:52:26 +08:00
Mohamed Boudra fa1198c2be Stop completed turns from appearing stuck (#2484)
* fix(app): stop completed turns from appearing stuck

Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow.

* fix(app): guard pending message identity

* test(app): measure submission layout independent of scroll

* fix(app): keep submitted messages consistent through reconnects

Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization.

* fix(app): close canonical submission races

* test(app): cover canonical submission races in browser

* fix(app): keep replacement submissions authoritative

Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle.

* fix(app): keep submitted messages stable across sync

Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows.

* fix(app): settle submissions in either acknowledgement order

Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity.

* fix(app): keep agent visible during history handoff

Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied.

* fix(app): settle attachment-only submissions

Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row.

* fix(agent): settle out-of-band message submissions

Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer.

* fix(app): settle out-of-band submissions compatibly

* fix(app): preserve canonical prompt order

* test(app): keep workspace status check timing-independent

The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
2026-07-27 21:27:10 +02:00
paseo-ai[bot] 1f253d92e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-27 18:00:50 +00:00
228 changed files with 6418 additions and 3079 deletions
+213 -20
View File
@@ -18,6 +18,106 @@ env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Detect affected CI jobs
id: filter
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
with:
filters: |
shared:
- '.github/workflows/ci.yml'
- '.github/actions/**'
- '.mise.toml'
- '.tool-versions'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- 'scripts/**'
- 'tsconfig.json'
- 'tsconfig.base.json'
- 'vitest.config.ts'
quality:
- 'packages/**'
- '*.cjs'
- '*.js'
- '*.json'
- '*.mjs'
- '*.ts'
server:
- 'packages/app/e2e/fixtures/recording.*'
- 'packages/client/**'
- 'packages/cli/src/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop:
- 'packages/app/**'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop_package:
- '.github/workflows/ci.yml'
- 'packages/desktop/**'
app:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
sdk:
- 'packages/client/**'
- 'packages/protocol/**'
- 'packages/relay/**'
playwright:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
relay:
- 'packages/relay/**'
cli:
- 'nix/**'
- 'packages/app/e2e/global-setup.ts'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/src/daemon/runtime-paths.ts'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
- name: Validate CI workflow
run: node --test scripts/ci-workflow.test.mjs
format:
runs-on: ubuntu-latest
env:
@@ -39,6 +139,12 @@ jobs:
run: npx oxfmt --check .
lint:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -63,6 +169,12 @@ jobs:
run: npm run lint
typecheck:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -89,6 +201,8 @@ jobs:
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -97,28 +211,43 @@ jobs:
name: server-tests (${{ matrix.os }})
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false' }}
steps:
- name: Skip unaffected server tests
if: env.RUN_TESTS != 'true'
run: echo "No server changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
with:
fetch-depth: 0
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
if: env.RUN_TESTS == 'true'
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:server-deps
- name: Run server tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -126,6 +255,8 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -134,49 +265,53 @@ jobs:
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
env:
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false' }}
steps:
- uses: actions/checkout@v4
- name: Skip unaffected desktop tests
if: env.RUN_TESTS != 'true'
run: echo "No desktop changes detected."
- name: Detect desktop changes
if: matrix.os == 'ubuntu-latest'
id: desktop_changes
uses: dorny/paths-filter@v3
with:
filters: |
desktop:
- 'packages/desktop/**'
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Run desktop tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4
if: failure() && matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
@@ -184,7 +319,11 @@ jobs:
retention-days: 7
- name: Build and smoke unpacked desktop app
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
if: >-
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
@@ -192,7 +331,11 @@ jobs:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
if: >-
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
uses: actions/upload-artifact@v4
with:
name: desktop-packaged-smoke-linux-x64
@@ -201,6 +344,12 @@ jobs:
retention-days: 7
app-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -225,6 +374,12 @@ jobs:
run: npm run test --workspace=@getpaseo/app
sdk-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -251,6 +406,8 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -264,43 +421,57 @@ jobs:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false' }}
steps:
- name: Skip unaffected Playwright tests
if: env.RUN_TESTS != 'true'
run: echo "No Playwright changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
if: env.RUN_TESTS == 'true'
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:app-deps
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }}
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: ${{ !matrix.desktop }}
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }}
if: env.RUN_TESTS == 'true' && matrix.desktop
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
if: env.RUN_TESTS == 'true' && failure()
with:
name: playwright-results-${{ matrix.shard }}
path: |
@@ -309,6 +480,12 @@ jobs:
retention-days: 7
relay-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -330,6 +507,8 @@ jobs:
run: npm run test --workspace=@getpaseo/relay
cli-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -338,24 +517,38 @@ jobs:
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false' }}
steps:
- name: Skip unaffected CLI tests
if: env.RUN_TESTS != 'true'
run: echo "No CLI changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## 0.2.4 - 2026-07-30
### Added
- Set an agent's thinking level from the CLI ([#2533](https://github.com/getpaseo/paseo/pull/2533))
- Switch projects while creating a workspace with ⌘P / Ctrl+P ([#2110](https://github.com/getpaseo/paseo/pull/2110) by [@turtleDev](https://github.com/turtleDev))
- Open a project or workspace folder from the sidebar ([#2491](https://github.com/getpaseo/paseo/pull/2491) by [@PTK030](https://github.com/PTK030))
- Flick up on the chat to dismiss the keyboard ([#2417](https://github.com/getpaseo/paseo/pull/2417) by [@nllptrx](https://github.com/nllptrx))
- Custom providers can read the agent's working directory ([#2563](https://github.com/getpaseo/paseo/pull/2563))
### Fixed
- The same repository added on more than one machine appears as one project in the sidebar ([#2565](https://github.com/getpaseo/paseo/pull/2565))
- Idle agents keep their background work instead of shutting down when unused ([#2590](https://github.com/getpaseo/paseo/pull/2590))
- Desktop no longer crashes at startup while restoring your file tree ([#2595](https://github.com/getpaseo/paseo/pull/2595))
- Plan approval only shows the latest proposal ([#2534](https://github.com/getpaseo/paseo/pull/2534))
- Opus 5 appears once in the model list, with its full 1M context ([#2497](https://github.com/getpaseo/paseo/pull/2497))
- The context meter no longer blanks out partway through a conversation ([#2494](https://github.com/getpaseo/paseo/pull/2494) by [@theslava](https://github.com/theslava))
- A very large working diff no longer drops your session ([#2488](https://github.com/getpaseo/paseo/pull/2488) by [@nikuscs](https://github.com/nikuscs))
- Interrupting Pi no longer surfaces a stream error ([#2311](https://github.com/getpaseo/paseo/pull/2311) by [@mcowger](https://github.com/mcowger))
- Grok usage shows again in Settings ([#2353](https://github.com/getpaseo/paseo/pull/2353) by [@jasonhnd](https://github.com/jasonhnd))
- OMP models that report no context window now load ([#2406](https://github.com/getpaseo/paseo/pull/2406) by [@astartsky](https://github.com/astartsky))
- Paseo's own tools are available directly in OMP ([#2418](https://github.com/getpaseo/paseo/pull/2418) by [@perezd](https://github.com/perezd))
- Codex finds the skills defined in your project ([#2423](https://github.com/getpaseo/paseo/pull/2423) by [@dwyanewang](https://github.com/dwyanewang))
- Pull request comments containing HTML render correctly ([#2432](https://github.com/getpaseo/paseo/pull/2432) by [@mcowger](https://github.com/mcowger))
- Self-hosted forge links keep their port ([#2478](https://github.com/getpaseo/paseo/pull/2478) by [@muzhi1991](https://github.com/muzhi1991))
- The Linux AppImage launches when opened from your desktop ([#2439](https://github.com/getpaseo/paseo/pull/2439) by [@stonegray](https://github.com/stonegray))
- Repository search works with older versions of the GitHub CLI ([#2611](https://github.com/getpaseo/paseo/pull/2611))
## 0.2.3 - 2026-07-27
### Added
+1
View File
@@ -154,6 +154,7 @@ npm run typecheck
## 関連プロジェクト
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
## ライセンス
+1
View File
@@ -163,6 +163,7 @@ npm run typecheck
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
## License
+1
View File
@@ -154,6 +154,7 @@ npm run typecheck
## 相关项目
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS
+4 -13
View File
@@ -21,16 +21,8 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. An idle parent also stays resident while current
in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated
independently; collection does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
### Cancellation
@@ -59,9 +51,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
+11 -4
View File
@@ -4,9 +4,15 @@
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills it for
older records where the field is absent—there is no migration.
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
@@ -450,7 +456,8 @@ Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
+8
View File
@@ -242,6 +242,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
that reads what it needs from `process.env` and invoke it as
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
```json
{
"worktree": {
+2 -2
View File
@@ -2,7 +2,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
@@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
+1 -1
View File
@@ -34,7 +34,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
+24
View File
@@ -38,6 +38,14 @@ There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
@@ -76,6 +84,20 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
@@ -92,6 +114,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green
+1 -1
View File
@@ -1 +1 @@
sha256-DaVBk1PxsNr1AQu/mTG1Inp67pLLDmc+Ed0IZ5GyzgY=
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.3",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.3",
"version": "0.2.4",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35211,7 +35211,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36236,12 +36236,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.3",
"@getpaseo/protocol": "0.2.3",
"@getpaseo/server": "0.2.3",
"@getpaseo/client": "0.2.4",
"@getpaseo/protocol": "0.2.4",
"@getpaseo/server": "0.2.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36488,10 +36488,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@getpaseo/protocol": "0.2.3",
"@getpaseo/relay": "0.2.3",
"@getpaseo/protocol": "0.2.4",
"@getpaseo/relay": "0.2.4",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36502,7 +36502,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.3",
"version": "0.2.4",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36745,7 +36745,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.3",
"version": "0.2.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37641,7 +37641,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37873,7 +37873,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37886,7 +37886,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38104,15 +38104,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.3",
"@getpaseo/highlight": "0.2.3",
"@getpaseo/protocol": "0.2.3",
"@getpaseo/relay": "0.2.3",
"@getpaseo/client": "0.2.4",
"@getpaseo/highlight": "0.2.4",
"@getpaseo/protocol": "0.2.4",
"@getpaseo/relay": "0.2.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38649,7 +38649,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.3",
"version": "0.2.4",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -49,9 +49,9 @@
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"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",
+21 -24
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,36 +13,21 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
+19 -15
View File
@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => {
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
let readdedProjectId: string | null = null;
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -206,7 +207,7 @@ test.describe("Project remove", () => {
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await removeProjectFromSidebar(page, workspace.projectKey);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
@@ -220,21 +221,24 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
}
await workspace.cleanup();
}
});
@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });
+63
View File
@@ -57,6 +57,69 @@ export async function addOfflineHostAndReload(
await page.reload();
}
export async function addConnectedHostAndReload(
page: Page,
input: { serverId: string; label: string; port: number; primaryLabel?: string },
): Promise<void> {
await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
export async function addConnectedHostsAndReload(
page: Page,
inputs: Array<{ serverId: string; label: string; port: number }>,
options?: { primaryLabel?: string },
): Promise<void> {
const connectedHosts = inputs.map((input) =>
buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
}),
);
await page.evaluate(
({ hosts, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) registry[0].label = primaryLabel;
for (const host of hosts) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: connectedHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: options?.primaryLabel,
},
);
await page.reload();
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
restart(): Promise<void>;
close(): Promise<void>;
}
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
return {
serverId,
port,
paseoHome,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
+23 -4
View File
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "inspectWorkspaceRecovery"
| "listProjects"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -29,6 +30,7 @@ type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
@@ -45,10 +47,19 @@ function requireWorkspace(payload: WorkspacePayload) {
return payload.workspace;
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
async function openedProjectFromWorkspace(
client: NewWorkspaceDaemonClient,
workspace: WorkspaceDescriptor,
): Promise<OpenedProject> {
const payload = await client.listProjects();
const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId);
if (!project?.projectKey) {
throw new Error(`Project ${workspace.projectId} has no project key`);
}
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectId: workspace.projectId,
projectKey: project.projectKey,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
@@ -107,7 +118,7 @@ export async function openProjectViaDaemon(
source: { kind: "directory", path: repoPath },
}),
);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function archiveWorkspaceFromDaemon(
@@ -149,7 +160,7 @@ export async function createWorktreeViaDaemon(
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function openNewWorkspaceComposer(
@@ -177,6 +188,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
});
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await expect(searchInput).toBeFocused();
}
export async function expectNewWorkspaceProjectSelected(
page: Page,
projectDisplayName: string,
@@ -0,0 +1,131 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
import { openProjectSettings } from "./project-settings";
import { waitForSidebarHydration } from "./workspace-ui";
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
function projectGroup(page: Page, projectName: string): Locator {
return page.getByRole("group", { name: projectName, exact: true });
}
function projectGroupWithWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Locator {
return projectGroup(page, projectName).filter({ hasText: workspaceName });
}
export async function openProjectDirectory(page: Page): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
}
export async function openProjectDirectoryWithHosts(
page: Page,
input: {
hosts: Array<{ serverId: string; label: string; port: number }>;
primaryLabel?: string;
},
): Promise<void> {
await gotoAppShell(page);
await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel });
for (const host of input.hosts) {
await waitForConnectedHost(page, {
serverId: host.serverId,
endpoint: `localhost:${host.port}`,
});
}
await waitForSidebarHydration(page);
}
export async function expectProjectContainsWorkspaces(
page: Page,
input: { projectName: string; workspaceNames: string[] },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
for (const workspaceName of input.workspaceNames) {
await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
}
export async function expectSeparateProjects(
page: Page,
projects: Array<{ projectName: string; workspaceName: string }>,
): Promise<void> {
for (const project of projects) {
await expect(
projectGroupWithWorkspace(page, project.projectName, project.workspaceName),
).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
}
}
export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise<void> {
const group = projectGroup(page, projectName);
await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT });
await group.hover();
await group.getByLabel(`Create a new workspace for ${projectName}`).click();
await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function selectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await page.getByRole("button", { name: "Host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
export async function createWorkspaceWithoutAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Create", exact: true }).click();
}
export async function expectProjectWorkspaceCountForHost(
page: Page,
input: { projectName: string; hostName: string; count: number },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount(
input.count,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function renameProject(page: Page, name: string): Promise<void> {
await page.getByRole("button", { name: "Rename project", exact: true }).click();
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
await page.getByRole("button", { name: "Save project name", exact: true }).click();
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await openProjectSettings(page, projectName);
}
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
const projectSettings = page.getByRole("main");
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
).toContainText(hostName);
}
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();
return projectId!;
}
@@ -16,6 +16,7 @@ type SessionRequest = Record<string, unknown> & { type?: string; requestId?: str
export interface FakeScheduleHostWorkspace {
serverId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspace: Record<string, unknown>;
}
@@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace(
return {
serverId: "schedule-fake-host",
projectId,
projectKey: workspace.projectKey,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
workspace: {
...baseWorkspace,
@@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: {
port: string;
serverId: string;
workspace: Record<string, unknown>;
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
schedules?: FakeScheduleSummary[];
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
@@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: {
features: {
providersSnapshot: true,
workspaceMultiplicity: true,
projectList: true,
projectAdd: true,
projectRemove: true,
workspaceRecovery: true,
@@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: {
}),
);
return;
case "project.list.request":
ws.send(
buildSessionMessage("project.list.response", {
requestId,
projects: [
{
projectId: input.project.projectId,
projectKey: input.project.projectKey,
projectDisplayName: input.project.projectDisplayName,
projectRootPath: String(input.workspace.projectRootPath),
projectKind: "git",
},
],
}),
);
return;
case "fetch_agents_request":
ws.send(
buildSessionMessage("fetch_agents_response", {
+17 -1
View File
@@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor {
workspaceDirectory: string;
}
interface SeedProjectDescriptor {
projectId: string;
projectKey?: string;
}
/**
* The general-purpose E2E daemon client used to seed and drive state out of
* band (workspaces, agents, terminals) while the UI is exercised through the
@@ -25,15 +30,18 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
renameProject(projectId: string, customName: string | null): Promise<void>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
createWorkspace(input: {
source:
| { kind: "directory"; path: string; projectId?: string }
@@ -179,8 +187,10 @@ export interface SeededWorkspace {
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Stable project identity the daemon groups workspaces under. */
/** Host-local identity used by daemon project operations. */
projectId: string;
/** Opaque cross-host key used by grouped project UI. */
projectKey: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
@@ -208,6 +218,11 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const listed = await client.listProjects();
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
if (!descriptor?.projectKey) {
throw new Error(`Created project ${workspace.projectId} has no project key`);
}
return {
client,
repoPath: project.path,
@@ -215,6 +230,7 @@ export async function seedWorkspace(options: {
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectKey: descriptor.projectKey,
projectDisplayName: workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const descriptor = (await client.listProjects()).projects.find(
(project) => project.projectId === createdWorkspace.workspace?.projectId,
);
const projectKey = descriptor?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectKey: firstProject.projectKey,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectKey: secondProject.projectKey,
projectDisplayName: secondProject.projectDisplayName,
});
+27 -10
View File
@@ -5,6 +5,7 @@ import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
openNewWorkspaceProjectPickerWithShortcut,
} from "./helpers/new-workspace";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
@@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => {
}
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await openNewWorkspaceProjectPickerWithShortcut(page);
} finally {
await seeded.cleanup();
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
@@ -164,13 +179,13 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectId,
projectKey: projectA.projectKey,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
@@ -179,7 +194,9 @@ test.describe("New workspace entry points", () => {
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectC.projectKey}`,
);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
@@ -188,7 +205,7 @@ test.describe("New workspace entry points", () => {
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectId,
projectKey: projectB.projectKey,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
@@ -211,8 +228,8 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
@@ -221,7 +238,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
@@ -234,7 +251,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectId}`,
`new-workspace-project-picker-option-${gitProject.projectKey}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();
@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
// so its handler is still registered when we cycle here.
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
+7
View File
@@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const localProjectIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
const localGithubFixtures = new Set<LocalGhPrFixture>();
@@ -212,9 +213,13 @@ test.describe("New workspace flow", () => {
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const projectId of localProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
localProjectIds.clear();
await client?.close().catch(() => undefined);
});
@@ -830,6 +835,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -881,6 +887,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -1,8 +1,10 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { waitForConnectedHost } from "./helpers/hosts";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -163,6 +163,7 @@ test.describe("Schedules", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
schedules: [
buildFakeHostSchedule({
id: scheduleId,
@@ -205,7 +205,7 @@ test.describe("Schedules project target", () => {
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -245,6 +245,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await addFakeScheduleHostAndReload({
page,
@@ -277,6 +278,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await gotoAppShell(page);
@@ -313,7 +315,7 @@ test.describe("Schedules project target", () => {
await expectSettled(hostTrigger);
await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -335,8 +337,7 @@ test.describe("Schedules project target", () => {
await expectSettled(projectTrigger);
await projectTrigger.click();
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click();
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i);
@@ -362,7 +363,7 @@ test.describe("Schedules project target", () => {
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
await selectModelByLabel(page, "Ten second stream");
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
+6 -6
View File
@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
// Both projects are expandable parents — the non-git one is NOT flattened
// into a bare workspace link.
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Each parent shows both of its workspace rows underneath.
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
await projectRow(page, gitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
await projectRow(page, nonGitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
@@ -0,0 +1,404 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { test as base } from "./fixtures";
import {
beginWorkspaceFromProject,
createWorkspaceWithoutAgent,
expectProjectContainsWorkspaces,
expectProjectSettingsName,
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openProjectDirectory,
openProjectDirectoryWithHosts,
renameProject,
selectWorkspaceHost,
switchProjectSettingsHost,
} from "./helpers/project-grouping";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host";
const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host";
const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
const SUBDIRECTORY = path.join("packages", "app");
const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }];
interface HostConnection {
serverId: string;
label: string;
port: number;
}
interface ProjectDirectoryScenario {
hosts: HostConnection[];
primaryLabel?: string;
}
interface CreatedProject {
projectId: string;
}
async function createProject(
client: SeedDaemonClient,
input: {
projectPath: string;
serverId: string;
workspaceName: string;
projectName?: string;
},
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project on ${input.serverId}`);
}
if (input.projectName) {
await client.renameProject(created.workspace.projectId, input.projectName);
}
return { projectId: created.workspace.projectId };
}
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) delete project.projectKey;
await writeFile(projectsPath, JSON.stringify(projects));
}
const test = base.extend<{
crossHostProject: ProjectDirectoryScenario;
reconciledCrossHostProject: ProjectDirectoryScenario;
rootAndSubdirectoryProjects: ProjectDirectoryScenario;
crossHostSubdirectoryProject: ProjectDirectoryScenario;
sameHostClones: ProjectDirectoryScenario;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary");
const primaryRepo = await createTempGitRepo("grouped-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: getServerId(),
workspaceName: "Primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
reconciledCrossHostProject: async ({ page: _page }, provide) => {
const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary");
const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary");
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient({ port: primaryHost.port });
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: primaryHost.serverId,
workspaceName: "Recovered primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Recovered secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
await provide({
hosts: [
{
serverId: primaryHost.serverId,
label: LEGACY_PRIMARY_HOST_LABEL,
port: primaryHost.port,
},
{
serverId: secondaryHost.serverId,
label: LEGACY_SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
}
},
rootAndSubdirectoryProjects: async ({ page: _page }, provide) => {
const repo = await createTempGitRepo("grouped-root-subdir-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: repo.path,
serverId: getServerId(),
workspaceName: "Repository root workspace",
projectName: "Repository root project",
}),
);
projects.push(
await createProject(client, {
projectPath: path.join(repo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "App package workspace",
projectName: "App package project",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
},
crossHostSubdirectoryProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary");
const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: path.join(primaryRepo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "Primary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: path.join(secondaryRepo.path, SUBDIRECTORY),
serverId: secondaryHost.serverId,
workspaceName: "Secondary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
sameHostClones: async ({ page: _page }, provide) => {
const firstRepo = await createTempGitRepo("grouped-clone-first-", {
originUrl: SHARED_REMOTE_URL,
});
const secondRepo = await createTempGitRepo("grouped-clone-second-", {
originUrl: SHARED_REMOTE_URL,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: firstRepo.path,
serverId: getServerId(),
workspaceName: "First clone workspace",
projectName: "First clone",
}),
);
projects.push(
await createProject(client, {
projectPath: secondRepo.path,
serverId: getServerId(),
workspaceName: "Second clone workspace",
projectName: "Second clone",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await firstRepo.cleanup().catch(() => undefined);
await secondRepo.cleanup().catch(() => undefined);
}
},
});
async function openScenario(
page: Parameters<typeof openProjectDirectory>[0],
scenario: ProjectDirectoryScenario,
): Promise<void> {
if (scenario.hosts.length === 0) {
await openProjectDirectory(page);
return;
}
await openProjectDirectoryWithHosts(page, scenario);
}
test.describe("Sidebar project grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("groups projects with the same Git remote across hosts", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary workspace", "Secondary workspace"],
});
});
test("groups persisted projects missing project keys after boot", async ({
page,
reconciledCrossHostProject,
}) => {
await openScenario(page, reconciledCrossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"],
});
});
test("keeps a repository root and its subdirectory as separate projects", async ({
page,
rootAndSubdirectoryProjects,
}) => {
await openScenario(page, rootAndSubdirectoryProjects);
await expectSeparateProjects(page, [
{ projectName: "Repository root project", workspaceName: "Repository root workspace" },
{ projectName: "App package project", workspaceName: "App package workspace" },
]);
});
test("groups the same repository subdirectory across hosts", async ({
page,
crossHostSubdirectoryProject,
}) => {
await openScenario(page, crossHostSubdirectoryProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary app workspace", "Secondary app workspace"],
});
});
test("keeps two clones of the same repository on one host separate", async ({
page,
sameHostClones,
}) => {
await openScenario(page, sameHostClones);
await expectSeparateProjects(page, [
{ projectName: "First clone", workspaceName: "First clone workspace" },
{ projectName: "Second clone", workspaceName: "Second clone workspace" },
]);
});
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
await openScenario(page, crossHostProject);
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
await renameProject(page, "Primary-only project name");
await expectProjectSettingsName(page, "Primary-only project name");
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
});
test("creates a workspace on the selected host from a grouped project", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await selectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
});
});
+1 -1
View File
@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
@@ -32,8 +32,12 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectId: string;
}>;
}>;
listProjects(): Promise<{
projects: Array<{ projectId: string; projectKey?: string }>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
agent: {
@@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => {
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
const reconciledProjectKey = (await client.listProjects()).projects.find(
(project) => project.projectId === seeded.projectId,
);
if (!reconciledProjectKey?.projectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: reconciledProjectKey.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
// Both the original and the new workspace render as distinct rows under
// the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.3",
"version": "0.2.4",
"private": true,
"main": "index.ts",
"scripts": {
@@ -14,7 +14,6 @@ export interface GithubRepositoryChoice {
cloneUrl: string;
cloneProtocol?: "https" | "ssh";
description: string | null;
visibility: string | null;
updatedAt: string | null;
}
@@ -101,7 +101,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit
nameWithOwner: identity?.repo ?? remoteName,
cloneUrl: repo,
description: "Clone this repository URL",
visibility: null,
updatedAt: null,
},
];
@@ -116,7 +115,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit
cloneUrl: nameWithOwner,
cloneProtocol,
description: `Clone owner/repo via ${cloneProtocol.toUpperCase()}`,
visibility: null,
updatedAt: null,
}));
}
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
type ScrollKeyboardDismissEvent,
type ScrollKeyboardDismissGesture,
} from "./model";
interface Point {
ts: number;
y: number;
nativeTs?: number | null;
}
function event(point: Point): ScrollKeyboardDismissEvent {
return {
timeStamp: point.ts,
nativeEvent: {
contentOffset: { y: point.y },
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
},
};
}
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
return points.reduce(
(gesture, point) => recordScroll(gesture, event(point)),
beginDrag(event(start)),
);
}
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
}
describe("scroll keyboard dismissal", () => {
it("keeps the keyboard up for a slow read-scroll", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
],
{ ts: 1160, y: 32 },
),
).toBe(false);
});
it("dismisses for an upward flick", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
true,
);
});
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
false,
);
});
it("keeps a fast-but-decelerating drag below the release threshold", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
],
{ ts: 1300, y: 600 },
),
).toBe(false);
});
it("uses the whole drag when the gesture is too short to sample", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
});
it("steps back a sample when release lands immediately after one", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
],
{ ts: 1080, y: 240 },
),
).toBe(true);
});
it("keeps the keyboard up when release has no measurable time span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
});
it("ignores scroll events that arrive before the minimum sample span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
true,
);
});
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
expect(
shouldDismiss(
{ ts: 5000, nativeTs: 1000, y: 0 },
[
{ ts: 5001, nativeTs: 1200, y: 40 },
{ ts: 5002, nativeTs: 1400, y: 80 },
],
{ ts: 5003, nativeTs: 1600, y: 120 },
),
).toBe(false);
});
it("falls back to synthetic event time when native time is absent", () => {
expect(
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
ts: 1080,
nativeTs: null,
y: 240,
}),
).toBe(true);
});
it("ignores scroll and release events without a matching drag", () => {
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
});
it("returns to idle after release", () => {
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
});
});
@@ -0,0 +1,124 @@
/**
* Pure state machine for the chat history's flick-to-dismiss gesture.
*
* Native keyboardDismissMode cannot express this interaction: "on-drag"
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
* inverted list. We therefore classify the list's existing scroll gesture at
* release instead of introducing a competing pan recognizer.
*/
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
const RELEASE_SAMPLE_MIN_MS = 30;
/**
* The subset of a native scroll event used by the classifier. React Native's
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
*/
export interface ScrollKeyboardDismissEvent {
timeStamp: number;
nativeEvent: {
contentOffset: { y: number };
timestamp?: number;
};
}
interface DragSamples {
startTs: number;
startY: number;
sampleTs: number;
sampleY: number;
previousSampleTs: number;
previousSampleY: number;
}
export type ScrollKeyboardDismissGesture =
| { phase: "idle" }
| { phase: "dragging"; samples: DragSamples };
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
phase: "idle",
});
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
const nativeTimestamp = event.nativeEvent.timestamp;
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
return nativeTimestamp;
}
return event.timeStamp;
}
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
const timestamp = resolveEventTimeMs(event);
const offsetY = event.nativeEvent.contentOffset.y;
return {
phase: "dragging",
samples: {
startTs: timestamp,
startY: offsetY,
sampleTs: timestamp,
sampleY: offsetY,
previousSampleTs: 0,
previousSampleY: 0,
},
};
}
export function recordScroll(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): ScrollKeyboardDismissGesture {
if (gesture.phase === "idle") {
return gesture;
}
const timestamp = resolveEventTimeMs(event);
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
return gesture;
}
const { samples } = gesture;
return {
phase: "dragging",
samples: {
startTs: samples.startTs,
startY: samples.startY,
sampleTs: timestamp,
sampleY: event.nativeEvent.contentOffset.y,
previousSampleTs: samples.sampleTs,
previousSampleY: samples.sampleY,
},
};
}
export function releaseDrag(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
if (gesture.phase === "idle") {
return { gesture, shouldDismiss: false };
}
const releaseTs = resolveEventTimeMs(event);
const releaseY = event.nativeEvent.contentOffset.y;
const { samples } = gesture;
// The release event carries the gesture's true endpoint. The final onScroll
// event can still be stale when a short flick lands.
let spanStartTs = samples.startTs;
let spanStartY = samples.startY;
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
spanStartTs = samples.sampleTs;
spanStartY = samples.sampleY;
} else if (samples.previousSampleTs > 0) {
spanStartTs = samples.previousSampleTs;
spanStartY = samples.previousSampleY;
}
const spanDurationMs = releaseTs - spanStartTs;
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
return {
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
};
}
@@ -0,0 +1,57 @@
import { useRef } from "react";
import {
Keyboard,
TextInput,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
} from "./model";
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
/**
* Owns the chat history's flick-to-dismiss behavior. The native stream only
* forwards the FlatList scroll lifecycle; removing this hook and those three
* calls removes the feature completely.
*/
export function useScrollKeyboardDismiss() {
const { shift } = useKeyboardShift();
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
gestureRef.current = beginDrag(event);
});
const onScroll = useStableEvent((event: ScrollEvent) => {
gestureRef.current = recordScroll(gestureRef.current, event);
});
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
const release = releaseDrag(gestureRef.current, event);
gestureRef.current = release.gesture;
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
// second calculation on JS, this prevents a hardware keyboard's focused
// composer from being blurred when no software keyboard occupies space.
if (!release.shouldDismiss || shift.value <= 0) {
return;
}
// Keep blur and dismiss paired: this exact sequence was validated on a
// physical Android device to clear both input focus and the IME inset.
const focusedInput = TextInput.State.currentlyFocusedInput();
if (focusedInput) {
TextInput.State.blurTextInput(focusedInput);
}
Keyboard.dismiss();
});
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
}
@@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
@@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = {
paddingTop: 4,
paddingBottom: 8,
};
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
@@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
@@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
scrollKeyboardDismiss.onScroll(event);
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
@@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent(() => {
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
@@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
@@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
scrollKeyboardDismiss.onScrollEndDrag(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
+1 -1
View File
@@ -883,7 +883,7 @@ function RootStack() {
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="settings/projects/[serverId]/[projectId]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
@@ -1,12 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -0,0 +1,19 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{
serverId?: string | string[];
projectId?: string | string[];
}>();
const serverId = normalizeProjectSettingsRouteId(params.serverId);
const projectId = normalizeProjectSettingsRouteId(params.projectId);
const view = useMemo(
() => ({ kind: "project" as const, serverId, projectId }),
[projectId, serverId],
);
return <SettingsScreen view={view} />;
}
@@ -365,7 +365,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const recommendedPaths = useRecommendedProjectPaths(hostId);
const openProject = useOpenProject(hostId);
const cloneGithubProject = useCloneGithubProject(hostId);
const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
const upsertProject = useSessionStore((store) => store.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<TextInput>(null);
const submissionInFlightRef = useRef(false);
@@ -644,7 +644,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
title: repository.cloneProtocol
? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}`
: repository.nameWithOwner,
subtitle: repository.description ?? repository.visibility,
subtitle: repository.description,
icon: Github,
testID: `add-project-flow-repository-${repository.id}`,
select: () =>
@@ -735,7 +735,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
registerProjectDescriptor({
serverId: page.hostId,
project: payload.project,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
openNewWorkspaceForProject(page.hostId, payload.project);
@@ -749,7 +749,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
} finally {
submissionInFlightRef.current = false;
}
}, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
const submitActive = useCallback(() => {
if (page.kind === "new-directory-name") {
+111 -148
View File
@@ -24,16 +24,28 @@ import {
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import type {
AgentFileExplorerState,
ExplorerDirectory,
ExplorerEntry,
} from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
flattenExplorerTree,
reconcileRestoredExpandedPaths,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
type ExplorerTreeRow,
} from "@/file-explorer/tree";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
@@ -83,7 +95,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
}
function treeRowKeyExtractor(row: TreeRow) {
function treeRowKeyExtractor(row: ExplorerTreeRow) {
return row.entry.path;
}
@@ -199,11 +211,6 @@ interface FileExplorerPaneProps {
onAddToChat?: (path: string) => void;
}
interface TreeRow {
entry: ExplorerEntry;
depth: number;
}
export function FileExplorerPane({
serverId,
workspaceId,
@@ -263,7 +270,7 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest],
);
const treeListRef = useRef<FlatList<TreeRow>>(null);
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
const hasInitializedRef = useRef(false);
@@ -276,9 +283,19 @@ export function FileExplorerPane({
hasWorkspaceScope,
hasInitializedRef,
workspaceStateKey,
persistedExpandedPaths: expandedPaths,
showHiddenFiles,
requestDirectoryListing,
setExpandedPathsForWorkspace,
});
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
}, [
expandedPaths,
hasWorkspaceScope,
requestDirectoryListing,
setExpandedPathsForWorkspace,
showHiddenFiles,
workspaceStateKey,
]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) =>
@@ -351,11 +368,42 @@ export function FileExplorerPane({
const handleToggleHiddenFiles = useCallback(() => {
const willShow = !usePanelStore.getState().explorerShowHiddenFiles;
toggleExplorerShowHiddenFiles();
if (willShow) {
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
if (!willShow) {
toggleExplorerShowHiddenFiles();
return;
}
}, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]);
const rootDirectory = directories.get(".");
if (!rootDirectory || !workspaceStateKey) {
toggleExplorerShowHiddenFiles();
return;
}
void showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: expandedPaths,
showHiddenFiles: toggleExplorerShowHiddenFiles,
requestDirectoryListing: (path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
}).then((restoredPaths) => {
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths: expandedPaths,
currentExpandedPaths: new Set(currentPaths),
restoredExpandedPaths: restoredPaths,
}),
);
return null;
});
}, [
directories,
expandedPaths,
requestDirectoryListing,
setExpandedPathsForWorkspace,
toggleExplorerShowHiddenFiles,
workspaceStateKey,
]);
const refreshExplorer = useCallback(
() =>
@@ -387,7 +435,7 @@ export function FileExplorerPane({
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
const treeRows = useMemo(
() => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }),
() => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }),
[directories, expandedPaths, showHiddenFiles, sortOption],
);
@@ -400,7 +448,7 @@ export function FileExplorerPane({
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
(info: ListRenderItemInfo<ExplorerTreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
@@ -480,11 +528,11 @@ interface FileExplorerPaneContentProps {
error: string | null;
showInitialLoading: boolean;
showBackFromError: boolean;
treeRows: TreeRow[];
treeRows: ExplorerTreeRow[];
currentSortLabel: string;
isRefreshFetching: boolean;
treeListRef: RefObject<FlatList<TreeRow> | null>;
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
handleSortCycle: () => void;
handleToggleHiddenFiles: () => void;
handleRefresh: () => void;
@@ -637,71 +685,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
);
}
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
switch (sortOption) {
case "name":
return a.name.localeCompare(b.name);
case "modified":
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
case "size":
return b.size - a.size;
default:
return 0;
}
});
return sorted;
}
function buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path,
depth,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
path: string;
depth: number;
}): TreeRow[] {
const directory = directories.get(path);
if (!directory) {
return [];
}
const rows: TreeRow[] = [];
const entries = sortEntries(
filterVisibleExplorerEntries(directory.entries, showHiddenFiles),
sortOption,
);
for (const entry of entries) {
rows.push({ entry, depth });
if (entry.kind === "directory" && expandedPaths.has(entry.path)) {
rows.push(
...buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: entry.path,
depth: depth + 1,
}),
);
}
}
return rows;
}
function deriveExplorerFields(state: AgentFileExplorerState | undefined) {
return {
directories:
@@ -751,30 +734,6 @@ function resolveCurrentSortLabel(
return labels[sortOption] ?? labels.name;
}
function resolveTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}): TreeRow[] {
if (!directories.get(".")) {
return [];
}
return buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: ".",
depth: 0,
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -786,26 +745,25 @@ function toggleDirectory({
entry: ExplorerEntry;
workspaceStateKey: string | null;
expandedPaths: Set<string>;
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
directories: Map<string, ExplorerDirectory>;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: string[]) => void;
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): void {
if (!workspaceStateKey) {
return;
}
const isExpanded = expandedPaths.has(entry.path);
if (isExpanded) {
setExpandedPathsForWorkspace(
workspaceStateKey,
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
return;
}
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
if (!directories.has(entry.path)) {
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
setExpandedDirectoryPath({
currentExpandedPaths: currentPaths,
directoryPath: entry.path,
expanded: !isExpanded,
}),
);
if (!isExpanded && !directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
@@ -827,7 +785,7 @@ function TreeRowDispatcher({
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
info: ListRenderItemInfo<ExplorerTreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
isDirectoryLoading: (path: string) => boolean;
@@ -865,54 +823,59 @@ async function initializeExplorer({
hasWorkspaceScope,
hasInitializedRef,
workspaceStateKey,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
setExpandedPathsForWorkspace,
}: {
hasWorkspaceScope: boolean;
hasInitializedRef: RefObject<boolean>;
workspaceStateKey: string | null;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) {
return;
}
hasInitializedRef.current = true;
const succeeded = await requestDirectoryListing(".", {
const rootDirectory = await requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
if (!succeeded) {
if (!rootDirectory) {
hasInitializedRef.current = false;
return;
}
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
}
function requestPersistedExpandedPaths({
workspaceStateKey,
requestDirectoryListing,
}: {
workspaceStateKey: string | null;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
}): void {
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (!persistedPaths) {
if (!workspaceStateKey) {
return;
}
for (const path of persistedPaths) {
if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) {
void requestDirectoryListing(path, {
const restoredPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing: (path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}),
});
const hiddenPersistedPaths = showHiddenFiles
? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths,
currentExpandedPaths: new Set(currentPaths),
restoredExpandedPaths: restoredPathsWithHidden,
}),
);
}
async function refreshExplorerDirectories({
@@ -925,7 +888,7 @@ async function refreshExplorerDirectories({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
) => Promise<ExplorerDirectory | null>;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;
@@ -296,6 +296,16 @@ describe("splitHtmlishMarkdown", () => {
);
});
it("normalizes HTML table cells and inline formatting into markdown", () => {
expect(
normalizeHtmlishMarkdown(
"<table><tr><td><strong>Score</strong>: 78</td></tr><tr><td><strong>No security concerns identified</strong></td></tr><tr><td><strong>Recommended focus areas for review</strong></td><td>Bearer authentication</td></tr></table>",
),
).toBe(
"\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n",
);
});
it("leaves complex code tags inert instead of parsing HTML", () => {
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
'<code onclick="evil()"><script>x</script></code>',
@@ -30,6 +30,15 @@ const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
const VOID_HTML_TAGS = new Set(["br", "img"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
del: ["~~", "~~"],
em: ["*", "*"],
i: ["*", "*"],
s: ["~~", "~~"],
strike: ["~~", "~~"],
strong: ["**", "**"],
};
interface ProtectedMarkdownRange {
start: number;
@@ -313,36 +322,83 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
if (token.name === "a") {
output += renderLinkToken(token, children);
index = closeIndex;
continue;
}
if (token.name === "sub") {
output += renderInlineTokens(children);
index = closeIndex;
continue;
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
output += `\`${renderInlineTokens(children)}\``;
index = closeIndex;
continue;
}
const rawTag = token.raw;
const tagName = token.name;
if (isHeadingTag(token)) {
output += renderInlineTokens(children);
index = closeIndex;
continue;
}
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
output += renderHtmlTag(token, children);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
if (token.name === "sub" || isHeadingTagName(token.name)) {
return renderInlineTokens(children);
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
return `\`${renderInlineTokens(children)}\``;
}
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
if (wrapper) {
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
}
if (token.name === "table") {
return renderTableTokens(children);
}
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
const rows: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
if (!isOpenTag(tokens[index], "tr")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, "tr");
if (closeIndex === null) {
continue;
}
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
if (cells.length === 1) {
rows.push(`- ${cells[0]}`);
} else if (cells.length > 1) {
const label =
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
}
index = closeIndex;
}
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
}
function renderTableCells(tokens: HtmlToken[]): string[] {
const cells: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, token.name);
if (closeIndex === null) {
continue;
}
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
if (cell) {
cells.push(cell);
}
index = closeIndex;
}
return cells;
}
function renderImageToken(token: HtmlTagToken): string {
const image = imageTokenToInlineImage(token, undefined);
if (!image) {
@@ -38,6 +38,7 @@ import {
} from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionWorkspaces } from "@/test/seed-session";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -164,9 +165,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
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])));
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrder: ["project-a", "project-b"],
@@ -115,6 +115,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
type SidebarProjectHostTarget,
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -131,6 +132,8 @@ import {
getIsElectron,
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -504,6 +507,9 @@ function ProjectRowTrailingActions({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const localDaemonServerId = useLocalDaemonServerId();
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
const settingsTarget = project.hosts[0] ?? null;
return (
<View style={styles.projectTrailingActions}>
{worktreeTarget ? (
@@ -522,7 +528,8 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectPath={project.iconWorkingDir}
settingsTarget={settingsTarget}
projectPath={localProjectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -549,11 +556,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
settingsTarget,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
settingsTarget: { serverId: string; projectId: string } | null;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
@@ -561,10 +570,10 @@ function ProjectKebabMenu({
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
if (!settingsTarget) return;
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
}, [settingsTarget]);
const canOpenProjectSettings = settingsTarget !== null;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
@@ -609,6 +618,10 @@ function ProjectKebabMenu({
{t("sidebar.project.actions.openNewWindow")}
</DropdownMenuItem>
) : null}
<OpenInFileManagerMenuItem
path={projectPath}
testID={`sidebar-project-menu-open-folder-${projectKey}`}
/>
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
@@ -660,6 +673,7 @@ function WorkspaceRowRightGroup({
isPinned?: boolean;
onTogglePin?: () => void;
}) {
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
@@ -698,6 +712,7 @@ function WorkspaceRowRightGroup({
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspacePath}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
@@ -901,10 +916,10 @@ function NewWorkspaceGhostRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
}, [displayName, onWorkspacePress, worktreeTarget]);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.newWorkspaceGhostRow,
@@ -981,10 +996,10 @@ function ProjectHeaderRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
}, [displayName, onWorkspacePress, worktreeTarget]);
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -1748,7 +1763,6 @@ function ProjectBlock({
}
void removeProjectFromHosts({
projectKey: project.projectKey,
targets: readiness.targets,
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
})
@@ -1817,7 +1831,7 @@ function ProjectBlock({
}
return (
<View style={styles.projectBlock}>
<View role="group" accessibilityLabel={displayName} style={styles.projectBlock}>
<ProjectHeaderRow
project={project}
displayName={displayName}
@@ -43,6 +43,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
hosts: [
{
serverId: "srv",
projectId: "project",
iconWorkingDir: "/repo",
canCreateWorktree: true,
},
@@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -58,6 +59,7 @@ interface SidebarWorkspaceMenuProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
openInFileManagerPath?: string | null;
}
export function SidebarWorkspaceMenu({
@@ -73,6 +75,7 @@ export function SidebarWorkspaceMenu({
archiveShortcutKeys,
isPinned,
onTogglePin,
openInFileManagerPath,
}: SidebarWorkspaceMenuProps) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
@@ -137,6 +140,10 @@ export function SidebarWorkspaceMenu({
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
</DropdownMenuItem>
) : null}
<OpenInFileManagerMenuItem
path={openInFileManagerPath}
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
/>
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
@@ -8,7 +8,7 @@ import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
+8 -1
View File
@@ -299,7 +299,14 @@ export function ComboboxItem({
);
return (
<Pressable testID={testID} disabled={disabled} onPress={onPress} style={itemPressableStyle}>
<Pressable
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
disabled={disabled}
onPress={onPress}
style={itemPressableStyle}
>
{leadingContent}
<View style={itemContentStyle}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
@@ -9,6 +9,7 @@ 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 { seedSessionWorkspaces } from "@/test/seed-session";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
@@ -93,7 +94,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
act(() => {
setHostProfiles([hostProfile()]);
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
seedSessionWorkspaces(
"srv",
new Map([
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
@@ -139,7 +140,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("status");
useSessionStore.getState().setWorkspaces(
seedSessionWorkspaces(
"srv",
new Map([
[
@@ -211,18 +212,14 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
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" })]]),
);
seedSessionWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
seedSessionWorkspaces(
"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().toggleHostFilter("host-b");
@@ -3,101 +3,56 @@ import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
it("selects the host-local project id for a git workspace", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
selectActiveGitWorkspaceProject("server-1", {
projectId: "prj_local",
projectKind: "git",
projectRootPath: "/repo/project",
}),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
projectId: "prj_local",
repoRoot: "/repo/project",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
it("ignores non-git workspaces and blank coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
selectActiveGitWorkspaceProject("server-1", {
projectId: "project",
projectKind: "directory",
projectRootPath: "/repo",
}),
).toBeNull();
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
it("shows only after a successful config read with no setup command", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm i" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
it("routes by server id and project id", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
projectId: "prj_local",
repoRoot: "/repo/project",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
).toMatchObject({
id: "worktree-setup-missing:server-1:prj_local",
projectSettingsRoute: "/settings/projects/server-1/prj_local",
testID: "worktree-setup-callout-prj_local",
});
});
});
@@ -6,16 +6,11 @@ export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
projectId: string;
repoRoot: string;
}
@@ -43,13 +38,13 @@ export function selectActiveGitWorkspaceProject(
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
const projectId = workspace.projectId;
const repoRoot = workspace.projectRootPath.trim();
if (!projectId.trim() || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
return { serverId, projectId, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
@@ -59,7 +54,7 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
const calloutKey = `worktree-setup-missing:${project.serverId}:${project.projectId}`;
return {
id: calloutKey,
@@ -68,8 +63,8 @@ export function buildWorktreeSetupCalloutPolicy(
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
projectSettingsRoute: buildProjectSettingsRoute(project.serverId, project.projectId),
testID: `worktree-setup-callout-${project.projectId}`,
};
}
@@ -14,11 +14,12 @@ import {
export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection();
const activeProject = useWorkspaceFields(
const selectedWorkspaceProject = useWorkspaceFields(
selection?.serverId ?? null,
selection?.workspaceId ?? null,
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
);
const activeProject = selectedWorkspaceProject;
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
const callouts = useSidebarCallouts();
const router = useRouter();
+11 -2
View File
@@ -204,25 +204,34 @@ describe("server data push router", () => {
fake.emit({
type: "subscribe_checkout_diff_response",
payload: { subscriptionId, cwd, files: [], error: null, requestId: "diff-1" },
payload: {
subscriptionId,
cwd,
files: [],
error: null,
diffTooLarge: true,
requestId: "diff-1",
},
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
files: [],
error: null,
diffTooLarge: true,
requestId: "diff-1",
});
fake.emit({
type: "checkout_diff_update",
payload: { subscriptionId, cwd, files: [], error: null },
payload: { subscriptionId, cwd, files: [], error: null, diffTooLarge: true },
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
files: [],
error: null,
diffTooLarge: true,
requestId: `subscription:${subscriptionId}`,
});
+6
View File
@@ -420,6 +420,9 @@ function applyCheckoutDiffUpdate(input: {
cwd: input.message.payload.cwd,
files: orderCheckoutDiffFiles(input.message.payload.files),
error: input.message.payload.error,
...(input.message.payload.diffTooLarge !== undefined
? { diffTooLarge: input.message.payload.diffTooLarge }
: {}),
requestId: `subscription:${input.message.payload.subscriptionId}`,
},
});
@@ -440,6 +443,9 @@ function applyCheckoutDiffSubscribeResponse(input: {
cwd: input.message.payload.cwd,
files: orderCheckoutDiffFiles(input.message.payload.files),
error: input.message.payload.error,
...(input.message.payload.diffTooLarge !== undefined
? { diffTooLarge: input.message.payload.diffTooLarge }
: {}),
requestId: input.message.payload.requestId,
},
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import type { ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
} from "./tree";
function makeDirectoryEntry(name: string, path: string): ExplorerEntry {
return {
name,
path,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("file explorer tree", () => {
it("flattens a deeply expanded tree without consuming the call stack", () => {
const depth = 10_000;
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const expandedPaths = new Set<string>(["."]);
let parentPath = ".";
for (let index = 1; index <= depth; index += 1) {
const childPath = `directory-${index}`;
directories.set(parentPath, {
path: parentPath,
entries: [makeDirectoryEntry(childPath, childPath)],
});
expandedPaths.add(childPath);
parentPath = childPath;
}
directories.set(parentPath, { path: parentPath, entries: [] });
const rows = flattenExplorerTree({
directories,
expandedPaths,
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(depth);
expect(rows[0]).toEqual({
entry: makeDirectoryEntry("directory-1", "directory-1"),
depth: 0,
});
expect(rows.at(-1)).toEqual({
entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`),
depth: depth - 1,
});
});
it("flattens a large expanded directory without spreading its rows into the parent", () => {
const fileCount = 150_000;
const files = Array.from(
{ length: fileCount },
(_, index): ExplorerEntry => ({
name: `file-${index.toString().padStart(6, "0")}`,
path: `generated/file-${index}`,
kind: "file",
size: index,
modifiedAt: "2026-01-01T00:00:00.000Z",
}),
);
const child = makeDirectoryEntry("generated", "generated");
const directories = new Map([
[".", { path: ".", entries: [child] }],
["generated", { path: "generated", entries: files }],
]);
const rows = flattenExplorerTree({
directories,
expandedPaths: new Set([".", "generated"]),
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(fileCount + 1);
expect(rows[0]).toEqual({ entry: child, depth: 0 });
expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 });
});
it("restores five rendered directory levels rather than counting path segments", async () => {
const paths = [
"generated/cache/level-1",
"generated/cache/level-1/level-2",
"generated/cache/level-1/level-2/level-3",
"generated/cache/level-1/level-2/level-3/level-4",
"generated/cache/level-1/level-2/level-3/level-4/level-5",
"generated/cache/level-1/level-2/level-3/level-4/level-5/level-6",
];
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("level-1", paths[0])],
};
directories.set(".", rootDirectory);
for (let index = 0; index < paths.length - 1; index += 1) {
directories.set(paths[index], {
path: paths[index],
entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])],
});
}
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(paths),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return directories.get(path) ?? null;
},
});
expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5);
expect(requestedPaths).toEqual(paths.slice(0, 5));
expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]);
});
it("does not restore persisted descendants beneath a collapsed rendered directory", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("parent", "parent")],
};
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return null;
},
});
expect(requestedPaths).toEqual([]);
expect(expandedPaths).toEqual(["."]);
});
it("preserves expansion changes made while persisted directories are restoring", () => {
const paths = reconcileRestoredExpandedPaths({
persistedExpandedPaths: new Set([".", "parent", "parent/child"]),
currentExpandedPaths: new Set([".", "parent/child", "manual"]),
restoredExpandedPaths: [".", "parent"],
});
expect(paths).toEqual([".", "manual"]);
});
it("applies a directory click to the latest restored expansion paths", () => {
const expanded = setExpandedDirectoryPath({
currentExpandedPaths: [".", "restored"],
directoryPath: "manual",
expanded: true,
});
const collapsed = setExpandedDirectoryPath({
currentExpandedPaths: expanded,
directoryPath: "manual",
expanded: false,
});
expect(expanded).toEqual([".", "restored", "manual"]);
expect(collapsed).toEqual([".", "restored"]);
});
it("shows hidden files before waiting for expanded directories to restore", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry(".hidden", ".hidden")],
};
let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void;
const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => {
resolveDirectory = resolve;
});
let hiddenFilesAreShown = false;
const restoration = showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set([".hidden"]),
showHiddenFiles: () => {
hiddenFilesAreShown = true;
},
requestDirectoryListing: () => directoryListing,
});
expect(hiddenFilesAreShown).toBe(true);
resolveDirectory({ path: ".hidden", entries: [] });
await expect(restoration).resolves.toEqual([".", ".hidden"]);
});
});
+203
View File
@@ -0,0 +1,203 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { filterVisibleExplorerEntries } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
export interface ExplorerTreeRow {
entry: ExplorerEntry;
depth: number;
}
interface FlattenExplorerTreeInput {
directories: ReadonlyMap<string, ExplorerDirectory>;
expandedPaths: ReadonlySet<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
RestoreExpandedDirectoriesInput,
"showHiddenFiles"
> {
showHiddenFiles: () => void;
}
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: string[];
}
interface SetExpandedDirectoryPathInput {
currentExpandedPaths: readonly string[];
directoryPath: string;
expanded: boolean;
}
export function flattenExplorerTree({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: FlattenExplorerTreeInput): ExplorerTreeRow[] {
const root = directories.get(".");
if (!root) {
return [];
}
const rows: ExplorerTreeRow[] = [];
const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed();
while (pending.length > 0) {
const row = pending.pop();
if (!row) {
break;
}
rows.push(row);
const entry = row.entry;
if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) {
continue;
}
const childDirectory = directories.get(entry.path);
if (!childDirectory) {
continue;
}
const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles);
for (let index = childRows.length - 1; index >= 0; index -= 1) {
pending.push(childRows[index]);
}
}
return rows;
}
export async function restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) {
const pathsToRequest: string[] = [];
for (const directory of parentDirectories) {
const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
for (const entry of entries) {
const isPersistedExpandedDirectory =
entry.kind === "directory" && persistedExpandedPaths.has(entry.path);
if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) {
pathsToRequest.push(entry.path);
restoredPathSet.add(entry.path);
}
}
}
if (pathsToRequest.length === 0) {
break;
}
const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)),
);
parentDirectories = [];
for (const directory of requestedDirectories) {
if (!directory) {
continue;
}
restoredPaths.push(directory.path);
parentDirectories.push(directory);
}
}
return restoredPaths;
}
export function showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles: true,
requestDirectoryListing,
});
}
export function reconcileRestoredExpandedPaths({
persistedExpandedPaths,
currentExpandedPaths,
restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] {
const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) {
if (!currentExpandedPaths.has(path)) {
reconciledPaths.delete(path);
}
}
for (const path of currentExpandedPaths) {
if (!persistedExpandedPaths.has(path)) {
reconciledPaths.add(path);
}
}
return Array.from(reconciledPaths);
}
export function setExpandedDirectoryPath({
currentExpandedPaths,
directoryPath,
expanded,
}: SetExpandedDirectoryPathInput): string[] {
const nextPaths = new Set(currentExpandedPaths);
if (expanded) {
nextPaths.add(directoryPath);
} else {
nextPaths.delete(directoryPath);
}
return Array.from(nextPaths);
}
function rowsForDirectory(
directory: ExplorerDirectory,
depth: number,
sortOption: SortOption,
showHiddenFiles: boolean,
): ExplorerTreeRow[] {
const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
const sortedEntries = sortExplorerEntries(visibleEntries, sortOption);
return sortedEntries.map((entry) => ({ entry, depth }));
}
function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
switch (sortOption) {
case "name":
return a.name.localeCompare(b.name);
case "modified":
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
case "size":
return b.size - a.size;
}
});
return sorted;
}
+8
View File
@@ -119,6 +119,7 @@ import {
type InlineReviewActions,
} from "@/review";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -1755,6 +1756,7 @@ interface DiffBodyContentProps {
notGit: boolean;
isDiffLoading: boolean;
diffErrorMessage: string | null;
diffTooLarge: boolean;
hasChanges: boolean;
emptyMessage: string;
children: ReactElement;
@@ -1768,6 +1770,7 @@ function DiffBodyContent({
notGit,
isDiffLoading,
diffErrorMessage,
diffTooLarge,
hasChanges,
emptyMessage,
children,
@@ -1803,6 +1806,9 @@ function DiffBodyContent({
</View>
);
}
if (diffTooLarge) {
return <DiffTooLargeState />;
}
if (diffErrorMessage) {
return (
<View style={styles.errorContainer}>
@@ -2693,6 +2699,7 @@ export function GitDiffPane({
selectBase: handleSelectBase,
files,
diffPayloadError,
diffTooLarge,
isDiffLoading,
reviewActions,
reviewAttachment,
@@ -2855,6 +2862,7 @@ export function GitDiffPane({
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
diffTooLarge={diffTooLarge}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { FileWarning } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedFileWarning = withUnistyles(FileWarning);
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function DiffTooLargeState() {
const { t } = useTranslation();
return (
<View style={styles.container} testID="diff-too-large">
<ThemedFileWarning size={ICON_SIZE.lg} uniProps={foregroundMutedIconColorMapping} />
<View style={styles.copy}>
<Text style={styles.title}>{t("workspace.git.diff.previewTooLargeTitle")}</Text>
<Text style={styles.description}>{t("workspace.git.diff.previewTooLargeDescription")}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
copy: {
alignItems: "center",
gap: theme.spacing[1],
maxWidth: 360,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
textAlign: "center",
},
}));
+30
View File
@@ -44,6 +44,24 @@ describe("buildForgeBranchTreeUrl", () => {
).toBe("https://codeberg.org/acme/repo/src/branch/main");
});
it("preserves a non-default port for a self-hosted https remote", () => {
expect(
buildForgeBranchTreeUrl("forgejo", {
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
branch: "master",
}),
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master");
});
it("omits the port for a self-hosted remote on the default port", () => {
expect(
buildForgeBranchTreeUrl("forgejo", {
remoteUrl: "https://home-git.example.com/team/repo.git",
branch: "master",
}),
).toBe("https://home-git.example.com/team/repo/src/branch/master");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildForgeBranchTreeUrl("github", {
@@ -131,6 +149,18 @@ describe("buildForgeBlobUrl", () => {
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
});
it("preserves a non-default port for a self-hosted https remote", () => {
expect(
buildForgeBlobUrl("forgejo", {
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
branch: "master",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master/src/index.ts#L12-L20");
});
it("canonicalizes the github.com SSH-alias host to the web host", () => {
expect(
buildForgeBlobUrl("github", {
+20 -8
View File
@@ -29,6 +29,8 @@ export interface ForgeBranchTreeUrlInput {
interface ForgeWebLocation {
host: string;
/** Non-default port for a self-hosted http(s) origin, or undefined. */
port?: string;
repo: string;
}
@@ -58,18 +60,28 @@ function resolveForgeWebLocation(
if (!location || !isValidRepoPath(location.path)) {
return null;
}
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
const webHost =
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
? normalizeHost(cloudHosts[0])
: location.host;
return { host: webHost, repo: location.path };
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
const isCloudHost = cloudHosts.includes(location.host);
const webHost = isCloudHost ? cloudHosts[0] : location.host;
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
// web port, and a canonicalized cloud host always serves on the default port.
const port =
!isCloudHost && (location.transport === "http" || location.transport === "https")
? location.port
: undefined;
return { host: webHost, port, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
/** Host, plus `:port` when the remote pins a non-default port. */
function forgeAuthority(location: ForgeWebLocation): string {
return location.port ? `${location.host}:${location.port}` : location.host;
}
function normalizeBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
@@ -102,7 +114,7 @@ export function buildForgeBranchTreeUrl(
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
@@ -114,7 +126,7 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
+1
View File
@@ -92,6 +92,7 @@ export function useCheckoutDiffQuery({
return {
files: payload?.files ?? [],
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null && enabled && isConnected,
isFetching: false,
isError: Boolean(payloadError),
+2
View File
@@ -79,6 +79,7 @@ export function useWorkingDiff({
const {
files,
payloadError: diffPayloadError,
diffTooLarge,
isLoading: isDiffLoading,
} = useCheckoutDiffQuery({
serverId,
@@ -123,6 +124,7 @@ export function useWorkingDiff({
selectBase,
files,
diffPayloadError,
diffTooLarge,
isDiffLoading,
reviewActions,
reviewAttachment,
@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
import type { SharedValue } from "react-native-reanimated";
export interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
/** Read the app-wide keyboard inset without loading its native provider implementation. */
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}
+7 -10
View File
@@ -4,10 +4,7 @@ import type {
ProjectAddResponse,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
} from "@/stores/session-store";
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
type OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
@@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput {
isConnected: boolean;
canAddProject: boolean;
client: Pick<DaemonClient, "addProject"> | null;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
interface ProjectRegistrationCallbacks {
serverId: string;
isConnected: boolean;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export interface RegisterProjectDescriptorInput {
serverId: string;
project: WorkspaceProjectDescriptorPayload;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
const serverId = input.serverId.trim();
if (!serverId) return false;
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
input.upsertProject(serverId, normalizeProjectDescriptor(input.project));
input.setHasHydratedWorkspaces(serverId, true);
return true;
}
@@ -109,7 +106,7 @@ export async function openProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -78,20 +78,25 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
hosts?: WorkspaceStructureProject["hosts"];
hosts?: Array<
Omit<WorkspaceStructureProject["hosts"][number], "projectId"> & { projectId?: string }
>;
}): 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,
},
],
hosts: Array.from(
input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
),
workspaceKeys: input.workspaceKeys,
};
}
@@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => {
expect.objectContaining({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{
serverId: "host-a",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
{
serverId: "host-b",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
],
workspaces: [
expect.objectContaining({
@@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => {
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
});
it("keeps a structurally disambiguated project key in status entries", () => {
const projectKey = "host:srv:project:prj_a";
const model = buildSidebarWorkspacePlacementModel({
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
});
const entries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"clone-a",
workspace({
id: "clone-a",
name: "main",
projectId: "prj_a",
projectDisplayName: "acme/app",
}),
],
]),
},
],
});
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
});
});
describe("shouldShowSidebarHostLabels", () => {
@@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
projectKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const projectKey = input.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
@@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
projectKey: placement.projectKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});
@@ -1,6 +1,10 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
function createExplorerState(): AgentFileExplorerState {
@@ -88,9 +92,9 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<boolean> => {
): Promise<ExplorerDirectory | null> => {
if (!workspaceStateKey) {
return false;
return null;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
@@ -119,7 +123,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return false;
return null;
}
if (!client) {
@@ -129,7 +133,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return false;
return null;
}
try {
@@ -150,7 +154,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return nextState;
});
return true;
return directory;
} catch (error) {
updateExplorerState((state) => ({
...state,
@@ -161,7 +165,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
: t("workspace.fileExplorer.errors.failedToListDirectory"),
pendingRequest: null,
}));
return false;
return null;
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
@@ -1,11 +1,4 @@
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { createElement, useEffect, useMemo, type ReactNode } from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -23,16 +16,10 @@ import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
} from "@/hooks/keyboard-shift-policy";
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
type KeyboardShiftMode = "translate" | "padding";
interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
@@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
return createElement(KeyboardShiftContext.Provider, { value }, children);
}
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
@@ -4,7 +4,7 @@ import {
getOpenProjectFailureReason,
openProjectDirectly,
} from "@/hooks/open-project";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
const PROJECT_PATH = "/repo/project";
@@ -20,7 +20,7 @@ function buildProjectPayload() {
interface RecordedProject {
serverId: string;
project: ProjectWithoutWorkspacesDescriptor;
project: ProjectDescriptor;
}
interface RecordedHydrated {
@@ -40,7 +40,7 @@ function createFakeSession() {
return {
projects,
hydrated,
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
upsertProject: (serverId: string, project: ProjectDescriptor) => {
projects.push({ serverId, project });
},
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
@@ -83,7 +83,7 @@ describe("openProjectDirectly", () => {
project: projectPayload,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -117,7 +118,7 @@ describe("openProjectDirectly", () => {
project: buildProjectPayload(),
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -146,7 +147,7 @@ describe("openProjectDirectly", () => {
project: null,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectKey: null,
},
},
]);
@@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
+6 -6
View File
@@ -20,7 +20,7 @@ export function useOpenProject(
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
: false,
);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -31,13 +31,13 @@ export function useOpenProject(
isConnected,
canAddProject,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
return result;
},
[
addEmptyProject,
upsertProject,
canAddProject,
client,
isConnected,
@@ -57,7 +57,7 @@ export function useCloneGithubProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -69,10 +69,10 @@ export function useCloneGithubProject(
...(cloneProtocol ? { cloneProtocol } : {}),
isConnected,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
},
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
);
}
+40 -192
View File
@@ -1,242 +1,90 @@
import { describe, expect, it } from "vitest";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
import {
deriveProjectsFromReplica,
type ProjectHostReplica,
type ProjectHostRuntimeState,
} from "@/hooks/use-projects";
function runtimeState(input: {
serverId: string;
isOnline: boolean;
isLoading?: boolean;
isFetching?: boolean;
error?: string | null;
}): ProjectHostRuntimeState {
function project(id: string, name: string, root: string): ProjectDescriptor {
return {
projectId: id,
projectKey: `remote:github.com/${name}`,
projectDisplayName: name,
projectCustomName: null,
projectRootPath: root,
projectKind: "git",
};
}
function runtimeState(input: Partial<ProjectHostRuntimeState> & { serverId: string }) {
return {
serverId: input.serverId,
isOnline: input.isOnline,
isOnline: input.isOnline ?? true,
isLoading: input.isLoading ?? false,
isFetching: input.isFetching ?? false,
error: input.error ?? null,
};
}
function workspace(input: {
id: string;
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
workspaceDirectory: input.cwd,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
title: null,
archivingAt: null,
status: "done",
statusEnteredAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function emptyProject(input: {
projectKey: string;
projectName: string;
cwd: string;
}): EmptyProjectDescriptor {
return {
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
projectKind: "git",
};
}
describe("deriveProjectsFromReplica", () => {
it("aggregates store workspaces and empty projects sorted by display name", () => {
it("derives projects from the project descriptor replica", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "z-main",
projectKey: "remote:github.com/acme/zeta",
projectName: "acme/zeta",
cwd: "/repo/zeta",
remoteUrl: "https://github.com/acme/zeta.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [
emptyProject({
projectKey: "remote:github.com/acme/alpha",
projectName: "acme/alpha",
cwd: "/repo/alpha",
}),
],
projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true }),
runtimeState({ serverId: "local" }),
runtimeState({ serverId: "laptop", isOnline: false }),
],
});
expect(result.projects.map((project) => project.projectName)).toEqual([
"acme/alpha",
"acme/zeta",
]);
expect(result.projects[0]?.hosts).toEqual([
expect.objectContaining({
serverId: "laptop",
isOnline: false,
repoRoot: "/repo/alpha",
workspaceCount: 0,
}),
]);
expect(result.hostErrors).toEqual([]);
});
it("maps runtime directory status into loading and host errors", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
runtimeState({
serverId: "laptop",
isOnline: true,
isLoading: true,
error: "laptop unavailable",
}),
],
expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]);
expect(result.projects[0]?.hosts[0]).toMatchObject({
serverId: "laptop",
projectId: "prj_alpha",
repoRoot: "/repo/alpha",
workspaceCount: 0,
});
expect(result.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
expect(result.hostErrors).toEqual([
{
serverId: "laptop",
serverName: "Laptop",
message: "laptop unavailable",
},
]);
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
});
it("returns only the stable public project and host entry shapes", () => {
it("preserves runtime loading and error state", () => {
const result = deriveProjectsFromReplica({
replicas: [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_app", "acme/app", "/repo/app")],
},
],
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
runtimeStates: [
runtimeState({
serverId: "local",
isLoading: true,
isFetching: true,
error: "unavailable",
}),
],
});
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hostCount",
"hosts",
"onlineHostCount",
"projectCustomName",
"projectKey",
"projectName",
"totalWorkspaceCount",
]);
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
"gitRuntime",
"githubRuntime",
"isOnline",
"repoRoot",
"serverId",
"serverName",
"workspaceCount",
"workspaces",
]);
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
"currentBranch",
"id",
"name",
"status",
"workspaceKind",
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
expect(result.hostErrors).toEqual([
{ serverId: "local", serverName: "Local", message: "unavailable" },
]);
});
});
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from "@/runtime/host-runtime";
import {
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
@@ -24,7 +24,7 @@ export interface ProjectHostReplica {
serverId: string;
serverName: string;
workspaces: WorkspaceDescriptor[];
emptyProjects: EmptyProjectDescriptor[];
projects: ProjectDescriptor[];
}
export interface ProjectHostRuntimeState {
@@ -87,7 +87,7 @@ function selectProjectHostReplicas(
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(session?.workspaces.values() ?? []),
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
projects: Array.from(session?.projects.values() ?? []),
};
});
}
@@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: {
serverName: replica.serverName,
isOnline: runtimeState?.isOnline ?? false,
workspaces: replica.workspaces,
emptyProjects: replica.emptyProjects,
projects: replica.projects,
};
});
const hostErrors = input.replicas.flatMap((replica) => {
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useMemo } from "react";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
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 {
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
@@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: {
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, emptyProjects } = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
// a freshly-added project from the sidebar.
store.setEmptyProjects(serverId, emptyProjects);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
void runtime.refreshDirectories(serverId).catch((error) => {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
});
}
}, [isActive, runtime, serverIds]);
+5
View File
@@ -764,6 +764,8 @@ export const ar: TranslationResources = {
closeChangesTab: "إغلاق علامة تبويب التغييرات",
binaryFile: "ملف ثنائي",
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته",
previewTooLargeDescription: "ضيّق نطاق المقارنة لمعاينتها هنا",
unified: "الفرق الموحدة",
split: "فرق جنبًا إلى جنب",
switchToUnified: "التبديل إلى الفرق الموحد",
@@ -908,6 +910,8 @@ export const ar: TranslationResources = {
openSettings: "افتح إعدادات المشروع",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "إزالة المشروع",
removing: "جارٍ الإزالة...",
},
@@ -1831,6 +1835,7 @@ export const ar: TranslationResources = {
sendMessage: "أرسل رسالة",
queueMessage: "رسالة قائمة الانتظار",
muteUnmuteVoiceMode: "كتم وضع الصوت /unmute",
switchProject: "تبديل المشروع",
},
helpNotes: {
showKeyboardShortcuts: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.",
+5
View File
@@ -774,6 +774,8 @@ export const en = {
closeChangesTab: "Close Changes tab",
binaryFile: "Binary file",
tooLarge: "Diff too large to display",
previewTooLargeTitle: "This diff is too large to preview",
previewTooLargeDescription: "Narrow the comparison to preview it here",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "Switch to unified diff",
@@ -918,6 +920,8 @@ export const en = {
openSettings: "Open project settings",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Remove project",
removing: "Removing...",
},
@@ -1841,6 +1845,7 @@ export const en = {
sendMessage: "Send message",
queueMessage: "Queue message",
muteUnmuteVoiceMode: "Mute/unmute voice mode",
switchProject: "Switch project",
},
helpNotes: {
showKeyboardShortcuts: "Available when focus is not in a text field or terminal.",
+5
View File
@@ -795,6 +795,8 @@ export const es: TranslationResources = {
closeChangesTab: "Cerrar la pestaña Cambios",
binaryFile: "archivo binario",
tooLarge: "La diferencia es demasiado grande para mostrarse",
previewTooLargeTitle: "Este diff es demasiado grande para previsualizarlo",
previewTooLargeDescription: "Reduce la comparación para previsualizarla aquí",
unified: "Diferencia unificada",
split: "Diferencia de lado a lado",
switchToUnified: "Cambiar a diferencia unificada",
@@ -939,6 +941,8 @@ export const es: TranslationResources = {
openSettings: "Abrir la configuración del proyecto",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Eliminar proyecto",
removing: "Eliminando...",
},
@@ -1879,6 +1883,7 @@ export const es: TranslationResources = {
sendMessage: "enviar mensaje",
queueMessage: "mensaje de cola",
muteUnmuteVoiceMode: "Silenciar el modo de voz/unmute",
switchProject: "Cambiar proyecto",
},
helpNotes: {
showKeyboardShortcuts: "Disponible cuando el foco no está en un campo de texto o terminal.",
+5
View File
@@ -794,6 +794,8 @@ export const fr: TranslationResources = {
closeChangesTab: "Fermer l'onglet Modifications",
binaryFile: "Fichier binaire",
tooLarge: "Diff trop grand pour être affiché",
previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé",
previewTooLargeDescription: "Réduisez la comparaison pour la prévisualiser ici",
unified: "Différentiel unifié",
split: "Différent côte à côte",
switchToUnified: "Passer au diff unifié",
@@ -938,6 +940,8 @@ export const fr: TranslationResources = {
openSettings: "Ouvrir les paramètres du projet",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Supprimer le projet",
removing: "Suppression...",
},
@@ -1881,6 +1885,7 @@ export const fr: TranslationResources = {
sendMessage: "Envoyer un message",
queueMessage: "Message de file d'attente",
muteUnmuteVoiceMode: "Mode vocal/unmutemuet",
switchProject: "Changer de projet",
},
helpNotes: {
showKeyboardShortcuts:
+5
View File
@@ -775,6 +775,8 @@ export const ja: TranslationResources = {
closeChangesTab: "変更タブを閉じる",
binaryFile: "バイナリファイル",
tooLarge: "差分が大きすぎて表示できません",
previewTooLargeTitle: "この差分は大きすぎるためプレビューできません",
previewTooLargeDescription: "比較範囲を狭めると、ここでプレビューできます",
unified: "ユニファイド差分",
split: "左右比較",
switchToUnified: "ユニファイド差分に切り替え",
@@ -919,6 +921,8 @@ export const ja: TranslationResources = {
openSettings: "プロジェクト設定を開く",
openNewWindow: "新しいウィンドウで開く",
openNewWindowFailed: "新しいウィンドウを開けませんでした",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "プロジェクトを削除",
removing: "削除中...",
},
@@ -1847,6 +1851,7 @@ export const ja: TranslationResources = {
sendMessage: "メッセージを送信",
queueMessage: "メッセージをキューに追加",
muteUnmuteVoiceMode: "音声モードのミュートを切り替え",
switchProject: "プロジェクトを切り替え",
},
helpNotes: {
showKeyboardShortcuts:
+5
View File
@@ -786,6 +786,8 @@ export const ptBR: TranslationResources = {
closeChangesTab: "Fechar a aba Alterações",
binaryFile: "Arquivo binário",
tooLarge: "Diff grande demais para exibir",
previewTooLargeTitle: "Este diff é grande demais para visualizar",
previewTooLargeDescription: "Reduza a comparação para visualizá-la aqui",
unified: "Diff unificado",
split: "Diff lado a lado",
switchToUnified: "Alternar para diff unificado",
@@ -930,6 +932,8 @@ export const ptBR: TranslationResources = {
openSettings: "Abrir configurações do projeto",
openNewWindow: "Abrir em nova janela",
openNewWindowFailed: "Não foi possível abrir uma nova janela",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Remover projeto",
removing: "Removendo...",
},
@@ -1862,6 +1866,7 @@ export const ptBR: TranslationResources = {
sendMessage: "Enviar mensagem",
queueMessage: "Enfileirar mensagem",
muteUnmuteVoiceMode: "Silenciar/ativar modo de voz",
switchProject: "Trocar projeto",
},
helpNotes: {
showKeyboardShortcuts:
+5
View File
@@ -786,6 +786,8 @@ export const ru: TranslationResources = {
closeChangesTab: "Закрыть вкладку «Изменения»",
binaryFile: "Бинарный файл",
tooLarge: "Разница слишком велика для отображения",
previewTooLargeTitle: "Этот diff слишком велик для предпросмотра",
previewTooLargeDescription: "Сузьте сравнение, чтобы просмотреть его здесь",
unified: "Единый дифференциал",
split: "Параллельная разница",
switchToUnified: "Переключить на объединенный diff",
@@ -930,6 +932,8 @@ export const ru: TranslationResources = {
openSettings: "Открыть настройки проекта",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Удалить проект",
removing: "Удаление...",
},
@@ -1869,6 +1873,7 @@ export const ru: TranslationResources = {
sendMessage: "Отправить сообщение",
queueMessage: "Сообщение в очереди",
muteUnmuteVoiceMode: "Отключить голосовой режим /unmute",
switchProject: "Сменить проект",
},
helpNotes: {
showKeyboardShortcuts: "Доступно, когда фокус находится не в текстовом поле или терминале.",
+5
View File
@@ -756,6 +756,8 @@ export const zhCN: TranslationResources = {
closeChangesTab: "关闭“更改”标签页",
binaryFile: "二进制文件",
tooLarge: "Diff 过大,无法显示",
previewTooLargeTitle: "此 diff 过大,无法预览",
previewTooLargeDescription: "缩小比较范围即可在此预览",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "切换到统一差异视图",
@@ -899,6 +901,8 @@ export const zhCN: TranslationResources = {
openSettings: "打开 project 设置",
openNewWindow: "在新窗口中打开",
openNewWindowFailed: "无法打开新窗口",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "移除 project",
removing: "正在移除...",
},
@@ -1810,6 +1814,7 @@ export const zhCN: TranslationResources = {
sendMessage: "发送消息",
queueMessage: "消息排队",
muteUnmuteVoiceMode: "静音/取消静音语音模式",
switchProject: "切换项目",
},
helpNotes: {
showKeyboardShortcuts: "焦点不在文本输入框或终端内时可用。",
+1
View File
@@ -44,6 +44,7 @@ export type KeyboardActionId =
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.pin"
@@ -29,6 +29,7 @@ export type KeyboardActionId =
| "workspace.terminal.new"
| "sidebar.toggle.right"
| "workspace.new"
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.pin";
@@ -62,6 +63,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope }
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope }
| { id: "workspace.pin"; scope: KeyboardActionScope };
@@ -142,6 +142,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
action: "workspace.new",
},
{
name: "matches Cmd+P to switch project on mac",
event: { key: "p", code: "KeyP", metaKey: true },
context: { isMac: true, commandCenterOpen: false },
action: "workspace.project.pick",
},
{
name: "matches Ctrl+P to switch project on non-mac",
event: { key: "p", code: "KeyP", ctrlKey: true },
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
action: "workspace.project.pick",
},
{
name: "matches question-mark shortcut to toggle the shortcuts dialog",
event: { key: "?", code: "Slash", shiftKey: true },
@@ -410,6 +422,16 @@ describe("keyboard-shortcuts", () => {
event: { key: "?", code: "Slash", shiftKey: true },
context: { focusScope: "message-input" },
},
{
name: "does not switch project with Ctrl+P on non-mac while terminal is focused",
event: { key: "p", code: "KeyP", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" },
},
{
name: "does not switch project with Cmd+P while the command center is open",
event: { key: "p", code: "KeyP", metaKey: true },
context: { isMac: true, commandCenterOpen: true },
},
{
name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)",
event: { key: "w", code: "KeyW", ctrlKey: true },
@@ -131,6 +131,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
"switch-project": "settings.shortcuts.help.switchProject",
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
"workspace-tab-new": "settings.shortcuts.help.newTab",
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
@@ -231,6 +232,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Switch project (New Workspace screen) ---
{
id: "workspace-project-pick-cmd-p-mac",
action: "workspace.project.pick",
combo: "Cmd+P",
when: { mac: true, commandCenter: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
keys: ["mod", "P"],
},
},
{
id: "workspace-project-pick-ctrl-p-non-mac",
action: "workspace.project.pick",
combo: "Ctrl+P",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
keys: ["mod", "P"],
},
},
// --- Archive workspace ---
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
@@ -30,6 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }],
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
["workspace.pin", { id: "workspace.pin", scope: "sidebar" }],
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
@@ -42,6 +42,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
"workspace.new": { id: "workspace.new", scope: "sidebar" },
"workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" },
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
"workspace.pin": { id: "workspace.pin", scope: "sidebar" },
"worktree.new": { id: "worktree.new", scope: "sidebar" },
+4
View File
@@ -17,6 +17,7 @@ import {
resolveDiffLayout,
SharedDiffView,
} from "@/git/diff-pane";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -120,6 +121,9 @@ function WorkingDiffBody({
if (workingDiff.notGit) {
return <PanelState message={t("workspace.git.diff.notRepository")} />;
}
if (workingDiff.diffTooLarge) {
return <DiffTooLargeState />;
}
if (workingDiff.diffPayloadError) {
return (
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
+43 -32
View File
@@ -4,14 +4,7 @@ import type {
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
export interface HostProjectListItem {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
export type HostProjectListItem = WorkspaceStructureProject;
export interface HostProjectRouteContext {
serverId: string;
@@ -31,33 +24,21 @@ export function canCreateWorktreeForProjectKind(
return projectKind === "git";
}
export function buildHostProjectList(input: {
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
}));
}
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
const projectKey = trimOptional(route.projectId);
const projectId = route.projectId?.trim() || undefined;
const iconWorkingDir = trimOptional(route.sourceDirectory);
if (!projectKey || !iconWorkingDir) {
if (!projectId || !iconWorkingDir) {
return null;
}
return {
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKey: projectId,
projectName: trimOptional(route.displayName) || projectId,
projectKind: "git",
iconWorkingDir,
hosts: [
{
serverId: route.serverId,
projectId,
iconWorkingDir,
canCreateWorktree: true,
},
@@ -73,20 +54,24 @@ export function hostProjectFromWorkspace(input: {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const projectId = input.workspace.projectId.trim() || undefined;
if (!projectId) {
return null;
}
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
if (!iconWorkingDir) {
return null;
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKey: projectId,
projectName: input.workspace.projectDisplayName || projectId,
projectKind: input.workspace.projectKind,
iconWorkingDir,
hosts: [
{
serverId: input.serverId,
projectId: input.workspace.projectId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
@@ -99,11 +84,25 @@ function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree);
}
function getHostProjectPlacement(
project: HostProjectListItem,
serverId: string,
): WorkspaceStructureHostPlacement | null {
for (const host of project.hosts) {
if (host.serverId === serverId) return host;
}
return null;
}
export function getHostProjectSourceDirectory(
project: HostProjectListItem,
serverId: string,
): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
return getHostProjectPlacement(project, serverId)?.iconWorkingDir ?? null;
}
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
return getHostProjectPlacement(project, serverId)?.projectId ?? null;
}
export function canCreateWorkspaceForHostProject(input: {
@@ -111,7 +110,7 @@ export function canCreateWorkspaceForHostProject(input: {
serverId: string;
allowAllProjects: boolean;
}): boolean {
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
const host = getHostProjectPlacement(input.project, input.serverId);
if (!host) {
return false;
}
@@ -144,8 +143,20 @@ export function resolveInitialWorkspaceProject(input: {
if (!candidate) {
continue;
}
const candidatePlacement = candidate.hosts.find(
(host) => host.serverId === input.serverId && host.projectId,
);
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
(candidatePlacement
? input.projects.find((project) =>
project.hosts.some(
(host) =>
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
),
)
: undefined) ??
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,

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