Compare commits

..

41 Commits

Author SHA1 Message Date
Mohamed Boudra
bb6a4db6e0 chore(release): cut 0.1.87 2026-05-30 09:49:45 +07:00
Mohamed Boudra
2a13a082b7 Changelog for 0.1.87 2026-05-30 09:48:43 +07:00
Mohamed Boudra
eea5932a21 Fix mobile bottom sheets reopening after dismiss 2026-05-30 09:43:49 +07:00
paseo-ai[bot]
e04bb942e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 16:05:39 +00:00
Mohamed Boudra
3c3574d670 Fix intermittent Android crash by upgrading Reanimated to 4.3.1
Reanimated 4.1.x crashes on Android with a NullPointerException in
ViewGroup.dispatchDraw (ReactViewGroup.dispatchDraw) — a child view is
null mid-draw because Reanimated commits a ShadowTree change during the
Android draw pass, mutating the view hierarchy while it's being painted.
Fixed upstream in 4.3.0 (software-mansion/react-native-reanimated#9072).

4.3.x requires react-native-worklets 0.8.x, so worklets moves 0.5.1 ->
0.8.3 in lockstep. react-native is pinned to exact 0.81.5 so the
re-resolve can't pull 0.81.6 (which needs react ^19.1.4, conflicting with
the react 19.1.0 override), and reanimated/worklets are added to root
overrides to force a single version across the tree — two copies of the
worklets runtime throw a version-mismatch at startup.
2026-05-29 23:01:07 +07:00
Mohamed Boudra
44863ec1dd Forward subagent permission requests 2026-05-29 22:02:18 +07:00
Mohamed Boudra
47414abc5e website tweaks 2026-05-29 19:33:57 +07:00
paseo-ai[bot]
9860dd36ef fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 11:50:20 +00:00
Mohamed Boudra
9e0c9d0662 chore(release): cut 0.1.86 2026-05-29 18:46:22 +07:00
Mohamed Boudra
654ebadaf5 Add 0.1.86 changelog entry 2026-05-29 18:27:57 +07:00
Mohamed Boudra
c2b4f3aa18 Add projectIconToDataUri to the projects-screen test mock 2026-05-29 18:27:48 +07:00
Mohamed Boudra
64e3a7152d Publish Electron migration post 2026-05-29 18:27:35 +07:00
Mohamed Boudra
daae71d1a8 Add Grok to the website and supported-providers docs
Adds the /grok SEO landing page (and its auto-generated route/sitemap
entries), the /agents directory card, and the Grok line in the ACP
catalog docs, following the Grok Build provider added to the in-app
catalog.
2026-05-29 18:10:51 +07:00
Mohamed Boudra
c1453b25a7 Add Claude fast mode toggle 2026-05-29 18:01:56 +07:00
Mohamed Boudra
c47f3190d8 Add Grok Build to the ACP provider catalog
Verified the ACP entrypoint against a real install (grok 0.2.11): the
command is `grok agent stdio`, and an initialize handshake returns a
valid ACP response (protocolVersion 1, model grok-build). Running
prompts requires a SuperGrok or X Premium+ login.
2026-05-29 17:59:03 +07:00
Mohamed Boudra
0803d5ff41 Show model selector provider errors 2026-05-29 17:16:35 +07:00
Mohamed Boudra
7ed0ce97df Fix local speech worker audio transport
Worker IPC now carries plain byte payloads instead of Node Buffer objects, so provider code receives process-local Buffers instead of transport-shaped views.
2026-05-29 17:09:01 +07:00
Mohamed Boudra
fcd93a23ac Add metadata generation page to public docs 2026-05-29 15:57:58 +07:00
Mohamed Boudra
ef3b8a42fd Merge branch 'main' of github.com:getpaseo/paseo 2026-05-29 14:16:19 +07:00
Mohamed Boudra
8b1bb90ce3 Show colored icons for projects without an icon
The letter-on-grey placeholder is replaced by a fill whose color is a
deterministic hash of the project key, so each project keeps a stable
color across the sidebar, projects list, and settings header. Mid-
saturation tones keep the white letter legible in both themes.
2026-05-29 14:06:47 +07:00
Mohamed Boudra
524aef5d75 Add Parakeet v3 local STT model for multilingual dictation
v2 is English-only; v3 covers 25 European languages with automatic
language detection. The archive shares v2's filenames, architecture,
sample rate, and feature dim, so no runtime loader changes are needed.

Document that the `language` config field and PASEO_*_LANGUAGE vars
only steer the OpenAI STT provider — the local Parakeet models ignore
them, so multilingual local dictation means selecting the v3 model.
2026-05-29 14:06:18 +07:00
Mohamed Boudra
69d36c76fe Show connected host daemon versions on the About page (#1220)
* Show connected host daemon versions on the About page

* Update About E2E assertion for the App version label
2026-05-29 14:51:53 +08:00
Mohamed Boudra
38f0560f9b Syntax-highlight Edit, Write, and Read tool calls (#1218)
Highlighting reuses the existing code-block tokenizer and LRU cache, runs
lazily on expand, and falls back to plain text above a size cap so a large
Read can't stall the main thread. The diff highlighter reconstructs the old
and new file text by position rather than from @@ line ranges, so diffs that
arrive without real hunk headers still highlight.

Read content is normalized server-side: the cat -n line-number gutter some
providers emit is stripped so content is uniformly raw source, with the first
line number surfaced as offset for the client to rebuild the gutter.
2026-05-29 14:51:14 +08:00
Matt Cowger
25d6fc8515 Prefer configured provider fallbacks for metadata generation (#1219)
* Prefer configured provider fallbacks for metadata generation

* Update client config test for metadata defaults
2026-05-29 06:38:22 +00:00
Mohamed Boudra
d1eb976653 Fix duplicate /clear in the slash command menu
A client command and a provider command can share a name (e.g. clear).
Both produced the same option id, which became a duplicate React key —
React then left stale rows and scrambled ordering, so /clear showed
twice and ranking broke as the query narrowed. Drop the colliding
provider command and keep the client one, which has extra affordances.
2026-05-29 13:23:29 +07:00
Mohamed Boudra
14d176a4e2 Add manual refresh button to git diff controls (#1216)
* feat(git): add manual refresh button to diff controls

Adds a checkout_refresh RPC that forces a hard re-read of the git and
GitHub snapshot plus the diff, bypassing polling. The diff controls now
show a refresh button (feature-gated) that triggers it and surfaces
errors via a toast — an escape hatch when the polled state goes stale.

* refactor(git): address review — dotted RPC name + withUnistyles

- Rename checkout_refresh RPC to the dotted convention
  (checkout.refresh.request/response) per docs/rpc-namespacing.md.
- Replace the banned useUnistyles() call in DiffRefreshButton with
  withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md.

* refactor(git): move refresh into the checkout actions store

Per the feature audit: the manual refresh was the one checkout action
whose UI logic lived inline in the component instead of alongside its
siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into
the store so there's one home for "UI triggers a checkout RPC" — it now
reuses runCheckoutAction's pending/success status and query invalidation.
The component just reads status and shows an error toast.

* fix(git): size refresh loader to the icon to stop layout shift

ActivityIndicator size="small" renders ~20px regardless of platform,
wider than the 14px refresh icon, so swapping to it grew the button and
shifted the controls row. Use SyncedLoader at the same iconSize — it
renders into a size×size box, so both states share one footprint.

* fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner)

Mirror the file-explorer pane's refresh button exactly: RotateCw icon,
LoadingSpinner while refreshing, both centered in a fixed icon-sized box
so the spinner can't grow the control or shift the row. Replaces the
ad-hoc RefreshCw + SyncedLoader pairing.
2026-05-29 14:23:16 +08:00
Mohamed Boudra
04fb0f9b82 Keep local voice memory out of the daemon (#1217)
* Move local speech work out of daemon

Run local speech models in a lazily spawned worker so native model memory and blocking synthesis/transcription work stay out of the daemon process. The worker exits after idle time so local speech RSS can be reclaimed.

* Update supervisor stop test for process title
2026-05-29 13:27:28 +08:00
Mohamed Boudra
cdcf3d8d55 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-29 12:20:59 +07:00
Mohamed Boudra
cfc9665f6a Fix wrong local speech STT model ID in voice docs
The documented parakeet-tdt-0.6b-v3-int8 doesn't exist in the model
catalog; only the v2 variant is accepted. Copying the doc value into
config produced an "invalid model id" error on daemon restart.
2026-05-29 12:20:22 +07:00
Mohamed Boudra
1350167dda Push the whole composer up with the keyboard on mobile
The keyboard shift was applied only to the input box inside the
composer, so the subagents track and the draft import-session pill —
rendered as siblings above the composer — stayed behind the keyboard.
Lift the shift to the wrapper that holds the full composer column and
add an opt-out prop so the composer skips its own shift when a parent
owns it.
2026-05-29 12:20:00 +07:00
Mohamed Boudra
bb2f540f29 Widen the mobile composer relative to the message column 2026-05-29 12:00:50 +07:00
Mohamed Boudra
f861aa2e88 Tighten mobile composer footer glyph alignment 2026-05-29 11:28:14 +07:00
Mohamed Boudra
3585c80266 Allow previews to open readable files (#1214)
* Allow previews to open readable files

* Fix Windows file preview path test

* Address file preview review comments
2026-05-29 11:35:38 +08:00
Mohamed Boudra
0ee76b2ea0 Cover timeline epoch reset behavior 2026-05-29 09:55:40 +07:00
Mohamed Boudra
65ace20880 Fix mobile agent timeline catch-up 2026-05-29 09:31:16 +07:00
Mohamed Boudra
2e1b907c63 Improve Playwright E2E test quality (#1210)
* chore(e2e): begin Playwright test-quality pass

* test(e2e): migrate terminal-performance spec onto TerminalE2EHarness

Replace the hand-rolled daemon-client lifecycle (connectTerminalClient +
openProject + createTerminal/navigateToTerminal/killTerminal) with the shared
TerminalE2EHarness, matching terminal-keystroke-stress.spec.ts. The spec now
seeds the workspace and terminals through the harness vocabulary instead of
duplicating setup, cutting the file by ~40 lines with no behavior change.

* test(e2e): extract shared mock-agent workspace helper

Five specs reimplemented the same setup: seed a temp repo, open it as a
project, create a mock-provider agent, then navigate to its workspace route.
Extract seedMockAgentWorkspace + openAgentRoute into helpers/mock-agent.ts and
migrate the rewind-menu and user-message-contract UI-contract specs onto them,
dropping their local getServerId/openAgent/inline-seed duplication.

* test(e2e): migrate terminal tab rename spec onto TerminalE2EHarness

Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createTerminal + navigateToTerminal + manual cleanup) with the
shared TerminalE2EHarness and withTerminalInApp helpers, matching the other
terminal specs. Behavior and assertions are unchanged.

* test(e2e): share openProjectViaDaemon across sidebar specs

Both sidebar-workspace and sidebar-workspace-rename rolled their own
identical openProjectViaDaemon helper against the workspace-setup daemon
client. Promote a single shared helper into helpers/workspace-setup.ts
(returning id/name/workspaceDirectory) and have seedProjectForWorkspaceSetup
delegate to it, removing the duplicated open-project logic.

* test(e2e): migrate client-slash-commands spec onto shared mock-agent helper

Replace the spec's hand-rolled openProject/createReadyMockAgent/
openActiveAgentTab/getServerId setup with seedMockAgentWorkspace and
openAgentRoute from helpers/mock-agent, matching the rewind-menu and
user-message-contract specs. Drops ~60 lines of duplicated daemon-client
wiring; the test bodies now read as domain intent.

* test(e2e): converge remaining workspace-setup specs onto shared daemon helpers

Replace the last inline openProject + null-check blocks with the shared
openProjectViaDaemon helper in the file-explorer-collapse and pr-pane specs,
and route workspace-setup-runtime project registration through
seedProjectForWorkspaceSetup. Behavior is identical; the seeding vocabulary now
lives entirely in helpers/workspace-setup.ts.

* test(e2e): migrate composer-autocomplete spec onto shared mock-agent helper

Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createAgent + route building + bespoke cleanup) with
seedMockAgentWorkspace + openAgentRoute from helpers/mock-agent, matching the
client-slash-commands migration. Drops the local getServerId, openProject, and
cleanupWithin helpers.

* test(e2e): migrate codex-plan-approval spec onto shared mock-agent helper

Replace the hand-rolled daemon client, project open, agent creation, and
manual route building with seedMockAgentWorkspace + openAgentRoute. The spec
now reads as intent: seed a mock agent, open it, approve the plan, assert the
panel clears.

* test(e2e): converge daemon-client bootstrap onto one shared factory

The five e2e helpers each rolled their own daemon-client connect logic —
duplicating the ws-url resolution, node WebSocket factory, client
construction, and connect call, plus a redeclared config interface.

Extract a single connectDaemonClient factory in daemon-client-loader.ts
that each helper delegates to with its own typed client interface. This
also propagates the port-6767 safety guard (previously only in two
helpers) to all of them, so no test client can target the developer
daemon. Specs are untouched; behavior is preserved.

* test(e2e): promote shared daemon seed client out of terminal-perf helper

The general-purpose E2E daemon client (workspace/agent/terminal seeding and
driving) had grown inside terminal-perf.ts under the name
TerminalPerfDaemonClient, even though most consumers — mock-agent, composer,
rewind-flow, and the launcher/title-handoff specs — have nothing to do with
terminal performance. Move the interface and its connect factory into a
neutrally-named helpers/seed-client.ts (SeedDaemonClient / connectSeedClient)
and point every consumer at it. terminal-perf.ts keeps only its terminal page
helpers. Pure rename/relocate; no behavior change.

* test(e2e): converge E2E_SERVER_ID lookup onto one shared helper

The server-id env accessor was copy-pasted as a local `getServerId`
function in six helpers and five specs (plus a `requireServerId` twin in
the sidebar helper). Extract a single `helpers/server-id.ts` accessor and
route every caller through it, so a new spec imports the vocabulary
instead of re-deriving it. Pure refactor — identical lookup behavior.

* test(e2e): finish converging server-id lookup onto the shared helper

The previous pass extracted helpers/server-id.ts but only routed callers
that wrapped the env read in a local function. Eight specs still re-derived
the lookup inline — some as their own copy-pasted getSeededServerId/
getE2EServerId functions, some as bare `process.env.E2E_SERVER_ID` blocks
inside test bodies. Route them all through getServerId() so a new spec
imports the vocabulary instead of re-deriving it. Pure refactor — identical
lookup behavior; daemon-port reads are left untouched.

* test(e2e): converge workspace seeding onto a shared seedWorkspace helper

Extract the repeated temp-repo + seed-client + openProject bootstrap into
seedWorkspace() in helpers/seed-client.ts, returning a {client, repoPath,
workspaceId, cleanup} handle. seedMockAgentWorkspace and the agent-title-handoff
spec now build on it instead of re-rolling the trio, so the open-project error
handling and teardown live in one place.

* test(e2e): converge launcher-tab spec onto the shared seedWorkspace helper

Replace the hand-rolled createTempGitRepo + connectSeedClient + openProject
trio in beforeAll with seedWorkspace(), and drop the redundant second seed
client in the terminal-title block in favor of the shared workspace.client.

* test(e2e): converge file-explorer-collapse onto the shared seedWorkspace helper

Teach seedWorkspace to forward createTempGitRepo options (files/branches/
remote) so file-seeding specs can drop their hand-rolled
createTempGitRepo + connect + openProjectViaDaemon trio. Migrate
file-explorer-collapse onto it, removing its bespoke WorkspaceSetup client setup.

* test(e2e): converge non-git project setup onto a shared workspace helper

Promote the non-git createTempDirectory out of sidebar-workspace.spec.ts
into helpers/workspace.ts next to createTempGitRepo, and dedupe the
copy-pasted temp-root resolution (workspace.ts, with-workspace.ts, and the
spec) into one shared resolveTempRoot(). The spec drops its low-level
node:fs imports and reads in domain terms.

* test(e2e): converge agent-tab-rename spec onto the shared seedWorkspace helper

Drop the bespoke daemon-client + temp-repo + manual-cleanup trio in
workspace-agent-tab-rename and seed through seedWorkspace, matching the
other converged specs. createIdleAgent now takes a minimal structural
client interface so it accepts either the archive-tab client or the
shared seed client (type-only; the existing archive-tab callers are
unchanged). Verified by running the spec on Desktop Chrome.

* test(e2e): converge pane-remount spec onto the shared seedWorkspace helper

Drop the bespoke archive-tab daemon client + temp-repo + manual-cleanup
trio in workspace-pane-remount and seed through seedWorkspace, matching
the other converged specs. createIdleAgent already accepts the shared
seed client structurally, so the only behavior change is teardown now
goes through workspace.cleanup(). Verified by running the spec on
Desktop Chrome.

* test(e2e): converge sidebar-workspace-rename onto the shared seedWorkspace helper

Expose workspaceName and workspaceDirectory on SeededWorkspace (sourced from
the open-project response) so branch-rename specs can read the resolved branch
name and checkout directory without a bespoke client. Migrate
sidebar-workspace-rename off its hand-rolled connectWorkspaceSetupClient +
createTempGitRepo + openProjectViaDaemon trio, dropping the per-test
client/repo cleanup in favor of seedWorkspace's single cleanup handle.

* test(e2e): converge sidebar-workspace list onto the shared seedWorkspace helper

Migrate all five "Sidebar workspace list" tests off their hand-rolled
connectWorkspaceSetupClient + createTempGitRepo/createTempDirectory +
openProjectViaDaemon trio onto seedWorkspace, collapsing each test's
client/repo cleanup into the single seedWorkspace cleanup handle and dropping
the spec-local setGitHubRemote/execSync machinery.

Two small helper additions make the full file converge:

- seedWorkspace gains a `git: false` option that seeds a plain non-git
  directory (via createTempDirectory) instead of a git repo, so the non-git
  project test gets the same single-handle treatment.
- createTempGitRepo's configureRemote now relabels origin to a display URL
  when both `withRemote` and `originUrl` are given: it sets up the local
  tracking remote, pushes, then `git remote set-url` to the GitHub URL. This
  reproduces the prior withRemote + setGitHubRemote git state exactly (real
  local tracking refs, GitHub origin URL for project grouping) in one fixture
  call, so the GitHub-remote tests are behavior-preserving.

* test(e2e): converge projects-settings onto the shared seedWorkspace helper

Replace the per-fixture connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio with seedWorkspace(), and expose the daemon's
projectId/projectDisplayName on SeededWorkspace so fixtures can read the
project label directly. All 9 specs pass.

* test(e2e): converge composer-attachments onto the shared seedWorkspace helper

Replace the inline connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio in the "composer is locked while new workspace agent
is being created" test with a single seedWorkspace() call, dropping the manual
client.close()/repo.cleanup() teardown in favor of workspace.cleanup(). The
test still passes against a real daemon.

* test(e2e): converge settings-toggle-tab-regression onto the shared seedWorkspace helper

Both tests rolled their own daemon client + temp git repo + manual agent
archive cleanup. Replace that trio with seedWorkspace(), drive idle agents
through workspace.client, and route off workspace.repoPath, leaving cleanup
to workspace.cleanup(). Matches the pattern already used by
workspace-pane-remount and workspace-agent-tab-rename.

* test(e2e): converge workspace-navigation-regression onto the shared seedWorkspace helper

Replace the bespoke connect/openProject/createTempGitRepo/archive trios in
the reconnect, cold-URL, and sidebar-navigation tests with seedWorkspace(),
matching the other migrated specs. Cleanup collapses to workspace.cleanup().

* test(e2e): drop orphaned dead code from agent-bottom-anchor helper

The agent-bottom-anchor spec was removed in a prior cleanup, but its
helper kept a private daemon-client interface and connect fn (duplicating
the shared seed client), seedBottomAnchorAgent, the reply-message builders,
and several scroll helpers that no spec references anymore. Only
readScrollMetrics, expectNearBottom, and waitForContentGrowth are still
used (by agent-stream.ts); keep those and delete the rest.

* test(e2e): converge archive-tab daemon client onto the shared seed client

Fold archiveAgent and fetchAgentHistory into the canonical SeedDaemonClient
and route the archive-tab and sessions-empty specs through connectSeedClient,
deleting the bespoke ArchiveTabDaemonClient wrapper. Both specs only need
general-purpose agent seed/drive operations, so they now share one client
interface instead of re-declaring their own.

* test(e2e): derive workspace-setup daemon client from the real client type

Replace the hand-rolled WorkspaceSetupDaemonClient interface with a
Pick<InternalDaemonClient, ...> over the real daemon client, matching the
pattern already used by the new-workspace helper. The 45-line re-declaration
of RPC method signatures could silently drift from the protocol; deriving it
from the source of truth keeps the test client honest and shrinks the helper.

* test(e2e): converge duplicated escapeRegex onto one shared helper

Seven byte-identical copies of escapeRegex lived across three specs and four
helpers. Extract a single helpers/regex.ts and route every caller through it,
so the suite has one regex-escaping primitive instead of re-declaring the same
pure function per file.

* test(e2e): converge E2E_DAEMON_PORT resolution onto one shared accessor

The isolated test daemon's port was re-read from the environment in ~10
places — three local helper functions in specs, two inline blocks, and
several helper modules — each repeating the "throw if unset" check and,
in the safety-critical paths, the "refuse the developer daemon (6767)"
guard.

Add helpers/daemon-port.ts exporting getE2EDaemonPort(), mirroring
getServerId(), and route every reader through it. The 6767 guard now
applies everywhere: the test port is never legitimately 6767, so
refusing it uniformly keeps every spec off the developer daemon.

While here, route the two inline port-regex escapes through the existing
escapeRegex helper instead of hand-inlining the same pattern.

* test(e2e): capture create-agent cwd via shared WS-frame helper

workspace-cwd's draft-agent test rolled its own request recorder by
monkeypatching WebSocket.prototype.send and stashing frames on a
window global, then reading them back through page.evaluate. Replace
that with captureWsSessionFrames — the same outbound-frame helper four
other specs already use for create_agent_request — so the assertion
reads the cwd directly and the spec drops the browser-side internals
reach-around. No behavior change; the three cwd cases still pass.

* test(e2e): converge daemon WS-route regex onto one shared helper

Five sites rebuilt the Playwright routeWebSocket matcher for the E2E
daemon inline as `new RegExp(`:${escapeRegex(getE2EDaemonPort())}\b`)`
(new-workspace, project-settings, composer-autocomplete, and two in
workspace-navigation-regression), and startup-dsl rolled the same regex
for arbitrary blocked test-host ports. Add daemonWsRoutePattern() and
wsRoutePatternForPort(port) to daemon-port.ts — whose docstring already
promised route patterns live here — and point every site at them.

The emitted regex is byte-identical, so interception behavior is
unchanged; composer-autocomplete still passes. Drops the now-unused
escapeRegex/getE2EDaemonPort imports and the redundant daemonPort locals.
2026-05-29 09:46:15 +08:00
paseo-ai[bot]
23502e474d fix: update lockfile signatures and Nix hash [skip ci] 2026-05-28 17:14:00 +00:00
Mohamed Boudra
b0a0cb4a99 chore(release): cut 0.1.85 2026-05-29 00:09:30 +07:00
Mohamed Boudra
342e92d0c7 Update changelog for 0.1.85 2026-05-29 00:06:48 +07:00
Mohamed Boudra
0170eba233 Add Opus 4.8 to the Claude model picker
Opus 4.8 (and its 1M-context variant) become the default Claude
models, pushing Opus 4.6 off the default slot. Opus 4.7 stays in
the list as the previous release.
2026-05-29 00:03:39 +07:00
Mohamed Boudra
b6103a59da Archive agents on worktree archive; clean up schedules on archive (#1206)
* Archive agents on worktree archive; clean up schedules on archive

Worktree archive used to hard-delete every agent inside it (storage
removed, agent_deleted emitted), losing history along with the worktree.
It now archives those agents instead, so they remain in storage with an
archivedAt timestamp and stay visible in the archived list.

Schedules targeting an archived agent were left as dead records that the
schedule service rejected at run time. AgentManager now fires an
onAgentArchived callback from every archive path (live, stored snapshot,
cascade-to-children), and bootstrap wires it to
ScheduleService.deleteForAgent so those schedules are cleaned up
automatically.

* Push agent_state when archiving stored-only agents; tolerate schedule delete errors

Greptile review on PR #1206 flagged two issues:

1. archiveSnapshot persisted archivedAt to storage but did not emit an
   agent_state event for stored-only agents. Connected clients would
   keep showing them as active until a full reload. Mirror the dispatch
   logic from markRecordArchived: notifyAgentState for live agents,
   dispatchArchivedStoredAgent for off-memory non-internal agents.

2. ScheduleService.deleteForAgent used Promise.all, which short-circuits
   on the first store error and abandons the rest. Switch to
   Promise.allSettled, count fulfilled deletions, and log rejections.

* Tighten agent-manager archive tests after /audit-tests review

- Split "fires onAgentArchived for live, stored, and cascaded archives"
  into two single-behavior tests with independent fixtures, so the assertion
  array isn't reset mid-test.
- Strict equality on the cascade hook assertion (no more arrayContaining).
- "archiveSnapshot dispatches archived state" now captures the full agent
  state event, asserts a dispatch happened, asserts the dispatched agent
  id, and asserts the lifecycle is closed.
2026-05-28 23:02:58 +08:00
194 changed files with 7339 additions and 2398 deletions

View File

@@ -1,5 +1,52 @@
# Changelog
## 0.1.87 - 2026-05-30
### Added
- Permission prompts from OpenCode subagents now surface in Paseo so you can approve or deny them
### Fixed
- Fixed an intermittent Android crash while animated views were drawing
- Fixed mobile bottom sheets not reopening after being dismissed
## 0.1.86 - 2026-05-29
### Added
- **Launch Grok (xAI) as a coding agent**
- **Fast mode for Claude Opus**
- **Multilingual local dictation with the new Parakeet v3 speech model**
### Improved
- Edit, Write, and Read tool calls are now syntax-highlighted
- The model selector shows the error when a provider fails to load
- The About page shows the versions of connected host daemons
- Refresh git diffs on demand with a new refresh button
- Previews can open readable files outside the current workspace
- Projects without an icon now show a colored icon instead of a grey placeholder
- Auto-generated agent titles and worktree branch names now use your configured provider fallbacks ([#1219](https://github.com/getpaseo/paseo/pull/1219) by [@mcowger](https://github.com/mcowger))
- Local dictation keeps its speech models out of the daemon, lowering its memory use
### Fixed
- On mobile, the whole composer now stays above the keyboard so the subagents track and draft pills no longer hide behind it
- The mobile agent timeline now catches up fully after reconnecting, so no messages go missing
- The slash command menu no longer shows /clear twice
## 0.1.85 - 2026-05-29
### Added
- **Opus 4.8 in the Claude model picker**, with a 1M-context variant
### Improved
- Archiving a worktree now keeps its agents under the archived list instead of removing them
- Archiving an agent cleans up any schedules targeting it
## 0.1.84 - 2026-05-28
### Added

View File

@@ -46,6 +46,8 @@ By default, the daemon binds to `127.0.0.1`. With no password configured, the lo
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.

View File

@@ -92,6 +92,7 @@ Cross-platform React Native app that connects to one or more daemons.
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
### `packages/cli` — Command-line client
@@ -222,7 +223,7 @@ initializing → idle ⇄ running
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to all subscribed clients in real time
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
## Agent providers

View File

@@ -155,7 +155,10 @@ Single file, validated with `PersistedConfigSchema`.
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
providers: Record<providerId, ProviderOverride>
providers: Record<providerId, ProviderOverride>,
metadataGeneration: {
providers: [{ provider, model?, thinkingOptionId? }]
}
},
features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
@@ -171,6 +174,8 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
---

View File

@@ -155,6 +155,20 @@ The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Gotcha 6 — Bottom sheet refs are not lifecycle truth
`@gorhom/bottom-sheet` modals churn their imperative ref while presenting and
dismissing. Do not treat `ref != null` as permission to call `present()`, and do
not treat `ref == null` as the sheet being closed. The user-visible lifecycle is
the desired `visible` prop plus the sheet callbacks (`onChange(-1)`,
`onDismiss`).
If a user closes a sheet with the backdrop or a pan gesture, the sheet may detach
and reattach before React state has acknowledged `visible=false`. Re-presenting
on that attach races Gorhom's dismiss path and leaves the modal unable to reopen.
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing.
## Recipe for a new anchored panel
Before you write a new one, ask:

View File

@@ -0,0 +1,149 @@
# Session God-File Decomposition Plan
`packages/server/src/server/session.ts` — 9116 lines, one `Session` class (declared line 724), 128 handlers, ~60 instance fields, 7 entangled domains. Goal: a **strictly behavior-preserving, incremental** decomposition into per-domain controllers, mirroring the existing `TerminalSessionController`.
## Chosen strategy: controller-context (per-domain option-bag controllers)
Each domain becomes a controller class in its own file with the **exact** contract the repo already proved twice (`TerminalSessionController` at `packages/server/src/terminal/terminal-session-controller.ts`, and `CreateAgentLifecycleDispatch`):
- An **options-bag constructor** injecting only what that domain reads.
- An **owned-type `ReadonlySet`** of message types.
- A **NON-async `dispatch(msg): Promise<void> | undefined`** that checks the owned-type set FIRST and returns `undefined` synchronously on a miss (verified at terminal-session-controller.ts:140-143).
- `start()` wired from `subscribeToOptionalManagers`, `dispose()` called by the shell's ordered `cleanup()`.
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
| # | Slice | Effort | Risk |
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
| 5 | WorkspaceController | XL | high |
| 6 | Voice prereqs: emit() purity + abortController ownership | M | high |
| 7 | VoiceSessionController | XL | high |
| 8a | Agent-lifecycle config setters | M | medium |
| 8b | AgentLifecycleController | XL | high |
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
---
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError`**kept separate, not merged**), `toScheduleSummary``packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead``packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `providerSnapshotManager` (**shared by reference** — stays a daemon singleton read by checkout/lifecycle/workspace), `isProviderVisibleToClient` (predicate closing over `this`, reads `appVersion` live), `downgradeModeIconsForClient`, `downgradeEntryModesForClient`, agent-control reads `{ listProviderAvailability, listDraftFeatures }`.
**Behavior note:** COMPAT correctness — PUSH and PULL paths MUST call the SAME injected visibility/downgrade closures, reading `appVersion` LIVE (mutated post-construction via `updateAppVersion`). Keep `COMPAT(providersSnapshot)` and `COMPAT(customModeIcons)` comments verbatim. Do NOT pull `resolveStructuredGenerationProviders`/`getFocusedAgentSelectionForCwd` in. **Tests:** `session.dispatch-seam.test.ts`, `daemon-e2e/models.e2e.test.ts`, `session.test.ts`.
---
## Slice 3 — Split the shared observer seams (prerequisite, no controller)
In-place refactor on the shell, two named fan-outs:
1. **workspace-git observer** (4910-4917): make `emitCheckoutStatusUpdate` and `onBranchChanged` injectable callbacks; keep `workspaceGitWatchTargets`/`workspaceGitSubscriptions` shared by reference.
2. **agentManager.subscribe callback** (~1298): refactor into `{ onAgentUpdate, shouldAutoAllowVoicePermission(event), onStreamEvent }`.
**Behavior note:** the single hardest seam, split exactly once before the two domains that co-own it. The observer fires BOTH workspace (`handleWorkspaceGitBranchSnapshot`, `emitWorkspaceUpdateForCwd`) and checkout (`emitCheckoutStatusUpdate`) effects; the agent-subscribe callback is invoked by agent EVENTS (not the `??` chain) and does lifecycle + voice work. Add a test asserting BOTH a `workspace_update` and a `checkout_status_update` fire from one simulated git snapshot change, and a voice-permission test for the auto-allow path. **Tests:** `session.workspace-git-watch.test.ts`, `session.workspaces.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 4 — GitCheckoutController
**Move:** ~22 `checkout_*`/`stash_*`/PR/github handlers + `handleSubscribeCheckoutDiffRequest`/`handleUnsubscribeCheckoutDiffRequest` + `emitCheckoutStatusUpdate` + `checkoutDiffSubscriptions``packages/server/src/server/checkout/git-checkout-controller.ts`. Collapse `dispatchCheckoutMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `checkoutDiffManager` (move in + dispose teardown), `github` (shared), `workspaceGitService` (**shared spine**), `workspaceGitWatchTargets`/`workspaceGitSubscriptions` (**shared**), `providerSnapshotManager.listRegisteredProviderIds`. `emitCheckoutStatusUpdate` is now owned here and injected back into the workspace observer seam from Slice 3.
**Behavior note:** safe now that Slice 3 split the observer. `checkoutDiffSubscriptions` teardown moves to `dispose()`, called by `cleanup()` at its current ordinal (8530). **Tests:** `session.dispatch-seam.test.ts`, `checkout-diff-manager.test.ts`, `daemon-e2e/checkout-diff-subscription.e2e.test.ts`, `session.test.ts`.
---
## Slice 5 — WorkspaceController (XL)
**Move:** all workspace handlers (incl. re-homed `handleProjectRenameRequest` and `start_workspace_script_request`) + ~25 private workspace helpers + the whole `workspaceUpdatesSubscription` state machine → `packages/server/src/server/workspace/workspace-controller.ts`.
**SessionContext surface:** `emit`, `sessionLogger`, `projectRegistry`/`workspaceRegistry`/`downloadTokenStore`/script stores/editor cache (**owned**), `workspaceGitService` + watch maps (**shared with checkout**), injected `emitCheckoutStatusUpdate`/`onBranchChanged`, `terminalManager`/`killTerminalsUnderPath`, an `agentUpdatesSubscription` write via a narrow `bufferAgentUpdate` command, `providerSnapshotManager.listRegisteredProviderIds`.
**Behavior note:** the workspaceUpdatesSubscription machine moves WHOLE. The eight already-public workspace methods stay a public surface re-exposed via the shell. Re-homes are atomic remove-from-old-dispatcher + add-to-new-owned-set. **Tests:** `session.workspaces.test.ts`, `session.workspace-git-watch.test.ts`, `session.workspace-resolution-invariants.test.ts`, `session.test.ts`.
---
## Slice 6 — Voice prerequisites (emit purity + abort ownership)
In-place, separately reviewable. Split the `audio_output` TTS-debug branch out of `emit()` (8421-8468, bypasses to `onMessage` at 8454) so `emit` is a pure trace+onMessage sink. Move `convertPCMToWavBuffer` (674-701) to `speech/audio.ts`. Decide abortController ownership.
**Behavior note:** TTS-debug split and abortController ownership are the SAME decision (`ttsDebugStreams.clear()` is tied to `createAbortController` reassignment at 8359). Keep `emit` (with the universal trace) on the shell and inject it everywhere — no trace-less emit. Do NOT inject the AbortController by value. Add: a TTS-debug persistence test (with the debug env flag) before the move, and a barge-in→cleanup regression test asserting the NEW run's signal is aborted. **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
**Behavior note:** depends on Slices 3 + 6. `cleanup()` stays the ordered orchestrator and calls `voiceController.dispose()` at the position the inlined voice teardown occupies today (8505-8525). **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-local-agent.e2e.test.ts`, `session.voice-mcp-config.test.ts`, `session.test.ts`.
---
## Slice 8a — Agent-lifecycle config setters
Parameterize the 4 setter envelopes `handleSetAgentMode/Model/Feature/Thinking` (4209-4390) into one helper; re-home `handleListCommandsRequest` (misfiled in `dispatchMiscMessage`). Add a handleMessage-driven **failure** test per setter (force the command to reject, assert both the `*_response{accepted:false}` AND the `activity_log` error frame in order) BEFORE collapsing. **Tests:** `session.test.ts`, `session.lifecycle-boundary.test.ts`.
## Slice 8b — AgentLifecycleController (XL, LAST)
**Move:** remaining lifecycle handlers + the `agentUpdatesSubscription` fan-out (`bufferOrEmitAgentUpdate`, `flushBootstrappedAgentUpdates`, `matchesAgentFilter`, `forwardAgentUpdate`) → `packages/server/src/server/agent/agent-lifecycle-controller.ts`. Collapse the three lifecycle dispatchers.
**SessionContext surface:** `emit`, `sessionLogger`, `agentManager`/`agentStorage` (**owned**), injected `forwardAgentUpdate``buildProjectPlacementForCwd` (backed by WorkspaceController), `agentUpdatesSubscription` accessor (owned; workspace writes via `bufferAgentUpdate`), `isProviderVisibleToClient`, `resolveCreateAgentWorkspace`, `supports`, `mcpBaseUrl`, `terminalController.killTerminalForClose`.
**Behavior note:** done LAST — the shared-projection hub. `handleCloseItemsRequest` splits its terminal-kill half from its agent-archive half. **Tests:** `session.test.ts`, `session.wait-for-finish.test.ts`, `session.create-agent-title.test.ts`, `session.lifecycle-boundary.test.ts`, `daemon-client.e2e.test.ts`.
---
## Cross-cutting invariants (every slice)
- **Always** run `npm run typecheck` and `npm run lint` after each slice; run `npm run build:server` before diagnosing cross-package type errors.
- Controller `dispatch` is **NON-async**, guarded by an owned-type `ReadonlySet` check returning `undefined` synchronously on miss. Never `async dispatch`.
- Controllers add **no** try/catch inside `dispatch` — error handling stays in `handleMessage`.
- `cleanup()` stays the single ordered teardown orchestrator on the shell.
- Move domain error emitters **verbatim**; treat any cross-domain emitter merge as a separate, test-guarded change.
- Per-slice typecheck/lint/format via `npm run` scripts; never re-run the full suite locally (run only the listed files with `--bail=1`).

42
docs/timeline-sync.md Normal file
View File

@@ -0,0 +1,42 @@
# Timeline sync
Agent chat delivery has two paths:
1. **Live stream**`agent_stream` WebSocket messages for immediacy.
2. **Authoritative history**`fetch_agent_timeline_request` for correctness.
The invariant is:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
## Presence is not delivery
Client heartbeat reports presence:
- device type
- app visibility
- focused agent
- last activity time
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
## Catch-up is paged but complete
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
## Resume behavior
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
When a client resumes without a cursor, it fetches the latest tail page.
## Relevant code
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
- Session wiring: `packages/app/src/contexts/session-context.tsx`

View File

@@ -1 +1 @@
sha256-25V9uu0hSecGCDixaJwQ9wu6Mj0cfG6uXNYjMZIZ58U=
sha256-B6xcv2QfqpmvZCwYZ34pF2HWu3Icjl3bylHcQr8lo8g=

676
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.84",
"version": "0.1.87",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.84",
"version": "0.1.87",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -10120,6 +10120,588 @@
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/metro-babel-transformer": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz",
"integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@react-native/babel-preset": "0.85.3",
"hermes-parser": "0.33.3",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-plugin-codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz",
"integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.29.0",
"@react-native/codegen": "0.85.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-preset": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz",
"integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-proposal-export-default-from": "^7.24.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-export-default-from": "^7.24.7",
"@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
"@babel/plugin-syntax-optional-chaining": "^7.8.3",
"@babel/plugin-transform-async-generator-functions": "^7.25.4",
"@babel/plugin-transform-async-to-generator": "^7.24.7",
"@babel/plugin-transform-block-scoping": "^7.25.0",
"@babel/plugin-transform-class-properties": "^7.25.4",
"@babel/plugin-transform-classes": "^7.25.4",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/plugin-transform-flow-strip-types": "^7.25.2",
"@babel/plugin-transform-for-of": "^7.24.7",
"@babel/plugin-transform-modules-commonjs": "^7.24.8",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
"@babel/plugin-transform-optional-catch-binding": "^7.24.7",
"@babel/plugin-transform-optional-chaining": "^7.24.8",
"@babel/plugin-transform-private-methods": "^7.24.7",
"@babel/plugin-transform-private-property-in-object": "^7.24.7",
"@babel/plugin-transform-react-display-name": "^7.24.7",
"@babel/plugin-transform-react-jsx": "^7.25.2",
"@babel/plugin-transform-react-jsx-self": "^7.24.7",
"@babel/plugin-transform-react-jsx-source": "^7.24.7",
"@babel/plugin-transform-regenerator": "^7.24.7",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/plugin-transform-typescript": "^7.25.2",
"@babel/plugin-transform-unicode-regex": "^7.24.7",
"@react-native/babel-plugin-codegen": "0.85.3",
"babel-plugin-syntax-hermes-parser": "0.33.3",
"babel-plugin-transform-flow-enums": "^0.0.2",
"react-refresh": "^0.14.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz",
"integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/parser": "^7.29.0",
"hermes-parser": "0.33.3",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"tinyglobby": "^0.2.15",
"yargs": "^17.6.2"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
"integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-parser": "0.33.3"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
"integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
"integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-estree": "0.33.3"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@react-native/metro-config": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz",
"integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/js-polyfills": "0.85.3",
"@react-native/metro-babel-transformer": "0.85.3",
"metro-config": "^0.84.3",
"metro-runtime": "^0.84.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@react-native/metro-config/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 14"
}
},
"node_modules/@react-native/metro-config/node_modules/ci-info": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-config/node_modules/hermes-estree": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz",
"integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-config/node_modules/hermes-parser": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz",
"integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-estree": "0.35.0"
}
},
"node_modules/@react-native/metro-config/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@react-native/metro-config/node_modules/metro": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz",
"integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"accepts": "^2.0.0",
"ci-info": "^2.0.0",
"connect": "^3.6.5",
"debug": "^4.4.0",
"error-stack-parser": "^2.0.6",
"flow-enums-runtime": "^0.0.6",
"graceful-fs": "^4.2.4",
"hermes-parser": "0.35.0",
"image-size": "^1.0.2",
"invariant": "^2.2.4",
"jest-worker": "^29.7.0",
"jsc-safe-url": "^0.2.2",
"lodash.throttle": "^4.1.1",
"metro-babel-transformer": "0.84.4",
"metro-cache": "0.84.4",
"metro-cache-key": "0.84.4",
"metro-config": "0.84.4",
"metro-core": "0.84.4",
"metro-file-map": "0.84.4",
"metro-resolver": "0.84.4",
"metro-runtime": "0.84.4",
"metro-source-map": "0.84.4",
"metro-symbolicate": "0.84.4",
"metro-transform-plugins": "0.84.4",
"metro-transform-worker": "0.84.4",
"mime-types": "^3.0.1",
"nullthrows": "^1.1.1",
"serialize-error": "^2.1.0",
"source-map": "^0.5.6",
"throat": "^5.0.0",
"ws": "^7.5.10",
"yargs": "^17.6.2"
},
"bin": {
"metro": "src/cli.js"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-babel-transformer": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz",
"integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"flow-enums-runtime": "^0.0.6",
"hermes-parser": "0.35.0",
"metro-cache-key": "0.84.4",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-cache": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz",
"integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==",
"license": "MIT",
"peer": true,
"dependencies": {
"exponential-backoff": "^3.1.1",
"flow-enums-runtime": "^0.0.6",
"https-proxy-agent": "^7.0.5",
"metro-core": "0.84.4"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-cache-key": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz",
"integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-config": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz",
"integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"connect": "^3.6.5",
"flow-enums-runtime": "^0.0.6",
"jest-validate": "^29.7.0",
"metro": "0.84.4",
"metro-cache": "0.84.4",
"metro-core": "0.84.4",
"metro-runtime": "0.84.4",
"yaml": "^2.6.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-core": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz",
"integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"lodash.throttle": "^4.1.1",
"metro-resolver": "0.84.4"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-file-map": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz",
"integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"fb-watchman": "^2.0.0",
"flow-enums-runtime": "^0.0.6",
"graceful-fs": "^4.2.4",
"invariant": "^2.2.4",
"jest-worker": "^29.7.0",
"micromatch": "^4.0.4",
"nullthrows": "^1.1.1",
"walker": "^1.0.7"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-minify-terser": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz",
"integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"terser": "^5.15.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-resolver": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz",
"integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-runtime": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz",
"integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.25.0",
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-source-map": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz",
"integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"invariant": "^2.2.4",
"metro-symbolicate": "0.84.4",
"nullthrows": "^1.1.1",
"ob1": "0.84.4",
"source-map": "^0.5.6",
"vlq": "^1.0.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-symbolicate": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz",
"integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"invariant": "^2.2.4",
"metro-source-map": "0.84.4",
"nullthrows": "^1.1.1",
"source-map": "^0.5.6",
"vlq": "^1.0.0"
},
"bin": {
"metro-symbolicate": "src/index.js"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-transform-plugins": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz",
"integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-transform-worker": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz",
"integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"metro": "0.84.4",
"metro-babel-transformer": "0.84.4",
"metro-cache": "0.84.4",
"metro-cache-key": "0.84.4",
"metro-minify-terser": "0.84.4",
"metro-source-map": "0.84.4",
"metro-transform-plugins": "0.84.4",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@react-native/metro-config/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@react-native/metro-config/node_modules/ob1": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz",
"integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@react-native/metro-config/node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@react-native/normalize-colors": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.6.tgz",
@@ -30808,19 +31390,18 @@
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
"integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1",
"semver": "7.7.2"
"react-native-is-edge-to-edge": "^1.3.1",
"semver": "^7.7.3"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0",
"react": "*",
"react-native": "*",
"react-native-worklets": ">=0.5.0"
"react-native": "0.81 - 0.85",
"react-native-worklets": "0.8.x"
}
},
"node_modules/react-native-reanimated/node_modules/semver": {
@@ -30962,27 +31543,28 @@
}
},
"node_modules/react-native-worklets": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0",
"@babel/plugin-transform-classes": "^7.0.0-0",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
"@babel/plugin-transform-optional-chaining": "^7.0.0-0",
"@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
"@babel/plugin-transform-template-literals": "^7.0.0-0",
"@babel/plugin-transform-unicode-regex": "^7.0.0-0",
"@babel/preset-typescript": "^7.16.7",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-classes": "^7.28.4",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-optional-chaining": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"convert-source-map": "^2.0.0",
"semver": "7.7.2"
"semver": "^7.7.3"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0",
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "*"
"react-native": "0.81 - 0.85"
}
},
"node_modules/react-native-worklets/node_modules/semver": {
@@ -36303,7 +36885,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36360,14 +36942,14 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
@@ -36375,7 +36957,7 @@
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
@@ -36528,12 +37110,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.84",
"@getpaseo/protocol": "0.1.84",
"@getpaseo/server": "0.1.84",
"@getpaseo/client": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/server": "0.1.87",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36779,10 +37361,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"@getpaseo/protocol": "0.1.84",
"@getpaseo/relay": "0.1.84",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -36802,7 +37384,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.84",
"version": "0.1.87",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37054,7 +37636,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.84",
"version": "0.1.87",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37090,7 +37672,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37319,7 +37901,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37340,7 +37922,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37558,14 +38140,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.84",
"@getpaseo/highlight": "0.1.84",
"@getpaseo/protocol": "0.1.84",
"@getpaseo/relay": "0.1.84",
"@getpaseo/client": "0.1.87",
"@getpaseo/highlight": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38337,7 +38919,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.84",
"version": "0.1.87",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.84",
"version": "0.1.87",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -121,6 +121,8 @@
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-native-reanimated": "4.3.1",
"react-native-worklets": "0.8.3"
}
}

View File

@@ -2,15 +2,12 @@
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
import { test } from "./fixtures";
import {
connectArchiveTabDaemonClient,
expectSessionsEmptyState,
openSessions,
} from "./helpers/archive-tab";
import { connectSeedClient } from "./helpers/seed-client";
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectArchiveTabDaemonClient();
const client = await connectSeedClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {

View File

@@ -1,5 +1,6 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
@@ -12,19 +13,11 @@ const ACP_PROVIDER = {
name: "Hermes",
};
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getSeededServerId());
await openSettingsHost(page, getServerId());
await openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);

View File

@@ -1,12 +1,12 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
connectArchiveTabDaemonClient,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
@@ -21,14 +21,14 @@ import {
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
client = await connectSeedClient();
});
test.afterAll(async () => {

View File

@@ -0,0 +1,95 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.",
});
await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
async function expectBottomSheetOpen(page: Page) {
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
}
async function closeBottomSheetWithBackdrop(page: Page) {
const box = await bottomSheetBackdrop(page).boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + 24);
await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 10_000 });
// Guard against the regression where the sheet starts dismissing, then re-presents.
await page.waitForTimeout(500);
await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 1_000 });
}
async function openTabSwitcher(page: Page) {
const trigger = page.getByRole("button", { name: /Switch tabs/ });
await trigger.click();
await expectBottomSheetOpen(page);
}
async function openModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expectBottomSheetOpen(page);
await expect(
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
}
async function openAndCloseModelSelectorTwice(page: Page) {
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
}
test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseTabSwitcherTwice(page);
});
});
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
});

View File

@@ -1,8 +1,6 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
expectSessionRowArchived,
expectWorkspaceTabHidden,
@@ -11,22 +9,12 @@ import {
} from "./helpers/archive-tab";
interface SlashCommandScenario {
agent: { id: string };
client: TerminalPerfDaemonClient;
cwd: string;
agentId: string;
title: string;
}
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function withOpenReadyMockAgent(
page: Page,
input: {
@@ -36,68 +24,23 @@ async function withOpenReadyMockAgent(
},
run: (scenario: SlashCommandScenario) => Promise<void>,
): Promise<void> {
const repo = await createTempGitRepo("client-slash-command-");
const client = await connectTerminalClient();
try {
await openProject(client, repo.path);
const agent = await createReadyMockAgent(client, {
cwd: repo.path,
title: input.title,
model: input.model,
modeId: input.modeId,
});
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
await run({ agent, client, cwd: repo.path, title: input.title });
} finally {
await client.close();
await repo.cleanup();
}
}
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
const opened = await client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
}
}
async function createReadyMockAgent(
client: TerminalPerfDaemonClient,
input: {
cwd: string;
title: string;
model?: string;
modeId?: string;
},
): Promise<{ id: string }> {
const agent = await client.createAgent({
provider: "mock",
cwd: input.cwd,
const session = await seedMockAgentWorkspace({
repoPrefix: "client-slash-command-",
title: input.title,
modeId: input.modeId ?? "load-test",
model: input.model ?? "ten-second-stream",
model: input.model,
modeId: input.modeId,
initialPrompt: "Prepare a client slash command test agent.",
});
return { id: agent.id };
}
async function openActiveAgentTab(
page: Page,
input: { cwd: string; agentId: string },
): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectWorkspaceTabVisible(page, input.agentId);
await expectComposerVisible(page);
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
await expectComposerVisible(page);
await run({ agentId: session.agentId, title: input.title });
} finally {
await session.cleanup();
}
}
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
@@ -169,9 +112,9 @@ async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promis
test.describe("Client slash commands", () => {
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agentId, title }) => {
await runClientSlashCommand(page, "/quit");
await expectWorkspaceTabHidden(page, agent.id);
await expectWorkspaceTabHidden(page, agentId);
await expectAgentArchivedInSessions(page, title);
});
});
@@ -180,9 +123,9 @@ test.describe("Client slash commands", () => {
await withOpenReadyMockAgent(
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agent, title }) => {
async ({ agentId, title }) => {
await selectClientSlashCommand(page, "/qu", "/exit");
await expectWorkspaceTabHidden(page, agent.id);
await expectWorkspaceTabHidden(page, agentId);
await expectAgentArchivedInSessions(page, title);
},
);
@@ -192,12 +135,12 @@ test.describe("Client slash commands", () => {
await withOpenReadyMockAgent(
page,
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
async ({ agent, title }) => {
async ({ agentId, title }) => {
await runClientSlashCommand(page, "/clear");
await expectWorkspaceTabHidden(page, agent.id);
await expectWorkspaceTabHidden(page, agentId);
await expectReplacementDraftMatchesPreviousSetup(page);
await createAgentFromReplacementDraft(page);
await waitForReplacementAgentId(page, agent.id);
await waitForReplacementAgentId(page, agentId);
await expectAgentArchivedInSessions(page, title);
},
);

View File

@@ -1,16 +1,6 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
@@ -18,29 +8,14 @@ test.describe("Codex plan approval", () => {
}) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("codex-plan-approval-");
const client = await connectTerminalClient();
const session = await seedMockAgentWorkspace({
repoPrefix: "codex-plan-approval-",
title: "Codex plan approval e2e",
initialPrompt: "Emit synthetic plan approval.",
});
try {
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Codex plan approval e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "Emit synthetic plan approval.",
});
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await openAgentRoute(page, session);
await waitForPermissionPrompt(page, 120_000);
@@ -54,8 +29,7 @@ test.describe("Codex plan approval", () => {
});
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
} finally {
await client.close();
await repo.cleanup();
await session.cleanup();
}
});
});

View File

@@ -23,16 +23,12 @@ import {
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import {
connectNewWorkspaceDaemonClient,
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
import { seedWorkspace } from "./helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -237,27 +233,23 @@ test.describe("Composer attachments", () => {
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const serverId = getServerId();
const repo = await createTempGitRepo("attach-lock-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const daemonClient = await connectNewWorkspaceDaemonClient();
const workspace = await seedWorkspace({ repoPrefix: "attach-lock-" });
try {
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
targetWorkspacePath: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
const createButton = page
@@ -277,8 +269,7 @@ test.describe("Composer attachments", () => {
await expectComposerEditable(page);
} finally {
agentCreatedDelay.release();
await daemonClient.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -1,9 +1,8 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
const TEST_COMMANDS = [
{
@@ -99,28 +98,8 @@ async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
);
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort || daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon.");
}
return daemonPort;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
@@ -163,58 +142,26 @@ async function installListCommandsStub(page: Page): Promise<void> {
});
}
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
const opened = await client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
}
}
async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): Promise<void> {
const operation = cleanup().catch(() => undefined);
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
}
async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
): Promise<{
cleanup: () => Promise<void>;
}> {
const repo = await createTempGitRepo("autocomplete-popover-");
const client = await connectTerminalClient();
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-popover-",
title: "Autocomplete popover regression",
});
try {
await openProject(client, repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Autocomplete popover regression",
modeId: "load-test",
model: "ten-second-stream",
});
const route = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(route);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await openAgentRoute(page, session);
if (options?.expectWorkspaceTab !== false) {
await expectWorkspaceTabVisible(page, agent.id);
await expectWorkspaceTabVisible(page, session.agentId);
}
await expectComposerVisible(page);
return {
cleanup: async () => {
await cleanupWithin(2_000, () => client.close());
await cleanupWithin(2_000, () => repo.cleanup());
},
};
return { cleanup: session.cleanup };
} catch (error) {
await cleanupWithin(2_000, () => client.close());
await cleanupWithin(2_000, () => repo.cleanup());
await session.cleanup();
throw error;
}
}

View File

@@ -1,5 +1,6 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
@@ -17,20 +18,12 @@ import {
expectDaemonStatusVersion,
} from "./helpers/desktop-updates";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
// No Playwright Electron runner exists; we simulate the desktop bridge via
// addInitScript so Electron-gated UI activates without a real Electron process.
test.describe("Desktop updates", () => {
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getSeededServerId(),
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
});
@@ -41,7 +34,7 @@ test.describe("Desktop updates", () => {
test("clicking install shows the installing state on the callout", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getSeededServerId(),
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
slowInstall: true,
@@ -58,7 +51,7 @@ test.describe("Desktop daemon management", () => {
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
@@ -74,7 +67,7 @@ test.describe("Desktop daemon management", () => {
});
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
@@ -89,7 +82,7 @@ test.describe("Desktop daemon management", () => {
});
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
@@ -106,7 +99,7 @@ test.describe("Desktop daemon management", () => {
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
@@ -124,7 +117,7 @@ test.describe("Desktop daemon management", () => {
});
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,

View File

@@ -9,41 +9,31 @@ import {
openFileFromExplorer,
} from "./helpers/file-explorer";
import { gotoWorkspace } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: WorkspaceSetupDaemonClient;
let workspace: SeededWorkspace;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
files: [
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
{ path: "docs/guide.md", content: "# Guide\n" },
],
workspace = await seedWorkspace({
repoPrefix: "file-explorer-collapse-",
repo: {
files: [
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
{ path: "docs/guide.md", content: "# Guide\n" },
],
},
});
seedClient = await connectWorkspaceSetupClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) {
throw new Error(result.error ?? "Failed to seed workspace");
}
workspaceId = result.workspace.id;
});
test.afterAll(async () => {
await seedClient?.close();
await tempRepo?.cleanup();
await workspace?.cleanup();
});
test.describe("File explorer collapse", () => {
test("collapses an opened image file parent folder and still expands other folders", async ({
page,
}) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "assets");

View File

@@ -1,4 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
@@ -17,20 +18,8 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const daemonPort = getE2EDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",

View File

@@ -1,8 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -13,147 +9,6 @@ export interface ScrollMetrics {
distanceFromBottom: number;
}
export interface SeededAgent {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
}
export interface DaemonClientInstance {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function buildReplyBlock(label: string, lineCount = 14): string {
return Array.from({ length: lineCount }, (_, index) => {
const line = (index + 1).toString().padStart(2, "0");
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
}).join("\n");
}
function buildProtocolMessage(label: string, lineCount = 14): string {
return [
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
"Do not add extra words, bullets, markdown fences, or tool calls.",
"REPLY:",
buildReplyBlock(label, lineCount),
].join("\n");
}
function buildReplyMessage(label: string, lineCount = 14): string {
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
}
export function createReplyTurn(label: string): {
message: string;
expectedReply: string;
} {
return {
message: buildReplyMessage(label),
expectedReply: buildReplyBlock(label),
};
}
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor<
DaemonClientConfig,
DaemonClientInstance
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function seedBottomAnchorAgent(input: {
client: DaemonClientInstance;
cwd: string;
title?: string;
turnCount?: number;
lineCount?: number;
}): Promise<SeededAgent> {
const title = input.title ?? `bottom-anchor-${Date.now()}`;
const turnCount = Math.max(3, input.turnCount ?? 5);
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title,
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
});
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
);
}
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
for (let index = 1; index < turnCount; index += 1) {
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
expectedTailText = buildReplyBlock(label, lineCount);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
);
}
}
return {
id: created.id,
title,
expectedTailText,
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}
function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
@@ -190,48 +45,6 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
});
}
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
const scrollViewport = getVisibleChatScroll(page);
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
let remaining = Math.max(0, pixels);
while (remaining > 0) {
const delta = Math.min(240, remaining);
await scrollViewport.evaluate((element: Element, step: number) => {
const scrollContainer = element as HTMLElement;
scrollContainer.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -step,
bubbles: true,
cancelable: true,
}),
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
}, delta);
remaining -= delta;
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
return;
}
}
}
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 60000,
});
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
if (expectedTailText) {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight;
})
.toBeGreaterThan(0);
}
}
export async function expectNearBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
@@ -241,15 +54,6 @@ export async function expectNearBottom(page: Page): Promise<void> {
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function expectDetachedFromBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number,
@@ -262,11 +66,3 @@ export async function waitForContentGrowth(
.toBeGreaterThan(previousContentHeight);
return readScrollMetrics(page);
}
export async function getChatContainerKey(page: Page): Promise<string | null> {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}

View File

@@ -1,8 +1,5 @@
import { expect, type Page } from "@playwright/test";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
import { escapeRegex } from "./regex";
export const gotoAppShell = async (page: Page) => {
await page.goto("/");

View File

@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { getE2EDaemonPort } from "./daemon-port";
import { getServerId } from "./server-id";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
@@ -16,90 +16,40 @@ export interface ArchiveTabAgent {
cwd: string;
}
interface ArchiveTabDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
initialPrompt?: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
interface ArchiveTabDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
ArchiveTabDaemonClientConfig,
ArchiveTabDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
/**
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
createAgent(options: {
provider: string;
model: string;
modeId: string;
cwd: string;
title: string;
}): Promise<{ id: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
client: IdleAgentSeedClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
@@ -125,7 +75,7 @@ export async function createIdleAgent(
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);

View File

@@ -1,9 +1,10 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { getServerId } from "./server-id";
import { waitForTabBar } from "./launcher";
function composerInput(page: Page) {
@@ -145,7 +146,7 @@ export async function selectGithubOption(
}
export interface MockAgentSetup {
client: TerminalPerfDaemonClient;
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
}
@@ -154,11 +155,10 @@ export async function startRunningMockAgent(
page: Page,
opts: { prefix: string; model: string; prompt: string },
): Promise<MockAgentSetup> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const serverId = getServerId();
const repo = await createTempGitRepo(opts.prefix);
const client = await connectTerminalClient();
const client = await connectSeedClient();
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const agent = await client.createAgent({

View File

@@ -1,5 +1,8 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getE2EDaemonPort } from "./daemon-port";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
new (config: ClientConfig) => ClientInstance
@@ -13,3 +16,40 @@ export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(
};
return mod.DaemonClient;
}
interface E2EDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
function resolveDaemonWsUrl(): string {
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
}
export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
}
/**
* Connects an in-test daemon client over the isolated E2E daemon's WebSocket.
* The port-6767 guard keeps tests off the developer daemon. Each helper passes
* its own typed client interface as the generic.
*/
export async function connectDaemonClient<ClientInstance extends { connect(): Promise<void> }>(
options: ConnectDaemonClientOptions,
): Promise<ClientInstance> {
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
const client = new DaemonClient({
url: resolveDaemonWsUrl(),
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
clientType: "cli",
appVersion: options.appVersion,
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}

View File

@@ -0,0 +1,38 @@
import { escapeRegex } from "./regex";
/**
* Resolves the isolated E2E daemon's port, which Playwright's globalSetup
* publishes into the environment before any spec runs. Helpers and specs that
* build daemon WebSocket URLs, route patterns, or host endpoints share this
* accessor instead of re-reading the env var.
*
* The port-6767 guard is a hard guardrail: 6767 is the developer's default
* daemon, which manages real agents. The e2e port is never legitimately 6767,
* so refusing it here keeps every test off the developer daemon.
*/
export function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon (6767).");
}
return port;
}
/**
* Playwright `routeWebSocket` matcher for a WebSocket on `port`. Matches the
* `:<port>` segment at a word boundary, so it catches the URL regardless of
* host or path. Use this when intercepting connections to an arbitrary port
* (e.g. blocking an unreachable test host); for the E2E daemon itself, prefer
* `daemonWsRoutePattern()`.
*/
export function wsRoutePatternForPort(port: string): RegExp {
return new RegExp(`:${escapeRegex(port)}\\b`);
}
/** `routeWebSocket` matcher for the isolated E2E daemon's WebSocket. */
export function daemonWsRoutePattern(): RegExp {
return wsRoutePatternForPort(getE2EDaemonPort());
}

View File

@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost } from "./settings";
interface DaemonApiStatus {
@@ -26,9 +27,8 @@ export interface RealDaemonState {
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
*/
export async function loadRealDaemonState(): Promise<RealDaemonState> {
const port = process.env.E2E_DAEMON_PORT;
const port = getE2EDaemonPort();
const paseoHome = process.env.E2E_PASEO_HOME;
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);

View File

@@ -1,17 +1,10 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { getServerId } from "./server-id";
// ─── Navigation ────────────────────────────────────────────────────────────
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);

View File

@@ -0,0 +1,69 @@
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
import { getServerId } from "./server-id";
export interface MockAgentWorkspace {
agentId: string;
cwd: string;
client: SeedDaemonClient;
cleanup(): Promise<void>;
}
export interface MockAgentOptions {
repoPrefix: string;
title: string;
initialPrompt?: string;
model?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
/**
* Seeds a temp git repo, opens it as a project, and creates a ready mock-provider
* agent in it via the daemon. Returns the agent id plus a cleanup that closes the
* client and removes the repo. Pair with {@link openAgentRoute} to drive the UI.
*/
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
try {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
return {
agentId: agent.id,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
} catch (error) {
await workspace.cleanup();
throw error;
}
}
export function buildAgentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
export async function openAgentRoute(
page: Page,
input: { cwd: string; agentId: string },
): Promise<void> {
await page.goto(buildAgentRoute(input.cwd, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
}

View File

@@ -1,10 +1,9 @@
import { randomUUID } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
type NewWorkspaceDaemonClient = Pick<
InternalDaemonClient,
@@ -16,13 +15,6 @@ type NewWorkspaceDaemonClient = Pick<
| "openProject"
>;
interface NewWorkspaceDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export interface OpenedProject {
@@ -32,21 +24,6 @@ export interface OpenedProject {
workspaceName: string;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function requireWorkspace(payload: OpenProjectPayload) {
if (payload.error) {
throw new Error(payload.error);
@@ -69,19 +46,9 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
NewWorkspaceDaemonClientConfig,
NewWorkspaceDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-new-workspace-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
return connectDaemonClient<NewWorkspaceDaemonClient>({
clientIdPrefix: "app-e2e-new-workspace",
});
await client.connect();
return client;
}
export async function openProjectViaDaemon(
@@ -316,12 +283,7 @@ export interface AgentCreatedDelayControl {
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const daemonPortPattern = daemonWsRoutePattern();
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port";
// --- Navigation ---
@@ -170,19 +171,13 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
// --- WebSocket helpers ---
function buildDaemonPortPattern(): RegExp {
const port = process.env.E2E_DAEMON_PORT;
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
}
// Proxies all daemon WS traffic transparently until a read_project_config_request
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
// connections pass through so the Reload action can succeed.
export async function installReadTransportFailure(page: Page): Promise<void> {
let armed = true;
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
@@ -230,7 +225,7 @@ export async function installDaemonConnectionGate(
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1001 });
return;

View File

@@ -0,0 +1,8 @@
/**
* Escape a literal string so it can be embedded safely inside a `RegExp`.
* Used across the suite to build dynamic patterns from daemon ports, URLs,
* workspace routes, and user-visible text without regex injection.
*/
export function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -4,14 +4,15 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expectComposerEditable, submitMessage } from "./composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
import { getServerId } from "./server-id";
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
export type RewindFlowMode = "conversation" | "files" | "both";
export interface AgentHandle {
page: Page;
client: TerminalPerfDaemonClient;
client: SeedDaemonClient;
agentId: string;
cwd: string;
provider: RewindFlowProvider;
@@ -33,14 +34,6 @@ interface ProviderLaunchConfig {
const SEND_TIMEOUT_MS = 240_000;
const REWIND_TIMEOUT_MS = 120_000;
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
switch (provider) {
case "claude":
@@ -173,7 +166,7 @@ export async function launchAgent(input: {
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectTerminalClient();
const client = await connectSeedClient();
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
@@ -324,7 +317,7 @@ export async function cleanupRewindFlow(input: {
}
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
const client = handle.client as TerminalPerfDaemonClient & {
const client = handle.client as SeedDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },

View File

@@ -0,0 +1,145 @@
import path from "node:path";
import { readFileSync } from "node:fs";
import { connectDaemonClient } from "./daemon-client-loader";
import { createTempDirectory, createTempGitRepo } from "./workspace";
/**
* 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
* browser. Domain-specific helpers wrap it for their own flows; specs should
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
} | null;
error: string | null;
}>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
cwd: string;
title?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
}>;
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
}
export async function connectSeedClient(): Promise<SeedDaemonClient> {
return connectDaemonClient<SeedDaemonClient>({
clientIdPrefix: "seed",
appVersion: loadAppVersion(),
});
}
/**
* A temp project opened as a workspace, with a seed client connected to drive
* it out of band. `cleanup` closes the client and removes the project. This is
* the canonical bootstrap for specs that need a real workspace plus daemon
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
*/
export interface SeededWorkspace {
client: SeedDaemonClient;
repoPath: string;
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Stable project identity the daemon groups workspaces under. */
projectId: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
}
export async function seedWorkspace(options: {
repoPrefix: string;
/** Repo fixture options; only applies to git projects (the default). */
repo?: Parameters<typeof createTempGitRepo>[1];
/** Set to false to seed a plain non-git directory instead of a git repo. */
git?: boolean;
}): Promise<SeededWorkspace> {
const project =
options.git === false
? await createTempDirectory(options.repoPrefix)
: await createTempGitRepo(options.repoPrefix, options.repo);
const client = await connectSeedClient();
try {
const opened = await client.openProject(project.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
}
return {
client,
repoPath: project.path,
workspaceId: opened.workspace.id,
workspaceName: opened.workspace.name,
workspaceDirectory: opened.workspace.workspaceDirectory,
projectId: opened.workspace.projectId,
projectDisplayName: opened.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
},
};
} catch (error) {
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
throw error;
}
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}

View File

@@ -0,0 +1,13 @@
/**
* Resolves the isolated E2E daemon's server id, which Playwright's globalSetup
* publishes into the environment before any spec runs. Helpers and specs that
* build host routes or `sidebar-workspace-row-${serverId}:${id}` selectors share
* this accessor instead of re-reading the env var.
*/
export function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}

View File

@@ -1,9 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { requireServerId } from "./sidebar";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
const SECTION_LABELS = {
general: "General",
@@ -113,13 +110,13 @@ export async function expectHostSettingsUrl(page: Page, serverId: string): Promi
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
const serverId = requireServerId();
const serverId = getServerId();
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expectHostSettingsUrl(page, serverId);
}
export async function openCompactSettingsHost(page: Page): Promise<void> {
const serverId = requireServerId();
const serverId = getServerId();
await openSettingsHost(page, serverId);
await expectHostSettingsUrl(page, serverId);
}
@@ -159,7 +156,7 @@ export async function expectDiagnosticsContent(page: Page): Promise<void> {
}
export async function expectAboutContent(page: Page): Promise<void> {
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
await expect(page.getByText("App version", { exact: true }).first()).toBeVisible();
}
export async function expectGeneralContent(page: Page): Promise<void> {

View File

@@ -1,15 +1,8 @@
import { expect, type Page } from "@playwright/test";
export function requireServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
import { getServerId } from "./server-id";
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}

View File

@@ -1,5 +1,6 @@
import { expect, type Page } from "../fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { wsRoutePatternForPort } from "./daemon-port";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
@@ -78,7 +79,7 @@ class StartupScenario {
}
for (const port of this.blockedEndpointPorts) {
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
});
}
@@ -227,7 +228,3 @@ function buildStoredHost(input: {
function buildStoredCreateAgentPreferences(serverId: string) {
return buildCreateAgentPreferences(serverId);
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -1,11 +1,7 @@
import type { Page } from "@playwright/test";
import { createTempGitRepo } from "./workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./terminal-perf";
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
interface TempRepo {
path: string;
@@ -19,12 +15,12 @@ export interface TerminalInstance {
}
export class TerminalE2EHarness {
readonly client: TerminalPerfDaemonClient;
readonly client: SeedDaemonClient;
readonly tempRepo: TempRepo;
readonly workspaceId: string;
private constructor(input: {
client: TerminalPerfDaemonClient;
client: SeedDaemonClient;
tempRepo: TempRepo;
workspaceId: string;
}) {
@@ -35,7 +31,7 @@ export class TerminalE2EHarness {
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
const tempRepo = await createTempGitRepo(input.tempPrefix);
const client = await connectTerminalClient();
const client = await connectSeedClient();
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) {
await client.close().catch(() => {});

View File

@@ -1,111 +1,6 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export interface TerminalPerfDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: { id: string; name: string; projectRootPath: string } | null;
error: string | null;
}>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
cwd: string;
title?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
}>;
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
TerminalPerfDaemonClientConfig,
TerminalPerfDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
appVersion: loadAppVersion(),
webSocketFactory,
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}
import { getServerId } from "./server-id";
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();

View File

@@ -1,10 +1,9 @@
import { execSync } from "node:child_process";
import { realpath } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { waitForTabBar } from "./launcher";
import { selectWorkspaceInSidebar } from "./sidebar";
import { createTempGitRepo } from "./workspace";
import { createTempGitRepo, resolveTempRoot } from "./workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
@@ -49,7 +48,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let workspacePath = repo.path;
if (options?.worktree) {
const tempRoot = await realpath("/tmp");
const tempRoot = await resolveTempRoot();
workspacePath = path.join(
tempRoot,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,

View File

@@ -1,58 +1,25 @@
import { realpathSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { loadDaemonClientConstructor } from "./daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { connectDaemonClient } from "./daemon-client-loader";
import { getServerId } from "./server-id";
import { switchWorkspaceViaSidebar } from "./workspace-ui";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
interface WorkspaceSetupDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
fetchWorkspaces(): Promise<{
entries: Array<{
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}>;
}>;
fetchAgents(): Promise<{
entries: Array<{
agent: { id: string; cwd: string; workspaceId?: string | null };
}>;
}>;
fetchAgent(agentId: string): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown;
} | null>;
listTerminals(cwd: string): Promise<{
cwd?: string;
terminals: Array<{ id: string; cwd: string; name: string }>;
error?: string | null;
}>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
}
type WorkspaceSetupDaemonClient = Pick<
InternalDaemonClient,
| "close"
| "connect"
| "createPaseoWorktree"
| "fetchAgent"
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "openProject"
| "subscribeRawMessages"
>;
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
@@ -61,43 +28,30 @@ export type WorkspaceSetupProgressPayload = Extract<
export type { WorkspaceSetupDaemonClient };
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
return connectDaemonClient<WorkspaceSetupDaemonClient>({ clientIdPrefix: "workspace-setup" });
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
{
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
},
WorkspaceSetupDaemonClient
>();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `workspace-setup-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
export async function openProjectViaDaemon(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
return {
id: result.workspace.id,
name: result.workspace.name,
workspaceDirectory: result.workspace.workspaceDirectory,
};
}
export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
await openProjectViaDaemon(client, repoPath);
}
export function projectNameFromPath(repoPath: string): string {
@@ -304,11 +258,11 @@ export async function navigateToWorkspaceViaSidebar(
page: Page,
workspaceId: string,
): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
targetWorkspacePath: workspaceId,
});
}
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {

View File

@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
import { escapeRegex } from "./regex";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
@@ -23,10 +24,6 @@ export function workspaceLabelFromPath(value: string): string {
return parts[parts.length - 1] ?? normalized;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]);

View File

@@ -9,6 +9,20 @@ interface TempRepo {
cleanup: () => Promise<void>;
}
export interface TempDirectory {
path: string;
cleanup: () => Promise<void>;
}
/**
* The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp →
* /private/tmp) so fixture paths match the daemon's resolved paths; on Windows
* `/tmp` doesn't exist, so fall back to the OS temp dir.
*/
export async function resolveTempRoot(): Promise<string> {
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
}
async function configureRemote(input: {
repoPath: string;
withRemote: boolean;
@@ -22,6 +36,15 @@ async function configureRemote(input: {
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
if (originUrl) {
// Relabel origin to a display URL after the local tracking remote is set
// up, so project grouping shows the remote's owner/repo while branch
// tracking refs still resolve locally (no fetch from the synthetic URL).
execSync(`git remote set-url origin ${JSON.stringify(originUrl)}`, {
cwd: repoPath,
stdio: "ignore",
});
}
return;
}
if (originUrl) {
@@ -44,9 +67,7 @@ export const createTempGitRepo = async (
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const repoPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
const withRemote = options?.withRemote ?? false;
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
@@ -110,6 +131,21 @@ export const createTempGitRepo = async (
};
};
/**
* A plain (non-git) directory opened as a project. The daemon shows its
* basename as the project name, since there's no remote to group under.
*/
export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<TempDirectory> {
const dirPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, { recursive: true, force: true });
},
};
}
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;

View File

@@ -1,5 +1,4 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
gotoWorkspace,
assertNewChatTileVisible,
@@ -16,30 +15,19 @@ import {
terminalSurfaceLocator,
} from "./helpers/launcher";
import { expectComposerVisible, composerLocator } from "./helpers/composer";
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
import {
connectTerminalClient,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";
import { expectTerminalSurfaceVisible, setupDeterministicPrompt } from "./helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
// ─── Shared state ──────────────────────────────────────────────────────────
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: TerminalPerfDaemonClient;
let workspace: SeededWorkspace;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("launcher-e2e-");
seedClient = await connectTerminalClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
workspaceId = result.workspace.id;
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
});
test.afterAll(async () => {
if (seedClient) await seedClient.close();
if (tempRepo) await tempRepo.cleanup();
await workspace?.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -48,7 +36,7 @@ test.afterAll(async () => {
test.describe("Tab creation", () => {
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await pressNewTabShortcut(page);
@@ -56,7 +44,7 @@ test.describe("Tab creation", () => {
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
@@ -75,7 +63,7 @@ test.describe("Tab creation", () => {
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
@@ -88,7 +76,7 @@ test.describe("Tab creation", () => {
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
@@ -100,7 +88,7 @@ test.describe("Tab creation", () => {
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page);
@@ -117,26 +105,16 @@ test.describe("Terminal title propagation", () => {
// must re-render before the assertion deadline. Allow retries.
test.describe.configure({ retries: 2 });
let client: TerminalPerfDaemonClient;
test.beforeAll(async () => {
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) await client.close();
});
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "title-test");
const result = await workspace.client.createTerminal(workspace.repoPath, "title-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
@@ -153,19 +131,19 @@ test.describe("Terminal title propagation", () => {
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "debounce-test");
const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
@@ -188,7 +166,7 @@ test.describe("Terminal title propagation", () => {
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
});
@@ -199,7 +177,7 @@ test.describe("Terminal title propagation", () => {
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
@@ -221,7 +199,7 @@ test.describe("Tab transitions (no flash)", () => {
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
@@ -237,7 +215,7 @@ test.describe("Tab transitions (no flash)", () => {
});
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,

View File

@@ -26,6 +26,7 @@ import {
selectPickerOptionByKeyboard,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
@@ -61,10 +62,7 @@ test.describe("New workspace flow", () => {
});
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const firstRepo = await createTempGitRepo("workspace-nav-a-");
const secondRepo = await createTempGitRepo("workspace-nav-b-");
@@ -118,10 +116,7 @@ test.describe("New workspace flow", () => {
});
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const repo = await createTempGitRepo("workspace-nav-same-project-");
@@ -201,10 +196,7 @@ test.describe("New workspace flow", () => {
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-");
@@ -276,10 +268,7 @@ test.describe("New workspace flow", () => {
});
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
@@ -355,10 +344,7 @@ test.describe("New workspace flow", () => {
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
branches: ["main", "dev"],

View File

@@ -10,6 +10,7 @@ import { gotoWorkspace } from "./helpers/launcher";
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
import {
connectWorkspaceSetupClient,
openProjectViaDaemon,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
@@ -50,11 +51,8 @@ test.describe("PR pane", () => {
});
for (const pr of repoFixture.prs) {
const result = await seedClient.openProject(pr.localPath);
if (!result.workspace) {
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
}
workspaceByTitle.set(pr.title, result.workspace.id);
const workspace = await openProjectViaDaemon(seedClient, pr.localPath);
workspaceByTitle.set(pr.title, workspace.id);
}
});

View File

@@ -1,8 +1,7 @@
import { chmod, readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base } from "./fixtures";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { seedWorkspace } from "./helpers/seed-client";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
@@ -63,38 +62,36 @@ const initialPaseoConfig = {
const test = base.extend<ProjectsSettingsFixtures>({
editableProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-", {
paseoConfig: initialPaseoConfig,
const workspace = await seedWorkspace({
repoPrefix: "projects-settings-",
repo: { paseoConfig: initialPaseoConfig },
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
name: workspace.projectDisplayName,
path: workspace.repoPath,
});
await client.close();
// Defensive: restore directory write permission in case the test left it blocked
// (write_failed test), so that repo.cleanup() can remove files inside.
await chmod(repo.path, 0o755).catch(() => undefined);
await repo.cleanup();
// (write_failed test), so that cleanup can remove files inside.
await chmod(workspace.repoPath, 0o755).catch(() => undefined);
await workspace.cleanup();
},
gitlabRemoteProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-gitlab-", {
paseoConfig: initialPaseoConfig,
originUrl: "https://gitlab.com/acme/app.git",
const workspace = await seedWorkspace({
repoPrefix: "projects-settings-gitlab-",
repo: {
paseoConfig: initialPaseoConfig,
originUrl: "https://gitlab.com/acme/app.git",
},
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
name: workspace.projectDisplayName,
path: workspace.repoPath,
});
await client.close();
await repo.cleanup();
await workspace.cleanup();
},
});

View File

@@ -1,7 +1,5 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
composerLocator,
expectComposerDraft,
@@ -12,50 +10,24 @@ import {
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
test.describe("Rewind sheet", () => {
test("rewinds from a user message sheet option", async ({ page }) => {
const repo = await createTempGitRepo("rewind-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-e2e-",
title: "Rewind e2e",
initialPrompt: firstPrompt,
});
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageCount(page, 1);
@@ -116,31 +88,25 @@ test.describe("Rewind sheet", () => {
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
} finally {
await client.close();
await repo.cleanup();
await session.cleanup();
}
});
test("surfaces rewind failures without crashing the page", async ({ page }) => {
const repo = await createTempGitRepo("rewind-failure-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-failure-e2e-",
title: "Rewind failure e2e",
initialPrompt: firstPrompt,
featureValues: {
mockRewindError: rewindError,
},
});
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind failure e2e",
modeId: "load-test",
model: "ten-second-stream",
featureValues: {
mockRewindError: rewindError,
},
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
@@ -160,8 +126,7 @@ test.describe("Rewind sheet", () => {
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {
await client.close();
await repo.cleanup();
await session.cleanup();
}
});
});

View File

@@ -1,6 +1,8 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
import { getServerId } from "./helpers/server-id";
import {
expectSettingsHeader,
openSettingsHost,
@@ -16,28 +18,12 @@ import {
expectLocalHostEntryFirst,
} from "./helpers/settings";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
function getSeededDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
return port;
}
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
const serverId = getSeededServerId();
const port = getSeededDaemonPort();
const serverId = getServerId();
const port = getE2EDaemonPort();
await gotoAppShell(page);
await openSettings(page);
@@ -51,7 +37,7 @@ test.describe("Settings host page", () => {
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
@@ -65,7 +51,7 @@ test.describe("Settings host page", () => {
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
@@ -85,7 +71,7 @@ test.describe("Settings host page", () => {
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
page,
}) => {
const serverId = getSeededServerId();
const serverId = getServerId();
await gotoAppShell(page);
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
@@ -97,7 +83,7 @@ test.describe("Settings host page", () => {
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
const serverId = getSeededServerId();
const serverId = getServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned

View File

@@ -1,21 +1,9 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import { createIdleAgent, openWorkspaceWithAgents } from "./helpers/archive-tab";
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
@@ -61,20 +49,17 @@ test.describe("Settings toggle tab regression", () => {
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("settings-toggle-tab-");
const agentIds: string[] = [];
const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" });
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `settings-toggle-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
@@ -89,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
@@ -97,11 +82,7 @@ test.describe("Settings toggle tab regression", () => {
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
@@ -109,31 +90,28 @@ test.describe("Settings toggle tab regression", () => {
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("agent-route-refresh-");
const agentIds: string[] = [];
const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" });
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `agent-route-refresh-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
workspaceId: workspace.repoPath,
agentId: firstAgent.id,
});
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
workspaceId: workspace.repoPath,
agentId: secondAgent.id,
});
@@ -143,11 +121,7 @@ test.describe("Settings toggle tab regression", () => {
await expectAgentTabActive(page, secondAgent.id);
}
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -1,17 +1,9 @@
import { execSync } from "node:child_process";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
import { seedWorkspace } from "./helpers/seed-client";
import { captureWsSessionFrames } from "./helpers/rename";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
import { getServerId } from "./helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -21,21 +13,6 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
}
async function openProjectViaDaemon(
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
cwd: string,
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
const result = await client.openProject(cwd);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${cwd}`);
}
return {
id: String(result.workspace.id),
name: result.workspace.name,
workspaceDirectory: result.workspace.workspaceDirectory,
};
}
async function openRenameModal(page: Page, workspaceId: string) {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
@@ -59,12 +36,10 @@ test.describe("Sidebar workspace rename", () => {
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-rename-");
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
try {
const workspace = await openProjectViaDaemon(client, repo.path);
expect(workspace.name).toBe("main");
expect(workspace.workspaceName).toBe("main");
const renameRequests = captureWsSessionFrames(
page,
@@ -76,18 +51,18 @@ test.describe("Sidebar workspace rename", () => {
);
await gotoAppShell(page);
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
const input = await openRenameModal(page, workspace.id);
const input = await openRenameModal(page, workspace.workspaceId);
await expect(input).toHaveValue("main");
await input.fill("Feature Rename 2");
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText(
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"feature-rename-2",
{ timeout: 15_000 },
);
@@ -99,40 +74,42 @@ test.describe("Sidebar workspace rename", () => {
});
const currentBranchOnDisk = execSync("git branch --show-current", {
cwd: repo.path,
cwd: workspace.repoPath,
stdio: "pipe",
})
.toString()
.trim();
expect(currentBranchOnDisk).toBe("feature-rename-2");
} finally {
await client.close();
await repo.cleanup();
await workspace.cleanup();
}
});
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-rename-error-",
repo: { branches: ["taken"] },
});
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
const input = await openRenameModal(page, workspace.id);
const input = await openRenameModal(page, workspace.workspaceId);
await expect(input).toHaveValue("main");
await input.fill("taken");
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error"));
const errorNode = page.getByTestId(
workspaceRenameModalTestId(workspace.workspaceId, "error"),
);
await expect(errorNode).toBeVisible({ timeout: 15_000 });
await expect(errorNode).toContainText(/already exists|branch/i);
await expect(input).toBeVisible();
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main");
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"main",
);
} finally {
await client.close();
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -1,6 +1,3 @@
import { execSync } from "node:child_process";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
@@ -10,59 +7,17 @@ import {
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
import { getServerId } from "./helpers/server-id";
import { escapeRegex } from "./helpers/regex";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
function getWorkspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function setGitHubRemote(repoPath: string): void {
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
cwd: repoPath,
stdio: "ignore",
});
}
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const dirPath = await mkdtemp(path.join(tempRoot, prefix));
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, { recursive: true, force: true });
},
};
}
async function openProjectViaDaemon(
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
cwd: string,
): Promise<{ id: string; name: string }> {
const result = await client.openProject(cwd);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${cwd}`);
}
return {
id: result.workspace.id,
name: result.workspace.name,
};
}
async function openWorkspaceFromSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
@@ -92,15 +47,15 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
test.describe("Sidebar workspace list", () => {
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-remote-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
});
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
await waitForSidebarWorkspace(page, workspace.workspaceId);
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
@@ -108,81 +63,73 @@ test.describe("Sidebar workspace list", () => {
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).not.toContainText(path.basename(repo.path));
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
} finally {
await client.close();
await repo.cleanup();
await workspace.cleanup();
}
});
test("project shows workspace under it", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
await waitForSidebarWorkspace(page, workspace.id);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await waitForSidebarWorkspace(page, workspace.workspaceId);
} finally {
await client.close();
await repo.cleanup();
await workspace.cleanup();
}
});
test("non-git project shows directory name", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const project = await createTempDirectory("sidebar-directory-");
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
try {
await openProjectViaDaemon(client, project.path);
await gotoAppShell(page);
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
await expect(projectRow).toContainText(path.basename(project.path));
const directoryName = path.basename(workspace.repoPath);
const projectRow = await waitForSidebarProject(page, directoryName);
await expect(projectRow).toContainText(directoryName);
} finally {
await client.close();
await project.cleanup();
await workspace.cleanup();
}
});
test("workspace header shows correct title and subtitle", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-header-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
});
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
await openWorkspaceFromSidebar(page, workspace.id);
await waitForSidebarWorkspace(page, workspace.workspaceId);
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await expectWorkspaceHeader(page, {
title: workspace.name,
title: workspace.workspaceName,
subtitle: "test-owner/test-repo",
});
} finally {
await client.close();
await repo.cleanup();
await workspace.cleanup();
}
});
test("git project shows branch name in workspace row", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-branch-");
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
await waitForSidebarProject(page, path.basename(workspace.repoPath));
expect(workspace.name).toBe("main");
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
expect(workspace.workspaceName).toBe("main");
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
"main",
);
} finally {
await client.close();
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -1,22 +1,8 @@
import { test } from "./fixtures";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id";
import { startupScenario } from "./helpers/startup-dsl";
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return port;
}
function getE2EServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
test.describe("Startup loading presentation", () => {
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
const startup = await startupScenario(page)
@@ -49,7 +35,7 @@ test.describe("Startup loading presentation", () => {
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
page,
}) => {
const serverId = getE2EServerId();
const serverId = getServerId();
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())

View File

@@ -1,14 +1,10 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
@@ -20,43 +16,26 @@ const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
terminalPerfDescribe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
// Seed the workspace in the daemon so the app can resolve the path
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
workspaceId = seedResult.workspace.id;
harness = await TerminalE2EHarness.create({ tempPrefix: "perf-" });
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
await harness?.cleanup();
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
const created = await harness.createTerminal({ name: "throughput" });
try {
await navigateToTerminal(page, { workspaceId, terminalId });
await setupDeterministicPrompt(page);
await harness.openTerminal(page, { terminalId: created.id });
await harness.setupPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const terminal = harness.terminalSurface(page);
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
@@ -97,25 +76,20 @@ terminalPerfDescribe("Terminal wire performance", () => {
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
).toBeLessThan(THROUGHPUT_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
await harness.killTerminal(created.id);
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
const created = await harness.createTerminal({ name: "latency" });
try {
await navigateToTerminal(page, { workspaceId, terminalId });
await setupDeterministicPrompt(page);
await harness.openTerminal(page, { terminalId: created.id });
await harness.setupPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
const terminal = harness.terminalSurface(page);
await terminal.press("Control+c");
await page.waitForTimeout(200);
@@ -166,7 +140,7 @@ terminalPerfDescribe("Terminal wire performance", () => {
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
await harness.killTerminal(created.id);
}
});
});

View File

@@ -1,7 +1,5 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
composerLocator,
expectComposerEditable,
@@ -10,27 +8,6 @@ import {
submitMessage,
} from "./helpers/composer";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
}
@@ -50,8 +27,10 @@ async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
test.describe("User message UI contract", () => {
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
const repo = await createTempGitRepo("user-message-contract-e2e-");
const client = await connectTerminalClient();
const session = await seedMockAgentWorkspace({
repoPrefix: "user-message-contract-e2e-",
title: "User message contract e2e",
});
const prompts = [
"emit 1 coalesced agent stream updates for user message contract turn one.",
"emit 1 coalesced agent stream updates for user message contract turn two.",
@@ -59,15 +38,8 @@ test.describe("User message UI contract", () => {
];
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "User message contract e2e",
modeId: "load-test",
model: "ten-second-stream",
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await openAgentRoute(page, session);
await expectComposerVisible(page);
for (let index = 0; index < prompts.length; index += 1) {
const prompt = prompts[index]!;
@@ -85,8 +57,7 @@ test.describe("User message UI contract", () => {
await expectUserMessageCount(page, 3);
await expectIdleComposer(page);
} finally {
await client.close();
await repo.cleanup();
await session.cleanup();
}
});
});

View File

@@ -1,22 +1,11 @@
import { randomUUID } from "node:crypto";
import { test, expect, type Page } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectArchiveTabDaemonClient,
createIdleAgent,
expectWorkspaceTabVisible,
} from "./helpers/archive-tab";
import { seedWorkspace } from "./helpers/seed-client";
import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
import { getServerId } from "./helpers/server-id";
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
@@ -34,13 +23,12 @@ test.describe("Workspace agent tab rename", () => {
}) => {
test.setTimeout(120_000);
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("workspace-agent-rename-");
const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-rename-" });
try {
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
const agent = await createIdleAgent(client, {
cwd: repo.path,
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: initialTitle,
});
@@ -81,8 +69,7 @@ test.describe("Workspace agent tab rename", () => {
expect(lastFrame.name).toBe(renamed);
expect(lastFrame.requestId.length).toBeGreaterThan(0);
} finally {
await client.close();
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -1,9 +1,9 @@
import { test, expect, type Page } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { captureWsSessionFrames } from "./helpers/rename";
import { getServerId } from "./helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface WorkspaceTabProbeRecord {
@@ -20,14 +20,6 @@ interface CapturedCreateAgentFrame {
configTitle: string | null;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function installWorkspaceTabProbe(page: Page): Promise<void> {
await page.evaluate(() => {
type ProbeRecord = WorkspaceTabProbeRecord;
@@ -122,10 +114,7 @@ function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
return record.tabs.some(tabHasLoadingTitle);
}
async function waitForCreatedAgentId(
client: TerminalPerfDaemonClient,
cwd: string,
): Promise<string> {
async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise<string> {
await expect
.poll(
async () => {
@@ -146,7 +135,7 @@ async function waitForCreatedAgentId(
}
async function fetchActiveAgentTitle(
client: TerminalPerfDaemonClient,
client: SeedDaemonClient,
agentId: string,
): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
@@ -179,15 +168,9 @@ test.describe("Workspace agent title handoff", () => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1440, height: 900 });
const client = await connectTerminalClient();
const repo = await createTempGitRepo("workspace-title-handoff-");
const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" });
try {
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? "Failed to open test workspace");
}
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
@@ -196,7 +179,7 @@ test.describe("Workspace agent title handoff", () => {
};
});
await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id));
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").click();
await expectComposerVisible(page);
@@ -206,7 +189,7 @@ test.describe("Workspace agent title handoff", () => {
await installWorkspaceTabProbe(page);
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
const agentId = await waitForCreatedAgentId(client, repo.path);
const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath);
await expect
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
timeout: 10_000,
@@ -219,9 +202,9 @@ test.describe("Workspace agent title handoff", () => {
await waitForPromptTabAgentActions(page, promptTitle);
await client.updateAgent(agentId, { name: generatedTitle });
await workspace.client.updateAgent(agentId, { name: generatedTitle });
await expect
.poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 })
.poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 })
.toBe(generatedTitle);
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
timeout: 15_000,
@@ -232,8 +215,7 @@ test.describe("Workspace agent title handoff", () => {
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -1,5 +1,6 @@
import { expect, type Page, test } from "./fixtures";
import { expect, test } from "./fixtures";
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
import { captureWsSessionFrames } from "./helpers/rename";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
@@ -8,53 +9,13 @@ import {
waitForTerminalContent,
} from "./helpers/terminal-perf";
async function installCreateAgentRequestRecorder(page: Page): Promise<void> {
await page.addInitScript(() => {
const requests: unknown[] = [];
(
window as typeof window & {
__paseoE2eCreateAgentRequests?: unknown[];
}
).__paseoE2eCreateAgentRequests = requests;
const originalSend = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
if (typeof data === "string") {
try {
const parsed = JSON.parse(data) as {
type?: unknown;
message?: { type?: unknown };
};
if (parsed.type === "session" && parsed.message?.type === "create_agent_request") {
requests.push(parsed.message);
}
} catch {
// Ignore non-JSON frames.
}
}
return originalSend.call(this, data);
};
});
interface CreateAgentFrame {
initialPrompt: string | null;
cwd: string | null;
}
async function getRecordedCreateAgentCwd(page: Page, message: string): Promise<string | null> {
return page.evaluate((expectedMessage) => {
const requests =
(
window as typeof window & {
__paseoE2eCreateAgentRequests?: Array<{
initialPrompt?: string;
config?: { cwd?: string };
}>;
}
).__paseoE2eCreateAgentRequests ?? [];
for (const request of requests) {
if (request.initialPrompt === expectedMessage) {
return request.config?.cwd ?? null;
}
}
return null;
}, message);
function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null {
return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null;
}
test.describe("Workspace cwd correctness", () => {
@@ -78,7 +39,14 @@ test.describe("Workspace cwd correctness", () => {
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
await installCreateAgentRequestRecorder(page);
const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
cwd: typeof config.cwd === "string" ? config.cwd : null,
};
});
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
await workspace.navigateTo();
@@ -97,7 +65,7 @@ test.describe("Workspace cwd correctness", () => {
});
await expect
.poll(async () => getRecordedCreateAgentCwd(page, message), { timeout: 30_000 })
.poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 })
.toBe(workspace.repoPath);
});

View File

@@ -3,20 +3,14 @@ import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import {
archiveLocalWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { expectComposerVisible } from "./helpers/composer";
import { createTempGitRepo } from "./helpers/workspace";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { seedWorkspace } from "./helpers/seed-client";
import {
getVisibleWorkspaceAgentTabIds,
expectOnlyWorkspaceAgentTabsVisible,
@@ -37,13 +31,10 @@ import {
expectWorkspaceDeckEntryCount,
} from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function expectNoLoadingWorkspacePane(
page: Page,
input: { label: string; durationMs?: number },
@@ -88,11 +79,11 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
await closeButton.first().click();
}
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
return;
@@ -166,32 +157,17 @@ test.describe("Workspace navigation regression", () => {
});
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const serverId = getServerId();
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
const daemonGate = await installDaemonWebSocketGate(page);
const workspaceClient = await connectNewWorkspaceDaemonClient();
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceIds = new Set<string>();
const agentIds: string[] = [];
const repo = await createTempGitRepo("workspace-reconnect-");
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
try {
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
workspaceIds.add(workspace.workspaceId);
const agent = await createIdleAgent(archiveClient, {
cwd: repo.path,
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `workspace-reconnect-${Date.now()}`,
});
agentIds.push(agent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -231,31 +207,16 @@ test.describe("Workspace navigation regression", () => {
await expectComposerVisible(page);
} finally {
daemonGate.restore();
for (const agentId of agentIds) {
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const serverId = getServerId();
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => {
await page.routeWebSocket(daemonWsRoutePattern(), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
});
@@ -272,22 +233,12 @@ test.describe("Workspace navigation regression", () => {
});
test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const workspaceClient = await connectNewWorkspaceDaemonClient();
const workspaceIds = new Set<string>();
const firstRepo = await createTempGitRepo("workspace-cold-url-a-");
const secondRepo = await createTempGitRepo("workspace-cold-url-b-");
const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-a-" });
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-b-" });
try {
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
workspaceIds.add(firstWorkspace.workspaceId);
workspaceIds.add(secondWorkspace.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await waitForSidebarHydration(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
@@ -304,45 +255,28 @@ test.describe("Workspace navigation regression", () => {
timeout: 30_000,
});
} finally {
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await workspaceClient.close().catch(() => undefined);
await secondRepo.cleanup();
await firstRepo.cleanup();
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const workspaceClient = await connectNewWorkspaceDaemonClient();
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceIds = new Set<string>();
const agentIds: string[] = [];
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-a-" });
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
try {
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
workspaceIds.add(firstWorkspace.workspaceId);
workspaceIds.add(secondWorkspace.workspaceId);
const firstAgent = await createIdleAgent(archiveClient, {
cwd: firstRepo.path,
const firstAgent = await createIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(archiveClient, {
cwd: secondRepo.path,
const secondAgent = await createIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
title: `workspace-nav-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -487,16 +421,8 @@ test.describe("Workspace navigation regression", () => {
`workspace-tab-agent_${firstAgent.id}`,
]);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
await secondRepo.cleanup();
await firstRepo.cleanup();
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
});

View File

@@ -1,12 +1,9 @@
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
} from "./helpers/archive-tab";
import { createIdleAgent } from "./helpers/archive-tab";
import { expectComposerVisible } from "./helpers/composer";
import { createTempGitRepo } from "./helpers/workspace";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
test.describe("Workspace pane mounting", () => {
@@ -14,21 +11,15 @@ test.describe("Workspace pane mounting", () => {
page,
}) => {
test.setTimeout(90_000);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("pane-remount-");
let agentId: string | null = null;
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
try {
const agent = await createIdleAgent(client, {
cwd: repo.path,
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
title: `pane-remount-${Date.now()}`,
});
agentId = agent.id;
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.waitForURL(
@@ -54,11 +45,7 @@ test.describe("Workspace pane mounting", () => {
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
} finally {
if (agentId) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
await workspace.cleanup();
}
});
});

View File

@@ -9,6 +9,7 @@ import {
findWorktreeWorkspaceForProject,
navigateToWorkspaceViaSidebar,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
test.describe("Workspace setup runtime authority", () => {
@@ -21,7 +22,7 @@ test.describe("Workspace setup runtime authority", () => {
const repo = await createTempGitRepo("workspace-setup-chat-");
try {
await client.openProject(repo.path);
await seedProjectForWorkspaceSetup(client, repo.path);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-chat-${Date.now()}`,
@@ -48,7 +49,7 @@ test.describe("Workspace setup runtime authority", () => {
const repo = await createTempGitRepo("workspace-setup-terminal-");
try {
await client.openProject(repo.path);
await seedProjectForWorkspaceSetup(client, repo.path);
// Create workspace via daemon API since the new workspace screen
// no longer has a standalone terminal button

View File

@@ -1,50 +1,42 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
test.describe("Workspace terminal tab rename", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
page,
}) => {
test.setTimeout(60_000);
const client = await connectTerminalClient();
const repo = await createTempGitRepo("workspace-terminal-rename-");
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
terminalId: String(inner.terminalId ?? ""),
title: String(inner.title ?? ""),
requestId: String(inner.requestId ?? ""),
}));
try {
const seeded = await client.openProject(repo.path);
if (!seeded.workspace) {
throw new Error(seeded.error ?? "Failed to seed workspace");
}
const workspaceId = seeded.workspace.id;
const created = await client.createTerminal(repo.path);
if (!created.terminal) {
throw new Error(created.error ?? "Failed to create terminal");
}
const terminalId = created.terminal.id;
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
terminalId: String(inner.terminalId ?? ""),
title: String(inner.title ?? ""),
requestId: String(inner.requestId ?? ""),
}));
await navigateToTerminal(page, { workspaceId, terminalId });
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => {
const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
@@ -56,12 +48,9 @@ test.describe("Workspace terminal tab rename", () => {
expect(renameFrames.length).toBeGreaterThan(0);
const lastFrame = renameFrames.at(-1)!;
expect(lastFrame.terminalId).toBe(terminalId);
expect(lastFrame.terminalId).toBe(terminal.id);
expect(lastFrame.title).toBe("My Renamed Terminal");
expect(lastFrame.requestId.length).toBeGreaterThan(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.84",
"version": "0.1.87",
"private": true,
"main": "index.ts",
"scripts": {
@@ -85,14 +85,14 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
@@ -100,7 +100,7 @@
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",

View File

@@ -1050,7 +1050,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingVertical: 0,
flexGrow: 1,
paddingHorizontal: {
xs: theme.spacing[2],
xs: theme.spacing[3],
md: theme.spacing[4],
},
},

View File

@@ -45,6 +45,7 @@ export const ACP_PROVIDER_ICON_SVGS = {
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>\n',
goose:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M20.9093 19.3861L19.5185 18.2413C18.7624 17.619 18.1189 16.8713 17.6157 16.0313C16.9205 14.8706 15.9599 13.8912 14.8133 13.1735L14.2533 12.8475C14.0614 12.7141 13.9276 12.5062 13.9086 12.2716C13.8963 12.1204 13.9326 11.9852 14.0171 11.8662C14.3087 11.4553 15.896 9.74698 16.1722 9.51845C16.528 9.22442 16.9243 8.97987 17.2921 8.69986C17.3443 8.66 17.3968 8.62035 17.4485 8.57989C17.4503 8.57808 17.4529 8.57668 17.4545 8.57508C17.5725 8.48195 17.6838 8.383 17.7724 8.26563C18.2036 7.76631 18.195 7.3443 18.195 7.3443C18.195 7.3443 18.1954 7.3439 18.1956 7.3437C18.1497 7.23133 17.9847 6.88163 17.6492 6.71759C17.9458 6.71178 18.2805 6.82294 18.4323 6.97156C18.6148 6.68534 18.7328 6.49967 18.9162 6.18762C18.9599 6.11352 18.9831 5.97652 18.8996 5.89981C18.8996 5.89981 18.8992 5.89981 18.8988 5.89981C18.8988 5.89981 18.8988 5.8994 18.8988 5.899C18.8972 5.8974 18.8952 5.8962 18.8936 5.8946C18.892 5.893 18.891 5.89119 18.8892 5.88939C18.8892 5.88939 18.8888 5.88939 18.8884 5.88939C18.8884 5.88939 18.8884 5.88899 18.8884 5.88859C18.885 5.88518 18.8812 5.88258 18.8776 5.87938C18.8754 5.87717 18.8736 5.87457 18.8716 5.87217C18.8692 5.87016 18.8665 5.86836 18.8643 5.86616C18.8609 5.86275 18.8587 5.85855 18.8551 5.85534C18.8551 5.85534 18.8545 5.85514 18.8543 5.85534C18.8543 5.85534 18.8543 5.85494 18.8543 5.85454C18.8527 5.85294 18.8507 5.85174 18.8491 5.85013C18.8475 5.84853 18.8463 5.84653 18.8447 5.84493C18.8447 5.84493 18.8441 5.84473 18.8439 5.84493C18.8439 5.84493 18.8439 5.84453 18.8439 5.84413C18.7672 5.7606 18.6302 5.78384 18.5561 5.8275C18.1503 6.06625 17.7555 6.32322 17.3996 6.54855C17.3996 6.54855 16.9778 6.53973 16.4783 6.97116C16.3607 7.05989 16.2618 7.17125 16.1688 7.28902C16.167 7.29082 16.1654 7.29322 16.164 7.29503C16.1234 7.3465 16.0837 7.39898 16.0441 7.45145C15.7639 7.81939 15.5195 8.21556 15.2255 8.57128C14.9971 8.84768 13.2887 10.4348 12.8777 10.7264C12.7587 10.8109 12.6237 10.8474 12.4723 10.835C12.2379 10.8161 12.0298 10.6821 11.8965 10.4903L11.5704 9.93024C10.8527 8.78318 9.87332 7.82299 8.71264 7.12778C7.87262 6.62466 7.12514 5.98092 6.50264 5.22503L5.35778 3.83421C5.3013 3.76571 5.19314 3.77693 5.15268 3.85585C5.02249 4.10941 4.77393 4.64479 4.58346 5.36483C4.57885 5.38186 4.58286 5.39988 4.59407 5.4135C4.83082 5.69952 5.37901 6.32983 6.03196 6.863C6.07742 6.90005 6.04017 6.97336 5.98369 6.95774C5.42047 6.80432 4.87288 6.55796 4.46308 6.34805C4.42964 6.33103 4.38918 6.35226 4.38437 6.38951C4.32068 6.89985 4.30425 7.46027 4.37155 8.05112C4.37355 8.07035 4.38577 8.08697 4.4036 8.09479C4.87088 8.29808 5.61816 8.59311 6.40269 8.78078C6.45958 8.7944 6.45777 8.87632 6.40029 8.88733C5.78941 9.0023 5.14968 9.02794 4.62973 9.02113C4.59327 9.02073 4.56643 9.05518 4.57625 9.09023C4.6806 9.45896 4.822 9.8339 5.00847 10.2115C5.08559 10.3811 5.16951 10.5475 5.25944 10.7104C5.27486 10.7382 5.3047 10.7548 5.33655 10.7534C5.76577 10.7324 6.28452 10.6871 6.80608 10.595C6.89501 10.5794 6.94268 10.6964 6.86757 10.7466C6.51345 10.9834 6.13571 11.1873 5.7844 11.3551C5.73733 11.3777 5.72211 11.4378 5.75315 11.4797C5.96186 11.7625 6.19139 12.0301 6.44075 12.2794C6.44075 12.2794 7.66853 13.5441 7.70198 13.6432C8.41841 12.9096 9.59612 12.0964 10.8966 11.3864C9.15488 12.8036 8.18387 13.8499 7.69517 14.4444L7.35447 14.9225C7.17742 15.1708 7.02379 15.4346 6.89541 15.7112C6.46579 16.6356 5.75756 18.5051 5.75756 18.5051C5.70328 18.6515 5.74754 18.7959 5.84168 18.89C5.84388 18.8922 5.84609 18.8944 5.84849 18.8964C5.85069 18.8986 5.8527 18.901 5.8549 18.9032C5.94924 18.9976 6.09345 19.0416 6.23986 18.9874C6.23986 18.9874 8.10897 18.2791 9.03371 17.8495C9.31031 17.7211 9.57429 17.5673 9.82245 17.3905L10.349 17.0153C10.6278 16.8166 11.0096 16.8483 11.2517 17.0904L12.4655 18.3042C12.7148 18.5535 12.9824 18.7831 13.2652 18.9918C13.3073 19.0226 13.3672 19.0076 13.3898 18.9605C13.5579 18.6094 13.7618 18.2313 13.9983 17.8774C14.0486 17.8022 14.1657 17.8501 14.1499 17.9388C14.0576 18.4606 14.0127 18.9794 13.9915 19.4084C13.9899 19.44 14.0067 19.4701 14.0345 19.4855C14.1972 19.5756 14.3636 19.6595 14.5335 19.7364C14.911 19.9229 15.2862 20.0645 15.6547 20.1687C15.6897 20.1785 15.7242 20.1516 15.7238 20.1152C15.7168 19.595 15.7424 18.9553 15.8576 18.3446C15.8684 18.2869 15.9503 18.2851 15.9641 18.3422C16.1516 19.127 16.4466 19.8742 16.6501 20.3413C16.6579 20.3591 16.6744 20.3712 16.6938 20.3734C17.2847 20.4407 17.8451 20.4242 18.3554 20.3606C18.3929 20.3559 18.4141 20.3155 18.3969 20.2818C18.187 19.872 17.9406 19.3241 17.7872 18.7612C17.7718 18.7046 17.8449 18.6675 17.8819 18.713C18.4151 19.3659 19.0454 19.9141 19.3314 20.1508C19.345 20.1621 19.3633 20.1659 19.3801 20.1615C20.1003 19.9712 20.6357 19.7226 20.8891 19.5922C20.968 19.5518 20.9792 19.4436 20.9107 19.3871L20.9093 19.3861Z"/>\n</svg>\n',
grok: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">\n <path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>\n <path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>\n</svg>\n',
junie:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40" fill="none">\n <path d="M25 15H35V16.75C35 29 30.5001 35 16.5001 35H15V25H16.5001C22.6251 25 25 22.875 25 16.75V15Z" fill="currentColor"/>\n <rect x="5" y="15" width="10" height="10" fill="currentColor"/>\n <rect x="15" y="5" width="10" height="10" fill="currentColor"/>\n</svg>\n',
kilo: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40">\n <path fill="currentColor" d="M27.9998324,34.6666921h6.6666279v5.3333023h-8.3809037l-3.6190266-3.6190266v-8.3809037h5.3333023v6.6666279ZM39.9997627,26.2857884l-3.6190266-3.6190266h-8.3809037v5.3333023h6.6666279v6.6666279h5.3333023v-8.3809037ZM17.3332326,22.6667578h-5.3333023v5.3333023h5.3333023v-5.3333023ZM0,36.3809638l3.6190266,3.6190266h13.714206v-5.3333023H5.3333023v-11.9999302H0v13.714206ZM33.9600959,11.9999247V3.619021L30.3410693-.0000056h-7.6745392v5.3333023h5.9602634v6.6666279h-5.9602634v5.3333023h17.3332326v-5.3333023h-6.0396668ZM5.3333023,0H0v17.3332326h5.3333023v-5.9999651h6.6666279v5.9999651h5.3333023v-5.9999651l-5.3333023-5.3333023h-6.6666279V0ZM17.3332326,0h-5.3333023v5.9999651h5.3333023V0Z"/>\n</svg>\n',

View File

@@ -336,12 +336,50 @@ describe("parseAssistantFileLink", () => {
});
});
it("rejects absolute hrefs outside the workspace root", () => {
it("allows absolute hrefs outside the workspace root", () => {
expect(
parseAssistantFileLink("/tmp/outside.txt", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
).toEqual({
raw: "/tmp/outside.txt",
path: "/tmp/outside.txt",
lineStart: undefined,
lineEnd: undefined,
});
});
it("keeps tilde hrefs as direct home-relative file targets", () => {
expect(
parseAssistantFileLink("~/.paseo/plans/file-preview.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "~/.paseo/plans/file-preview.md",
path: "~/.paseo/plans/file-preview.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("~/.paseo/plans/file-preview.md:12", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "~/.paseo/plans/file-preview.md:12",
path: "~/.paseo/plans/file-preview.md",
lineStart: 12,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("~\\.paseo\\plans\\file-preview.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "~\\.paseo\\plans\\file-preview.md",
path: "~/.paseo/plans/file-preview.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("rejects external URLs", () => {
@@ -408,6 +446,15 @@ describe("normalizeInlinePathTarget", () => {
});
});
it("keeps tilde paths as home-relative file targets", () => {
expect(
normalizeInlinePathTarget("~/.paseo/plans/file-preview.md", "/Users/test/project"),
).toEqual({
directory: "~/.paseo/plans",
file: "~/.paseo/plans/file-preview.md",
});
});
it("treats cwd itself as the workspace root directory", () => {
expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({
directory: ".",

View File

@@ -233,10 +233,7 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
};
}
function parseAssistantInlinePathLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
const inlinePathTarget = parseInlinePathToken(value);
if (!inlinePathTarget) {
return null;
@@ -247,10 +244,6 @@ function parseAssistantInlinePathLink(
return null;
}
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
return null;
}
return {
...inlinePathTarget,
path: normalizedPath,
@@ -314,9 +307,7 @@ export function parseAssistantFileLink(
return null;
}
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, {
workspaceRoot: options.workspaceRoot,
});
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
if (inlinePathTarget) {
return inlinePathTarget;
}
@@ -324,7 +315,7 @@ export function parseAssistantFileLink(
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
if (!normalizedPath) {
return null;
}
@@ -363,10 +354,6 @@ export function parseAssistantFileLink(
return null;
}
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
return null;
}
const lines = parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
@@ -402,13 +389,21 @@ function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
const workspaceRoot = normalizePathInput(options.workspaceRoot);
if (!workspaceRoot) {
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
if (isHomeRelativePath(parsed.path)) {
return {
raw: value,
path: parsed.path,
...parsed.lines,
};
}
const workspaceRoot = normalizePathInput(options.workspaceRoot);
if (!workspaceRoot) {
return null;
}
@@ -529,6 +524,10 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
}
function isHomeRelativePath(pathValue: string): boolean {
return pathValue === "~" || pathValue.startsWith("~/") || pathValue.startsWith("~\\");
}
function isExternalHref(value: string): boolean {
if (value.includes("://")) {
return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
classifyForResolution,
fetchDaemonResolution,
@@ -35,27 +35,22 @@ function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEn
matchMode: "suffix";
limit: number;
}> = [];
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
async (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => {
searches.push({
query: input.query,
cwd: input.cwd,
matchMode: input.matchMode,
limit: input.limit,
});
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
},
);
const getDirectorySuggestions: GetDirectorySuggestions = async (input) => {
searches.push({
query: input.query,
cwd: input.cwd,
matchMode: input.matchMode,
limit: input.limit,
});
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
};
return { getDirectorySuggestions, searches };
}
const unavailableSuggestions: GetDirectorySuggestions = async () => {
throw new Error("daemon unavailable");
};
describe("classifyForResolution", () => {
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
@@ -119,6 +114,40 @@ describe("classifyForResolution", () => {
});
});
it("keeps absolute paths outside the workspace as direct file targets", () => {
const result = classifyForResolution({ href: "/tmp/outside.txt" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "/tmp/outside.txt",
path: "/tmp/outside.txt",
lineStart: undefined,
lineEnd: undefined,
},
},
});
});
it("keeps tilde paths as direct file targets", () => {
const result = classifyForResolution({ href: "~/.paseo/plans/file-preview.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "~/.paseo/plans/file-preview.md",
path: "~/.paseo/plans/file-preview.md",
lineStart: undefined,
lineEnd: undefined,
},
},
});
});
it("keeps auto-linkified normal domains external", () => {
const result = classifyForResolution(
{ href: "http://google.com", text: "google.com", markup: "linkify" },
@@ -193,10 +222,6 @@ describe("fetchDaemonResolution", () => {
});
it("throws a typed unresolved error when the daemon throws", async () => {
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(async () => {
throw new Error("daemon unavailable");
});
await expect(
fetchDaemonResolution({
ambiguousQuery: "dumm.md",
@@ -208,7 +233,7 @@ describe("fetchDaemonResolution", () => {
lineEnd: undefined,
},
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
getDirectorySuggestions: unavailableSuggestions,
}),
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
});

View File

@@ -23,7 +23,6 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -66,6 +65,10 @@ import {
// TODO: this should be configured per provider in the provider manifest
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
const DESKTOP_MODEL_ROW_HEIGHT = 40;
type SelectorView =
| { kind: "all" }
@@ -87,6 +90,8 @@ interface CombinedModelSelectorProps {
}) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
}
@@ -101,7 +106,8 @@ interface SelectorContentProps {
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
serverId: string | null;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider: boolean;
}
function normalizeSearchQuery(value: string): string {
@@ -418,26 +424,29 @@ function ProviderModelRows({
}
function ProviderErrorEmptyState({
serverId,
providerId,
message,
onRetryProvider,
isRetryingProvider,
}: {
serverId: string | null;
providerId: string;
message: string;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider: boolean;
}) {
const { theme } = useUnistyles();
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
const handleRetry = useCallback(() => {
void refresh([providerId]);
}, [refresh, providerId]);
onRetryProvider?.(providerId);
}, [onRetryProvider, providerId]);
return (
<View style={styles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>{message}</Text>
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
{isRefreshing ? "Retrying…" : "Retry"}
</Button>
{onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
{isRetryingProvider ? "Retrying…" : "Retry"}
</Button>
) : null}
</View>
);
}
@@ -452,7 +461,8 @@ function SelectorContent({
onSelect,
onToggleFavorite,
onDrillDown,
serverId,
onRetryProvider,
isRetryingProvider,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
@@ -502,9 +512,10 @@ function SelectorContent({
if (drillSelection.kind === "error") {
return (
<ProviderErrorEmptyState
serverId={serverId}
providerId={view.providerId}
message={drillSelection.message}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
/>
);
}
@@ -556,6 +567,8 @@ export function CombinedModelSelector({
renderTrigger,
onOpen,
onClose,
onRetryProvider,
isRetryingProvider = false,
disabled = false,
serverId = null,
}: CombinedModelSelectorProps) {
@@ -630,8 +643,17 @@ export function CombinedModelSelector({
return undefined;
}
const provider = providers.find((entry) => entry.id === view.providerId);
const modelCount = provider ? getProviderModelRows(provider).length : 0;
return Math.min(80 + modelCount * 40, 400);
if (!provider || provider.modelSelection.kind !== "models") {
return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT;
}
const modelCount = getProviderModelRows(provider).length;
return Math.min(
Math.max(
DESKTOP_PROVIDER_VIEW_MIN_HEIGHT,
DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT,
),
DESKTOP_PROVIDER_VIEW_MAX_HEIGHT,
);
}, [providers, view]);
const triggerLabel = useMemo(() => {
@@ -804,7 +826,8 @@ export function CombinedModelSelector({
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onDrillDown={handleDrillDown}
serverId={serverId}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
/>
) : (
<View style={styles.sheetLoadingState}>

View File

@@ -4,6 +4,8 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { diffLinePrefix } from "@/utils/diff-highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
@@ -40,6 +42,26 @@ function DiffLineRow({ line }: { line: DiffLine }) {
[line.type],
);
const prefixStyle = React.useMemo(
() => [
line.type === "add" && styles.addText,
line.type === "remove" && styles.removeText,
line.type === "context" && styles.contextText,
],
[line.type],
);
if (line.tokens) {
return (
<View style={lineContainerStyle}>
<Text style={styles.lineText}>
<Text style={prefixStyle}>{diffLinePrefix(line)}</Text>
<DiffTokens tokens={line.tokens} />
</Text>
</View>
);
}
return (
<View style={lineContainerStyle}>
{line.segments ? (
@@ -62,6 +84,22 @@ function DiffLineRow({ line }: { line: DiffLine }) {
);
}
function DiffTokens({ tokens }: { tokens: NonNullable<DiffLine["tokens"]> }) {
const keyed = React.useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<>
{keyed.map(({ key, token }) => (
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</Text>
))}
</>
);
}
function DiffSegment({
segment,
lineType,

View File

@@ -27,6 +27,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
interface CodeLineProps {
@@ -398,16 +399,26 @@ export function FilePane({
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
() =>
normalizedFilePath
? resolveFilePreviewReadTarget({
path: normalizedFilePath,
workspaceRoot: normalizedWorkspaceRoot,
})
: null,
[normalizedFilePath, normalizedWorkspaceRoot],
);
const query = useQuery({
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
enabled: Boolean(client && readTarget),
queryFn: async () => {
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
if (!client || !readTarget) {
return { file: null as ExplorerFile | null, error: "Host is not connected" };
}
try {
const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath);
const file = await client.readFile(readTarget.cwd, readTarget.path);
const preview = await createFilePanePreview(file);
return {
file: preview.file,

View File

@@ -4,10 +4,11 @@ import { StyleSheet } from "react-native-unistyles";
import { MarkdownTextSpan } from "@/components/markdown-text";
import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import type { HighlightToken } from "@getpaseo/highlight";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
interface HighlightedCodeBlockProps {
code: string;
@@ -44,34 +45,6 @@ function stripTerminalFenceNewline(code: string): string {
return code.endsWith("\n") ? code.slice(0, -1) : code;
}
// Cross-instance cache for tokenized code blocks. Tokenization is
// theme-independent (colors are applied at render time), so the key is just
// (language, code). Bounded by entry count — 200 is generous for a chat
// transcript, code blocks rarely repeat beyond a handful of distinct shapes.
class LRUCache<K, V> {
private readonly map = new Map<K, V>();
constructor(private readonly max: number) {}
get(key: K): V | undefined {
const value = this.map.get(key);
if (value === undefined) return undefined;
this.map.delete(key);
this.map.set(key, value);
return value;
}
set(key: K, value: V): void {
if (this.map.has(key)) this.map.delete(key);
else if (this.map.size >= this.max) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
this.map.set(key, value);
}
}
const tokenizationCache = new LRUCache<string, KeyedLine[]>(200);
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
code,
language,
@@ -87,22 +60,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
);
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
const keyedLines = useMemo<KeyedLine[] | null>(() => {
const ext = fenceLanguageToExtension(language);
if (!ext) return null;
const cacheKey = `${ext}:${renderedCode}`;
const cached = tokenizationCache.get(cacheKey);
if (cached) return cached;
let tokenizedLines: HighlightToken[][];
try {
tokenizedLines = highlightCode(renderedCode, `x.${ext}`);
} catch {
return null;
}
const result = tokenizedLines.map(toKeyedLine);
tokenizationCache.set(cacheKey, result);
return result;
}, [renderedCode, language]);
const keyedLines = useMemo<KeyedLine[] | null>(
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
[renderedCode, language],
);
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
@@ -127,26 +88,6 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
);
});
interface KeyedToken {
key: string;
token: HighlightToken;
}
interface KeyedLine {
key: string;
tokens: KeyedToken[];
}
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
return {
key: `line-${lineIndex}`,
tokens: tokens.map((token, tokenIndex) => ({
key: `${lineIndex}-${tokenIndex}`,
token,
})),
};
}
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
const segments: React.ReactNode[] = [];
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {

View File

@@ -0,0 +1,104 @@
import React from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { isWeb } from "@/constants/platform";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache";
// Line height kept in sync with the plain `scrollText` style in
// tool-call-details so a highlighted block lines up with an unhighlighted
// fallback and blank lines keep their height.
const CODE_LINE_HEIGHT = 18;
const ZERO_WIDTH = "";
interface HighlightedLinesProps {
lines: KeyedLine[];
// 1-based line number of the first line; when set, a line-number gutter is
// rendered (used by Read, which carries a server-normalized offset).
startLine?: number;
}
function ContentLine({ line }: { line: KeyedLine }) {
return (
<Text selectable style={styles.lineText}>
{line.tokens.length === 0
? ZERO_WIDTH
: line.tokens.map(({ key, token }: KeyedToken) => (
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</Text>
))}
</Text>
);
}
const GutteredLine = React.memo(function GutteredLine({
line,
lineNumber,
digits,
}: {
line: KeyedLine;
lineNumber: number;
digits: number;
}) {
return (
<View style={styles.row}>
<Text style={styles.gutterText}>{String(lineNumber).padStart(digits)} </Text>
<ContentLine line={line} />
</View>
);
});
// Renders pre-tokenized lines (from the shared highlight cache), optionally with
// a line-number gutter. Callers decide whether to highlight at all, so the
// expensive size-cap / unsupported-language fallback stays a single plain Text.
export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
if (startLine === undefined) {
return (
<View>
{lines.map((line) => (
<ContentLine key={line.key} line={line} />
))}
</View>
);
}
const lastLineNumber = startLine + lines.length - 1;
const digits = Math.max(2, String(lastLineNumber).length);
return (
<View>
{lines.map((line, index) => (
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
minHeight: CODE_LINE_HEIGHT,
},
gutterText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
lineHeight: CODE_LINE_HEIGHT,
color: theme.colors.foregroundMuted,
opacity: 0.6,
userSelect: "none",
flexShrink: 0,
},
lineText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: CODE_LINE_HEIGHT,
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
}));

View File

@@ -0,0 +1,45 @@
import { useMemo } from "react";
import {
Image,
type ImageStyle,
type StyleProp,
Text,
type TextStyle,
View,
type ViewStyle,
} from "react-native";
import { deriveProjectIconColor } from "@/utils/project-icon-color";
const WHITE_TEXT = { color: "#ffffff" } as const;
export function ProjectIconView({
iconDataUri,
initial,
projectKey,
imageStyle,
fallbackStyle,
textStyle,
}: {
iconDataUri: string | null;
initial: string;
projectKey: string;
imageStyle: StyleProp<ImageStyle>;
fallbackStyle: StyleProp<ViewStyle>;
textStyle: StyleProp<TextStyle>;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
const fallbackStyles = useMemo(
() => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }],
[fallbackStyle, projectKey],
);
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
if (iconDataUri) {
return <Image source={imageSource} style={imageStyle} />;
}
return (
<View style={fallbackStyles}>
<Text style={textStyles}>{initial}</Text>
</View>
);
}

View File

@@ -2,7 +2,6 @@ import {
View,
Text,
Pressable,
Image,
Platform,
ActivityIndicator,
StatusBar,
@@ -14,6 +13,7 @@ import {
import * as Haptics from "expo-haptics";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { ProjectIconView } from "@/components/project-icon-view";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import {
@@ -57,7 +57,7 @@ import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
import {
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
@@ -119,13 +119,6 @@ import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
return null;
}
return `data:${icon.mimeType};base64,${icon.data}`;
}
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
@@ -471,6 +464,7 @@ function ProjectLeadingVisual({
displayName,
iconDataUri,
workspace,
projectKey,
chevron = null,
showChevron = false,
isArchiving = false,
@@ -478,6 +472,7 @@ function ProjectLeadingVisual({
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry | null;
projectKey: string;
chevron?: "expand" | "collapse" | null;
showChevron?: boolean;
isArchiving?: boolean;
@@ -502,7 +497,11 @@ function ProjectLeadingVisual({
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
/>
</View>
);
}
@@ -511,6 +510,7 @@ function ProjectLeadingVisual({
<ProjectLeadingVisualStatus
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
@@ -789,30 +789,35 @@ function WorkspaceKebabMenu({
function ProjectIcon({
iconDataUri,
placeholderInitial,
projectKey,
}: {
iconDataUri: string | null;
placeholderInitial: string;
projectKey: string;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
if (iconDataUri) {
return <Image source={imageSource} style={styles.projectIcon} />;
}
return (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
/>
);
}
function ProjectLeadingVisualStatus({
iconDataUri,
placeholderInitial,
projectKey,
isArchiving,
shouldShowSyncedLoader,
activeWorkspace,
}: {
iconDataUri: string | null;
placeholderInitial: string;
projectKey: string;
isArchiving: boolean;
shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry;
@@ -852,7 +857,11 @@ function ProjectLeadingVisualStatus({
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
/>
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
@@ -1244,6 +1253,7 @@ function ProjectHeaderRow({
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
projectKey={project.projectKey}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -2350,7 +2360,7 @@ export function SidebarWorkspaceList({
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
select: toProjectIconDataUri,
select: projectIconToDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
@@ -2713,13 +2723,10 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: 9,
},
projectTitle: {

View File

@@ -11,9 +11,12 @@ import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
import { HighlightedLines } from "./highlighted-content";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -143,10 +146,10 @@ function useDetailStyles(
function useDiffLines(detail: ToolCallDetail | undefined): DiffLine[] | undefined {
return useMemo(() => {
if (!detail || detail.type !== "edit") return undefined;
if (detail.unifiedDiff) {
return parseUnifiedDiff(detail.unifiedDiff);
}
return buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
const diffLines = detail.unifiedDiff
? parseUnifiedDiff(detail.unifiedDiff)
: buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
return highlightDiffLines(diffLines, detail.filePath);
}, [detail]);
}
@@ -431,9 +434,22 @@ interface ScrollableContentProps {
content: string;
ds: DetailStyles;
wrapInSectionFill?: boolean;
// Drives syntax highlighting (extension only) and, with startLine, a gutter.
filePath?: string | null;
startLine?: number;
}
function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: ScrollableContentProps) {
function ScrollableTextSection({
content,
ds,
wrapInSectionFill = true,
filePath,
startLine,
}: ScrollableContentProps) {
const keyedLines = useMemo(
() => (filePath ? highlightToKeyedLines(content, extensionFromPath(filePath)) : null),
[content, filePath],
);
const body = (
<ScrollView
style={ds.scrollAreaFillStyle}
@@ -447,9 +463,13 @@ function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: Scroll
showsHorizontalScrollIndicator={true}
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{content}
</Text>
{keyedLines ? (
<HighlightedLines lines={keyedLines} startLine={startLine} />
) : (
<Text selectable style={styles.scrollText}>
{content}
</Text>
)}
</ScrollView>
</ScrollView>
);
@@ -669,14 +689,27 @@ function buildDetailSections(
return [
<View key="write" style={ds.sectionFillStyle}>
{detail.content ? (
<ScrollableTextSection content={detail.content} ds={ds} wrapInSectionFill={false} />
<ScrollableTextSection
content={detail.content}
ds={ds}
wrapInSectionFill={false}
filePath={detail.filePath}
/>
) : null}
</View>,
];
}
if (detail.type === "read") {
if (!detail.content) return [];
return [<ScrollableTextSection key="read" content={detail.content} ds={ds} />];
return [
<ScrollableTextSection
key="read"
content={detail.content}
ds={ds}
filePath={detail.filePath}
startLine={detail.offset ?? 1}
/>,
];
}
if (detail.type === "search") {
return buildSearchSections(detail, ds);

View File

@@ -118,4 +118,44 @@ describe("bottom sheet visibility tracker", () => {
tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(2);
});
it("does not re-present when the controller reattaches before parent state acknowledges a user dismiss", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(sheet.events).toEqual([{ type: "present" }]);
});
it("does not re-present when dismiss fires before parent state acknowledges a user dismiss", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetDismiss();
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(sheet.events).toEqual([{ type: "present" }]);
});
it("allows a fresh open after parent state acknowledges a dismissed sheet", () => {
const { sheet, tracker } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.attachController(null);
tracker.attachController(sheet);
tracker.syncDesired({ visible: false });
tracker.syncDesired({ visible: true });
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
});
});

View File

@@ -15,25 +15,27 @@ export interface BottomSheetVisibilityTracker {
handleSheetDismiss(): void;
}
type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
export function createBottomSheetVisibilityTracker(opts: {
onClose: () => void;
}): BottomSheetVisibilityTracker {
let controller: BottomSheetController | null = null;
let visible = false;
let isEnabled: boolean | undefined;
let isPresented = false;
let phase: BottomSheetPhase = "closed";
let hasNotifiedClose = false;
function present(): void {
if (!controller || isPresented) return;
isPresented = true;
if (!controller || phase !== "closed") return;
phase = "presenting";
hasNotifiedClose = false;
controller.present();
}
function dismiss(): void {
if (!controller || !isPresented) return;
isPresented = false;
if (!controller || phase === "closed" || phase === "dismissing") return;
phase = "dismissing";
controller.dismiss();
}
@@ -58,19 +60,34 @@ export function createBottomSheetVisibilityTracker(opts: {
present();
return;
}
if (phase === "dismissing") {
phase = "closed";
hasNotifiedClose = false;
return;
}
dismiss();
},
handleSheetIndexChange(index) {
if (index === -1 && visible) {
if (index !== -1) {
if (phase === "presenting") {
phase = "presented";
}
return;
}
if (phase === "presenting" || phase === "presented") {
phase = "dismissing";
}
if (visible) {
notifyClose();
}
},
handleSheetDismiss() {
isPresented = false;
if (visible) {
phase = "dismissing";
notifyClose();
return;
}
phase = "closed";
hasNotifiedClose = false;
},
};

View File

@@ -24,6 +24,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import {
buildProviderSelectorProviders,
buildSelectableProviderSelectorProviders,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useSessionStore } from "@/stores/session-store";
@@ -91,6 +92,8 @@ interface ControlledAgentControlsProps {
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
isRetryingModelProvider?: boolean;
/** Extra elements rendered inline with the agent controls (desktop only). */
desktopExtras?: ReactNode;
modelSelectorServerId?: string | null;
@@ -117,6 +120,8 @@ export interface DraftAgentControlsProps {
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
isRetryingModelProvider?: boolean;
disabled?: boolean;
modelSelectorServerId?: string | null;
}
@@ -175,17 +180,6 @@ function getFeatureIconColor(
}
}
function resolveDisplayModel(
isModelLoading: boolean,
modelOptions: AgentControlOption[] | undefined,
selectedModelId: string | undefined,
) {
if (isModelLoading && (!modelOptions || modelOptions.length === 0)) {
return "Loading models...";
}
return findOptionLabel(modelOptions, selectedModelId, "Select model");
}
// Mobile agent controls only — strip namespace prefix so providers like OpenCode
// show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in
// the model picker.
@@ -411,6 +405,8 @@ function ControlledAgentControls({
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
isRetryingModelProvider = false,
desktopExtras,
modelSelectorServerId = null,
}: ControlledAgentControlsProps) {
@@ -432,7 +428,6 @@ function ControlledAgentControls({
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId);
const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions),
[thinkingOptions],
@@ -588,6 +583,8 @@ function ControlledAgentControls({
onToggleFavoriteModel={onToggleFavoriteModel}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider}
isRetryingModelProvider={isRetryingModelProvider}
favoriteKeys={favoriteKeys}
disabled={disabled}
isModelLoading={isModelLoading}
@@ -599,7 +596,6 @@ function ControlledAgentControls({
comboboxProviderOptions={comboboxProviderOptions}
comboboxThinkingOptions={comboboxThinkingOptions}
displayProvider={displayProvider}
displayModel={displayModel}
displayThinking={displayThinking}
openSelector={openSelector}
providerAnchorRef={providerAnchorRef}
@@ -628,6 +624,8 @@ function ControlledAgentControls({
onToggleFavoriteModel={onToggleFavoriteModel}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider}
isRetryingModelProvider={isRetryingModelProvider}
favoriteKeys={favoriteKeys}
disabled={disabled}
isModelLoading={isModelLoading}
@@ -665,6 +663,8 @@ interface DesktopAgentControlsContentProps {
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
isRetryingModelProvider: boolean;
favoriteKeys: Set<string>;
disabled: boolean;
isModelLoading: boolean;
@@ -676,7 +676,6 @@ interface DesktopAgentControlsContentProps {
comboboxProviderOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
displayProvider: string;
displayModel: string;
displayThinking: string;
openSelector: AgentControlSelector | null;
providerAnchorRef: RefObject<View | null>;
@@ -717,6 +716,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onToggleFavoriteModel,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
isRetryingModelProvider,
favoriteKeys,
disabled,
isModelLoading,
@@ -728,7 +729,6 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
comboboxProviderOptions,
comboboxThinkingOptions,
displayProvider,
displayModel,
displayThinking,
openSelector,
providerAnchorRef,
@@ -779,12 +779,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
) : null}
{canSelectModel ? (
<Tooltip
key={`model-${displayModel}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<View>
<CombinedModelSelector
@@ -798,6 +793,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
onRetryProvider={onRetryModelProvider}
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
/>
</View>
@@ -870,6 +867,8 @@ interface SheetAgentControlsContentProps {
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
isRetryingModelProvider: boolean;
favoriteKeys: Set<string>;
disabled: boolean;
isModelLoading: boolean;
@@ -906,6 +905,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
onToggleFavoriteModel,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
isRetryingModelProvider,
favoriteKeys,
disabled,
isModelLoading,
@@ -992,6 +993,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
onRetryProvider={onRetryModelProvider}
isRetryingProvider={isRetryingModelProvider}
renderTrigger={renderModelTrigger}
serverId={modelSelectorServerId}
/>
@@ -1353,6 +1356,8 @@ export const AgentControls = memo(function AgentControls({
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isRefreshing: snapshotIsRefreshing,
refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
@@ -1373,14 +1378,15 @@ export const AgentControls = memo(function AgentControls({
() => buildAgentProviderModels(agent?.provider, models),
[agent?.provider, models],
);
const agentModelSelectorProviders = useMemo(
() =>
buildProviderSelectorProviders({
providerDefinitions: agentProviderDefinitions,
modelsByProvider: agentProviderModels,
}),
[agentProviderDefinitions, agentProviderModels],
);
const agentModelSelectorProviders = useMemo(() => {
if (snapshotSelectedEntry) {
return buildSelectableProviderSelectorProviders([snapshotSelectedEntry]);
}
return buildProviderSelectorProviders({
providerDefinitions: agentProviderDefinitions,
modelsByProvider: agentProviderModels,
});
}, [agentProviderDefinitions, agentProviderModels, snapshotSelectedEntry]);
const modelSelection = resolveAgentModelSelection({
models,
@@ -1504,6 +1510,13 @@ export const AgentControls = memo(function AgentControls({
refetchSnapshotIfStale(agentProvider);
}, [agentProvider, refetchSnapshotIfStale]);
const handleRetryModelProvider = useCallback(
(provider: AgentProvider) => {
void refreshSnapshot([provider]);
},
[refreshSnapshot],
);
const modeChip = useMemo(
() => <AgentModeControl serverId={serverId} agentId={agentId} placement="toolbar" />,
[serverId, agentId],
@@ -1529,6 +1542,8 @@ export const AgentControls = memo(function AgentControls({
onSetFeature={handleSetFeature}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={handleModelSelectorOpen}
onRetryModelProvider={handleRetryModelProvider}
isRetryingModelProvider={snapshotIsRefreshing}
onDropdownClose={onDropdownClose}
disabled={!client}
desktopExtras={modeChip}
@@ -1558,6 +1573,8 @@ export function DraftAgentControls({
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
isRetryingModelProvider = false,
disabled = false,
modelSelectorServerId = null,
}: DraftAgentControlsProps) {
@@ -1627,6 +1644,8 @@ export function DraftAgentControls({
disabled={disabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
onRetryProvider={onRetryModelProvider}
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
/>
{selectedProvider ? (
@@ -1638,6 +1657,8 @@ export function DraftAgentControls({
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
onRetryModelProvider={onRetryModelProvider}
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
desktopExtras={draftModeChip}
/>
@@ -1663,6 +1684,8 @@ export function DraftAgentControls({
features={features}
onSetFeature={onSetFeature}
onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider}
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
modelSelectorServerId={modelSelectorServerId}
/>

View File

@@ -46,6 +46,8 @@ export function buildDraftAgentControls(input: {
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels,
isRetryingModelProvider: formState.isProviderModelsRefreshing,
modelSelectorServerId: formState.selectedServerId,
};
}

View File

@@ -101,6 +101,7 @@ vi.mock("@/hooks/use-agent-form-state", () => ({
},
],
isAllModelsLoading: false,
isProviderModelsRefreshing: false,
availableThinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
@@ -584,9 +586,13 @@ export function WorkspaceDraftAgentTab({
[composerState],
);
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const inputAreaWrapperStyle = useMemo(
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
[insets.bottom],
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
[insets.bottom, composerKeyboardStyle],
);
const handleDropdownCloseFocus = useCallback(() => {
@@ -656,7 +662,7 @@ export function WorkspaceDraftAgentTab({
)}
</View>
<View style={inputAreaWrapperStyle}>
<ReanimatedAnimated.View style={inputAreaWrapperStyle}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
@@ -667,6 +673,7 @@ export function WorkspaceDraftAgentTab({
<Composer
agentId={tabId}
serverId={serverId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
@@ -686,7 +693,7 @@ export function WorkspaceDraftAgentTab({
agentControls={composerAgentControls}
footer={composerFooter}
/>
</View>
</ReanimatedAnimated.View>
</View>
</FileDropZone>
);

View File

@@ -665,6 +665,8 @@ interface ComposerProps {
inputWrapperStyle?: import("react-native").ViewStyle;
/** Rendered below the input, inside the keyboard-shifted container. */
footer?: ReactNode;
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
externalKeyboardShift?: boolean;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -859,6 +861,7 @@ export function Composer({
agentControls,
inputWrapperStyle,
footer,
externalKeyboardShift,
}: ComposerProps) {
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
@@ -1307,6 +1310,7 @@ export function Composer({
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate",
enabled: !externalKeyboardShift,
});
const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId);
@@ -1749,8 +1753,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
footer: {
width: "100%",
paddingHorizontal: theme.spacing[4],
// Negative margin pulls the footer up against the input area's paddingBottom.
// On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more.
marginTop: {
xs: -theme.spacing[4],
xs: -(theme.spacing[4] - 3),
md: -theme.spacing[3],
},
alignItems: "center",
@@ -1761,12 +1767,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
// On mobile, the negative margins below cancel each glyph's internal padding
// to reach the composer border; this inset adds a small visual gap from it.
paddingLeft: {
xs: 6,
xs: 5,
md: 10,
},
paddingRight: {
xs: 6,
xs: 5,
md: 10,
},
},
@@ -1774,11 +1782,23 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
// so its icon aligns to the composer border before the footer inset is applied.
marginLeft: {
xs: -theme.spacing[2],
md: 0,
},
},
footerRight: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
// On mobile, cancel the trailing glyph's internal inset (28px box around a 16px
// ring) so its right edge aligns to the composer border before the footer inset.
marginRight: {
xs: -6,
md: 0,
},
},
messageInputContainer: {
position: "relative",

View File

@@ -14,7 +14,12 @@ import {
type TimelineReducerSideEffect,
} from "@/timeline/session-stream-reducers";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import {
isTimelineCatchUpComplete,
planResumeTimelineSync,
planTimelineCatchUpAfter,
planTimelineCatchUpFollowUp,
} from "@/timeline/timeline-sync-plan";
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
@@ -500,6 +505,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const revalidationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const revalidationInFlightRef = useRef<Promise<void> | null>(null);
const revalidationQueuedRef = useRef(false);
const timelineCatchUpInFlightRef = useRef<Set<string>>(new Set());
const wasConnectedRef = useRef(isConnected);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
@@ -746,6 +752,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS);
}, [client, flushAuthoritativeRevalidation, isConnected]);
const requestCanonicalCatchUp = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
const request = planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq });
const key = `${agentId}:${request.cursor.epoch}:${request.cursor.seq}`;
const inFlight = timelineCatchUpInFlightRef.current;
if (inFlight.has(key)) {
return;
}
inFlight.add(key);
void client
.fetchAgentTimeline(agentId, request)
.catch((error) => {
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
})
.finally(() => {
inFlight.delete(key);
});
},
[client],
);
const handleAppResumed = useCallback(
(awayMs: number) => {
scheduleAuthoritativeRevalidation();
@@ -754,15 +781,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId;
if (agentId) {
void client
.fetchAgentTimeline(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
})
.catch((error) => {
const plan = planResumeTimelineSync({
cursor: session?.agentTimelineCursor.get(agentId),
});
if (plan.direction === "after") {
requestCanonicalCatchUp(agentId, {
epoch: plan.cursor.epoch,
endSeq: plan.cursor.seq,
});
} else {
void client.fetchAgentTimeline(agentId, plan).catch((error) => {
console.warn("[Session] failed to fetch tail timeline on resume", agentId, error);
});
}
}
}
@@ -771,7 +802,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
bumpHistorySyncGeneration(serverId);
},
[bumpHistorySyncGeneration, client, scheduleAuthoritativeRevalidation, serverId],
[
bumpHistorySyncGeneration,
client,
requestCanonicalCatchUp,
scheduleAuthoritativeRevalidation,
serverId,
],
);
// Client activity tracking (heartbeat, push token registration)
@@ -1018,22 +1055,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[serverId, upsertWorkspaceSetupProgress],
);
const requestCanonicalCatchUp = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
});
},
[client],
);
const applyTimelineResponse = useCallback(
(
payload: Extract<
@@ -1043,8 +1064,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
) => {
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const catchUpComplete = isTimelineCatchUpComplete({
direction: payload.direction,
hasNewer: payload.hasNewer,
error: payload.error,
});
const shouldMarkAuthoritativeHistoryApplied =
payload.direction === "tail" || payload.direction === "after";
payload.direction === "tail" || (payload.direction === "after" && catchUpComplete);
// Read current store state
const session = useSessionStore.getState().sessions[serverId];
@@ -1114,6 +1140,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyAgentUpdatePayload,
});
const followUp = planTimelineCatchUpFollowUp({
direction: payload.direction,
hasNewer: payload.hasNewer,
endCursor: payload.endCursor,
error: payload.error,
});
if (followUp?.direction === "after") {
requestCanonicalCatchUp(agentId, {
epoch: followUp.cursor.epoch,
endSeq: followUp.cursor.seq,
});
}
finalizeTimelineApplication({
result,
agentId,

View File

@@ -231,6 +231,16 @@ const CATALOG_DATA = [
installLink: "https://block.github.io/goose/",
command: ["goose", "acp"],
},
{
id: "grok",
title: "Grok",
description:
"xAI's Grok Build agentic coding CLI with plan mode and parallel subagents. Requires a SuperGrok or X Premium+ subscription.",
version: "0.2.11",
iconId: "grok",
installLink: "https://docs.x.ai/build/overview",
command: ["grok", "agent", "stdio"],
},
{
id: "hermes",
title: "Hermes",

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { resolveFilePreviewReadTarget } from "./preview-target";
describe("resolveFilePreviewReadTarget", () => {
it("uses the workspace cwd for relative paths", () => {
expect(
resolveFilePreviewReadTarget({
path: "packages/app/src/message.tsx",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
cwd: "/Users/test/project",
path: "packages/app/src/message.tsx",
});
});
it("uses the workspace cwd for absolute paths inside the workspace", () => {
expect(
resolveFilePreviewReadTarget({
path: "/Users/test/project/packages/app/src/message.tsx",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
cwd: "/Users/test/project",
path: "/Users/test/project/packages/app/src/message.tsx",
});
});
it("uses the filesystem root for absolute paths outside the workspace", () => {
expect(
resolveFilePreviewReadTarget({
path: "/tmp/paseo-preview.txt",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
cwd: "/",
path: "/tmp/paseo-preview.txt",
});
});
it("uses the home root for tilde paths", () => {
expect(
resolveFilePreviewReadTarget({
path: "~/.paseo/plans/file-preview.md",
workspaceRoot: "/Users/test/project",
}),
).toEqual({
cwd: "~",
path: "~/.paseo/plans/file-preview.md",
});
});
it("uses the drive root for Windows absolute paths outside the workspace", () => {
expect(
resolveFilePreviewReadTarget({
path: "C:/Users/test/Desktop/file.txt",
workspaceRoot: "D:/repo",
}),
).toEqual({
cwd: "C:/",
path: "C:/Users/test/Desktop/file.txt",
});
});
it("rejects relative paths without an absolute workspace root", () => {
expect(resolveFilePreviewReadTarget({ path: "src/app.ts" })).toBeNull();
expect(
resolveFilePreviewReadTarget({
path: "src/app.ts",
workspaceRoot: "relative/root",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,107 @@
import { isAbsolutePath } from "@/utils/path";
export interface FilePreviewReadTarget {
cwd: string;
path: string;
}
function trimTrailingSeparators(value: string): string {
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
return value.replace(/\\/g, "/");
}
return value.replace(/[\\/]+$/, "");
}
function normalizeForPathComparison(value: string): string {
const normalized = trimTrailingSeparators(value.replace(/\\/g, "/"));
if (/^[A-Za-z]:\//.test(normalized)) {
return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`;
}
return normalized;
}
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
const candidate = normalizeForPathComparison(candidatePath);
const root = normalizeForPathComparison(rootPath);
if (!candidate || !root) {
return false;
}
if (root === "/") {
return candidate.startsWith("/");
}
if (candidate === root) {
return true;
}
return candidate.startsWith(`${root}/`);
}
function deriveFilesystemRootFromAbsolutePath(value: string): string | null {
if (value.startsWith("/")) {
return "/";
}
const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value);
if (driveMatch?.[1]) {
return `${driveMatch[1]}/`;
}
const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value);
if (uncMatch?.[1]) {
return uncMatch[1];
}
return null;
}
function isHomeRelativePath(value: string): boolean {
return value === "~" || value.startsWith("~/") || value.startsWith("~\\");
}
export function resolveFilePreviewReadTarget(input: {
path: string;
workspaceRoot?: string;
}): FilePreviewReadTarget | null {
const previewPath = input.path.trim();
if (!previewPath) {
return null;
}
if (isHomeRelativePath(previewPath)) {
return {
cwd: "~",
path: previewPath,
};
}
const workspaceRoot = input.workspaceRoot?.trim();
if (!isAbsolutePath(previewPath)) {
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
return null;
}
return {
cwd: workspaceRoot,
path: previewPath,
};
}
if (
workspaceRoot &&
isAbsolutePath(workspaceRoot) &&
isPathWithinRoot(previewPath, workspaceRoot)
) {
return {
cwd: workspaceRoot,
path: previewPath,
};
}
const filesystemRoot = deriveFilesystemRootFromAbsolutePath(previewPath);
if (!filesystemRoot) {
return null;
}
return {
cwd: filesystemRoot,
path: previewPath,
};
}

View File

@@ -170,6 +170,46 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("refreshes git and GitHub state and reports success", async () => {
const client = {
checkoutRefresh: vi.fn(async () => ({ success: true, error: null })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd });
expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
).toBe("success");
});
it("surfaces a refresh error and returns to idle", async () => {
const client = {
checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow(
"not a git repository",
);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
).toBe("idle");
});
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({

View File

@@ -28,6 +28,7 @@ export type CheckoutGitAsyncActionId =
| "pull"
| "push"
| "pull-and-push"
| "refresh"
| "create-pr"
| "merge-pr-squash"
| "merge-pr-merge"
@@ -236,6 +237,7 @@ interface CheckoutGitActionsStoreState {
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
refresh: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergePr: (params: {
serverId: string;
@@ -360,6 +362,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
refresh: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "refresh",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutRefresh(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
pullAndPush: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,

View File

@@ -25,7 +25,8 @@ import {
type ViewStyle,
type TextStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
@@ -41,6 +42,7 @@ import {
ListChevronsUpDown,
Pilcrow,
RefreshCcw,
RotateCw,
Upload,
WrapText,
} from "lucide-react-native";
@@ -78,6 +80,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
@@ -1222,6 +1228,44 @@ function DiffFilesToolbar({
);
}
interface DiffRefreshButtonProps {
isRefreshing: boolean;
toggleStyle: PressableStyleFn;
onPress: () => void;
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Refresh</Text>
</TooltipContent>
</Tooltip>
);
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
@@ -1559,6 +1603,29 @@ export function GitDiffPane({
[controlSurfaceColor],
);
const refreshToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const toast = useToast();
const refreshSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
const isRefreshing =
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
"pending";
const handleRefresh = useCallback(() => {
if (isRefreshing) {
return;
}
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to refresh git state.");
});
}, [cwd, isRefreshing, runRefresh, serverId, toast]);
const {
status,
isLoading: isStatusLoading,
@@ -2088,6 +2155,13 @@ export function GitDiffPane({
onToggleExpandAll={handleToggleExpandAll}
/>
) : null}
{refreshSupported ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
</View>
</View>
</View>
@@ -2216,6 +2290,12 @@ const styles = StyleSheet.create((theme) => ({
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",

View File

@@ -318,13 +318,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
const providerCommands = commands.map(
(command): AvailableCommand => ({ source: "provider", command }),
);
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
const availableCommands: AvailableCommand[] = isDraftContext
? providerCommands
: [
...CLIENT_SLASH_COMMANDS.map(
(command): AvailableCommand => ({ source: "client", command }),
),
...providerCommands,
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
];
const matches = filterAndRankCommandAutocompleteEntries(
availableCommands,

View File

@@ -69,10 +69,11 @@ export interface UseAgentFormStateResult {
allProviderModels: Map<string, AgentModelDefinition[]>;
modelSelectorProviders: ProviderSelectorProvider[];
isAllModelsLoading: boolean;
isProviderModelsRefreshing: boolean;
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
isModelLoading: boolean;
modelError: string | null;
refreshProviderModels: () => void;
refreshProviderModels: (provider?: AgentProvider) => void;
refetchProviderModelsIfStale: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
workingDirIsEmpty: boolean;
@@ -208,6 +209,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isRefreshing: snapshotIsRefreshing,
error: snapshotError,
refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale,
@@ -475,9 +477,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
dispatch({ type: "SET_SERVER_ID", value });
}, []);
const refreshProviderModels = useCallback(() => {
void refreshSnapshot();
}, [refreshSnapshot]);
const refreshProviderModels = useCallback(
(provider?: AgentProvider) => {
void refreshSnapshot(provider ? [provider] : undefined);
},
[refreshSnapshot],
);
const refetchProviderModelsIfStale = useCallback(() => {
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
@@ -535,6 +540,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
allProviderModels,
modelSelectorProviders,
isAllModelsLoading,
isProviderModelsRefreshing: snapshotIsRefreshing,
availableThinkingOptions,
isModelLoading,
modelError,
@@ -568,6 +574,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
allProviderModels,
modelSelectorProviders,
isAllModelsLoading,
snapshotIsRefreshing,
availableThinkingOptions,
isModelLoading,
modelError,

View File

@@ -8,7 +8,7 @@ import {
getInitKey,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
export const INIT_TIMEOUT_MS = 30_000;
@@ -32,19 +32,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest =
hasAuthoritativeHistory && cursor
? {
direction: "after" as const,
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical" as const,
}
: {
direction: "tail" as const,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical" as const,
};
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
const deferred = createInitDeferred(key, timelineRequest.direction);
const timeoutId = setTimeout(() => {
@@ -87,11 +75,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
try {
await client.refreshAgent(agentId);
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
await client.fetchAgentTimeline(agentId, planTimelineTailFetch());
} catch (error) {
setAgentInitializing(agentId, false);
throw error;

View File

@@ -1,7 +1,7 @@
import { useCallback } from "react";
import type { ToastApi } from "@/components/toast-host";
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
export interface LoadOlderAgentHistoryClient {
fetchAgentTimeline: (
@@ -40,12 +40,10 @@ export async function loadOlderAgentHistory(
setInFlight(true);
try {
await client.fetchAgentTimeline(agentId, {
direction: "before",
cursor: { epoch: cursor.epoch, seq: cursor.startSeq },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
await client.fetchAgentTimeline(
agentId,
planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
);
} catch (error) {
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
toast?.show("Couldn't load older history", {

View File

@@ -6,6 +6,13 @@ export function projectIconQueryKey(serverId: string, cwd: string) {
return ["projectIcon", serverId, cwd] as const;
}
export function projectIconToDataUri(icon: ProjectIcon | null): string | null {
if (!icon) {
return null;
}
return `data:${icon.mimeType};base64,${icon.data}`;
}
interface UseProjectIconQueryOptions {
serverId: string;
cwd: string;

View File

@@ -1392,9 +1392,13 @@ function ActiveAgentComposer({
],
);
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const inputAreaStyle = useMemo(
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
[insets.bottom],
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
[insets.bottom, composerKeyboardStyle],
);
const composerFooter = useMemo(
@@ -1406,7 +1410,7 @@ function ActiveAgentComposer({
);
return (
<View style={inputAreaStyle}>
<ReanimatedAnimated.View style={inputAreaStyle}>
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
@@ -1415,6 +1419,7 @@ function ActiveAgentComposer({
<Composer
agentId={agentId}
serverId={serverId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
@@ -1434,7 +1439,7 @@ function ActiveAgentComposer({
onClientSlashCommand={handleClientSlashCommand}
footer={composerFooter}
/>
</View>
</ReanimatedAnimated.View>
);
}

View File

@@ -259,6 +259,27 @@ describe("combined model selector data", () => {
).toBe("Default");
});
it("keeps provider snapshot errors visible in the selected trigger label", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "opencode",
label: "OpenCode",
status: "error",
error: "OpenCode app.agents timed out after 10s",
models: [],
}),
]);
expect(
resolveSelectedModelLabel({
providers,
selectedProvider: "opencode",
selectedModel: "",
isLoading: false,
}),
).toBe("Error");
});
it("returns observable submission readiness reasons", () => {
expect(
resolveSubmissionReadiness({

View File

@@ -162,6 +162,9 @@ export function resolveSelectedModelLabel(input: {
if (provider.modelSelection.kind === "loading") {
return "Loading...";
}
if (provider.modelSelection.kind === "error") {
return "Error";
}
if (provider.modelSelection.kind !== "models") {
return "Select model";
}

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