Compare commits

..

39 Commits

Author SHA1 Message Date
Mohamed Boudra
9e802e92cd refactor(app): extract composer-actions module with pure tests
Move composer cancel/queue/attachment/dispatch orchestration out of
composer.tsx into a React-free actions module. The module takes its
dependencies (send client, queue writer, stream writer, attachment
persister, image picker) as injected ports, so the new
composer-actions.test.ts can drive every action with inline fakes —
zero vi.mock of internal modules, zero JSDOM, zero React.

The old composer.test.tsx (heavy mocked component test) is removed
and replaced with composer-actions.test.ts (31 unit tests).

Also extract isWorkspaceAttachment / userAttachmentsOnly /
workspaceAttachmentToSubmitAttachment from composer-workspace-attachments.tsx
into a sibling .ts so the actions module (and composer-attachments.ts)
can use them without dragging React/RN/lucide into a pure test graph.
2026-05-04 22:25:59 +07:00
Mohamed Boudra
6f04d33b18 refactor(app): assert store state rather than mock call counts in store tests (#715)
Rewrites two store tests to verify observable state instead of implementation-level call counts.

navigation-active-workspace-store: the "persists" test now uses a functional in-memory AsyncStorage mock that retains written values across vi.resetModules(), then rehydrates a fresh store instance and asserts the resulting selection state matches what was written.

checkout-git-actions-store: removes toHaveBeenCalledWith/toHaveBeenCalledTimes assertions on client methods; replaces with getStatus() assertions that reflect the store's actual state after each action completes or fails.
2026-05-04 23:06:41 +08:00
Mohamed Boudra
1526532ff4 refactor(app): fix type-aware lint errors in UI components (#710) 2026-05-04 22:14:41 +08:00
Mohamed Boudra
dbff649d88 perf(cli): run CLI E2E tests in parallel (#708)
* perf(cli): run E2E tests in parallel via worker pool

The custom CLI test runner ran 35 tsx test files sequentially, making the
cli-tests CI job the longest in main CI (~17 minutes). Each test file
already isolates its own daemon (ephemeral port + tmp PASEO_HOME), so
parallelism was just gated by the runner.

Replace the sequential recursion with a fixed-size worker pool (default
concurrency=4 to match GitHub Actions standard runners; override via
PASEO_CLI_TEST_CONCURRENCY). Buffer per-test stdout/stderr and flush as
a contiguous block on completion so concurrent output stays readable,
and report per-test wall clock plus the five slowest tests.

Local wall clock drops from ~12-15 minutes serial to ~2:49 with
concurrency=4. The slowest single test (05-agent-run, 49s) is now the
floor; CI should land near 4-5 minutes.

* fix(cli-tests): deflake 30-chat under load and shard CI across 3 runners

`chat wait` reads the latest message id and then subscribes for newer
messages. Under CI load the subprocess takes >1s to bootstrap, so the
old test's 250ms head start before posting "second message" raced
against that read. When the post landed first, the subprocess saw the
second message as latest and timed out waiting for a newer one. Replace
the brittle delay with a post-and-race loop: every iteration posts a
fresh "second message" and races a 250ms tick against the wait promise,
so whichever message lands after the snapshot wakes wait deterministically.

CI was also slower than local (10.3min vs 2.8min). On 4-vCPU GHA
runners, 35 sequential-CPU-time of ~1850s caps wall clock around 8 min
even at concurrency=4. Shard the suite across 3 GHA runners via matrix
strategy. The runner now reads PASEO_CLI_TEST_SHARD/SHARD_TOTAL and
distributes files into buckets — known long-pole tests
(05/06/11/13/14-...) round-robin first so they spread across shards
instead of clustering by their numeric prefixes, then the remainder
fills in the reverse direction to balance light load.

Local run is unchanged (single shard by default). Expected per-shard
wall on CI: ~2:30 + setup ≈ ~4:30 total.
2026-05-04 22:14:35 +08:00
Mohamed Boudra
a6ad4ff430 refactor(agent-providers): fix type-aware lint errors in diagnostic-utils and generic-acp-agent (#705)
* refactor(agent-providers): add type guards and fix type-aware lint errors in diagnostic-utils, generic-acp-agent, and tool-call-detail-primitives

* fix(server): defer PTY onExit to allow pending data events to fire

On Linux, node-pty's onExit callback can arrive before the last buffered
PTY data chunk is delivered via onData. Wrapping finish() in setImmediate
gives libuv's I/O poll phase a chance to flush remaining PTY reads before
the promise resolves, preventing tail bytes from being silently dropped.

Fixes a flaky worktree-bootstrap test that reliably reproduced on Linux CI.

* unslop: remove dead guard, fix import order, trim comment

- generic-acp-agent: remove isNonEmptyStringArray and its guard; the
  constructor parameter is already typed [string, ...string[]] so the
  check can never fire
- provider-registry: move isNonEmptyStringArray below the import block
  instead of between two import groups
- worktree: condense 3-line comment to one line per project convention
2026-05-04 21:50:23 +08:00
Mohamed Boudra
165fc11955 refactor(app): fix type-aware lint errors in voice, tabs store, host-connection, audio recorder (#706)
Clear all type-aware lint errors in four app state/runtime files:

- voice-context.tsx: bind runtime methods before passing to useSyncExternalStore
  and object spreads to satisfy the unbound-method rule
- workspace-tabs-store.ts: replace unsafe `as` casts with isPlainRecord type
  predicate + toObjectRecord; add coerceWorkspaceTabTarget to bridge unknown
  storage data to WorkspaceTabTarget without assertions
- host-connection.ts: same isPlainRecord/toObjectRecord pattern; replace
  String(record.x ?? "") calls with typeof guards to fix no-base-to-string
- use-audio-recorder.native.ts: construct real Blob instead of casting an
  object literal; restructure useEffect for consistent-return; drop await on
  void recorder.record(); use instanceof Error for message extraction; remove
  redundant Boolean() wrapping
2026-05-04 21:50:19 +08:00
Mohamed Boudra
426b81e5d2 refactor(claude-agent): replace unsafe type assertions with type guards (#707)
Adds isObjectRecord, isUnknownArray, isChildProcessWithStreams, and
isImageMimeType type predicates to eliminate all no-unsafe-type-assertion
violations surfaced by typeAware lint mode. Fixes floating promise, adds
throw after exhaustive switch, and widens readCompactionMetadata to accept
unknown so callers need no casts.
2026-05-04 21:50:14 +08:00
Mohamed Boudra
2c90498cb8 refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors (#704)
* refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors in session.ts

* fix build: correct parseClientCapabilities type handling
2026-05-04 21:23:44 +08:00
Mohamed Boudra
c42d48a8ff refactor(agent-providers): add toObjectRecord helper and fix type-aware lint errors in codex and claude agents (#699) 2026-05-04 20:30:24 +08:00
Mohamed Boudra
54b1f524b7 refactor(speech): add ONNX type augmentation and fix type-aware lint errors (#701) 2026-05-04 20:29:37 +08:00
Mohamed Boudra
85db9711b3 fix(app): make e2e setup an auto fixture so first-of-spec tests get setup (#702)
* fix(app): make e2e setup an auto fixture so it runs for the first test of every spec

The fixtures.ts beforeEach was declared at the top level of a non-test
fixture file. Playwright sometimes skipped it for the first test of a
subsequent spec when multiple specs ran in the same worker — that test
hit page.evaluate without the seed nonce or daemon registry in
localStorage and failed (then passed on retry, masking the bug behind
retries: 1). Reproduced locally by running sidebar-workspace then
startup-loading: the first startup-loading test got no fixture setup
and threw "Expected e2e seed nonce".

Move the setup into an `auto: true` fixture, the canonical Playwright
pattern for running setup for every test in a workspace. The afterEach
console-attachment is folded into the same fixture's teardown.

* refactor(server): simplify nullish handling in workspace-git-metadata

Drop the explicit `null` arg in `deriveProjectSlug(input.cwd, null)` —
the second parameter already defaults to `null`. Inline the
`repoName && repoName.length > 0` check in `parseGitHubRepoNameFromRemote`
since `pop()` on a non-empty array returns a non-empty string here, and
`|| null` covers the empty-string case directly.
2026-05-04 20:10:26 +08:00
Mohamed Boudra
3c2a98b09b refactor(relay): fix type-aware lint errors in WebSocket and crypto handling (#698) 2026-05-04 19:39:33 +08:00
Mohamed Boudra
9e3ebd9665 schedule: add paseo schedule update to edit schedules in place (#694)
* schedule: add `paseo schedule update` to edit schedules in place

Editing a schedule today requires delete+recreate, losing run history
and the schedule id. This adds an additive RPC and CLI command that
patches name, prompt, cadence, new-agent target fields, max-runs, and
expires-in without touching runs or in-flight executions. nextRunAt is
recomputed only when the cadence actually changes.

* schedule(cli): share cadence flag parser between create and update

Both paths were turning --every/--cron into a ScheduleCadence with
near-identical code. Extract `parseCadenceFromFlags` so the literals
and the exclusivity check live in one place; create wraps it to
require a value, update lets it stay optional.
2026-05-04 19:39:13 +08:00
Mohamed Boudra
a1606ea515 fix(server): derive non-GitHub project display names from remote owner/repo (#697)
Reconciliation overwrote correctly-set project display names with the
directory name for non-GitHub remotes (e.g. gitlab.com/acme/app), because
buildWorkspaceGitMetadataFromSnapshot only handled GitHub URLs while the
registry-model layer used the more general deriveProjectGroupingKey path.
Reuse that path here so both layers agree on the owner/repo display name.
2026-05-04 19:38:58 +08:00
Mohamed Boudra
fa8fab75aa refactor(app): remove unnecessary String() and Boolean() conversions from type-aware lint fixes (#696) 2026-05-04 18:09:48 +08:00
Mohamed Boudra
a3f350a7dc refactor(server): remove unnecessary String() conversions from type-aware lint fixes (#695) 2026-05-04 18:09:43 +08:00
Mohamed Boudra
6a8f507d10 refactor(server): remove redundant null from unknown union types (#693) 2026-05-04 17:23:30 +08:00
Mohamed Boudra
ea0bd47c1d refactor(app): remove redundant type constituents from type definitions (#692) 2026-05-04 17:23:09 +08:00
Mohamed Boudra
f4dcb3954a refactor(server): replace JSON.parse type assertions with Zod validation (#691)
Replace unsafe 'JSON.parse(content) as Type' patterns with proper Zod schema
validation. This fixes type-aware lint errors and provides runtime safety.

- pid-lock.ts: Add pidLockInfoSchema and parsePidLockInfo helper
- package-version.ts: Add packageJsonSchema and parsePackageJson helper
- relay-transport.ts: Add isRecord type guard to avoid unsafe type assertion
2026-05-04 16:53:40 +08:00
Mathias Kurz
7fc4dd1e86 Feat/open projects config to any (#681)
* feat(server): derive owner/repo display name for any remote host

Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.

Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.

* feat(app): show projects from any git remote in Projects settings

Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.

Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.

* chore(app): simplify Projects empty state to "No projects yet"

Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.

* test(app): cover non-GitHub remote in projects-settings e2e

Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.

Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.

---------

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
2026-05-04 16:51:33 +08:00
Mohamed Boudra
78bb56d90d schedule: fire --every now by default, add run-once for cron-style triggers (#689)
Interval schedules used to wait the full interval before their first run, which
made `--every 1d` feel broken for fresh schedules. Now `--every` fires
immediately on creation; `--cron` keeps waiting for the next slot. `--no-run-now`
and `--run-now` override the defaults, and the parser rejects redundant combos.

Adds `paseo schedule run-once <id>` to manually trigger a single run without
advancing cadence or recomputing completion. The new `runOnCreate` field and
`schedule/run-once` RPC are additive on the WebSocket schema.
2026-05-04 16:37:33 +08:00
Mohamed Boudra
a788064e32 mcp(create_agent): validate mode and refuse silent cross-provider inheritance (#688)
The MCP create_agent tool inherited the parent agent's mode regardless of
provider, so a Claude parent in bypassPermissions spawning an opencode
child would feed an invalid mode to opencode and the new agent would die
in error state. The fix:

- Add an optional mode field to the agent-to-agent input schema.
- Validate any explicit mode against the target provider's available
  modes; throw with the list when invalid.
- Inherit the caller's mode only when both sides use the same provider;
  otherwise refuse with a helpful error listing the target's modes.
2026-05-04 16:33:19 +08:00
Mohamed Boudra
74f0b9da56 refactor(relay): remove unnecessary awaits from synchronous crypto functions (#687) 2026-05-04 16:33:14 +08:00
Mohamed Boudra
aa01f23ab3 feat(cli): paseo worktree create with MCP parity (#686)
Mirrors the MCP create_worktree tool's three modes (branch-off,
checkout-branch, checkout-pr) against the daemon's existing
createPaseoWorktree RPC, so CLI users (especially over --host) can
provision worktrees in the paseo-managed location.
2026-05-04 16:32:50 +08:00
Mohamed Boudra
ae3f1bb5df fix(cli): update schedule provider/mode error message substring in test
The error message gained a `--mode` mention when the schedule mode flag
landed; the existing CLI integration test still asserted on the old
exact-prefix substring and broke main CI for all PR builds.
2026-05-04 15:31:23 +07:00
Mohamed Boudra
2064bac918 cli(schedule): require --cwd when --host is set (#685)
process.cwd() is the local path and won't exist on a remote daemon, so
fail fast client-side instead of running the schedule in a wrong dir.
2026-05-04 16:26:55 +08:00
Mohamed Boudra
fa4294a0e1 voice: quieter thinking tone, log cleanup, and small ui polish
- Add scripts/lower-thinking-tone.mjs to scale the thinking-tone PCM
  amplitude in-place; apply it at 0.1 so the cue is a soft indicator
  instead of triggering the VAD via mic feedback.
- Drop the "Received first voice_audio_chunk" log that re-fires every
  summary window because the chunk counter is reset.
- Match Spoke message icon and label sizing to the standard tool-call
  badge.
- Append a spoken-input instruction so voice replies route through the
  speak tool.
2026-05-04 14:36:48 +07:00
Mohamed Boudra
c993858898 Merge branch 'trace-voice-interrupt-message-gap' 2026-05-04 13:21:55 +07:00
Mohamed Boudra
6e04153b95 Switch voice turn controller to streaming transcription 2026-05-04 13:17:11 +07:00
Mohamed Boudra
a6f0d79212 Merge branch 'investigate-opencode-error-swallowing' 2026-05-04 12:49:34 +07:00
Mohamed Boudra
4b2e0357aa fix(opencode): forward provider retries instead of swallowing them
OpenCode subproviders (e.g. opencode/kimi-k2.6 on OpenCode Zen) emit
session.status:retry events with messages like "Internal server error"
when the upstream provider returns 5xx. opencode itself retries
indefinitely with backoff and never emits a terminal event for these.

Previously the adapter only surfaced retry messages on a small allowlist
of "fatal" tokens (insufficient balance, invalid api key, etc.) and
silently dropped everything else. The agent appeared hung from the
user's perspective — no message, no spinner update, nothing — until the
upstream eventually recovered or the user manually interrupted.

Forward every session.status:retry as a non-terminal timeline error
item so the user can see what opencode is doing, mirroring opencode's
own TUI. Drop the fatal-token allowlist: classifying which retries are
"really" terminal is opencode's job, and synthesizing turn_failed for
ones we guess at is misleading anyway because opencode keeps spending
upstream while we tell the user the agent is done.

Also a small design pass on the timeline error rendering:
- drop the redundant "Agent error" prefix (the message is descriptive)
- drop the colored box background (visual weight too high for a retry)
- align the icon vertically to the first text line (height+center)
- make the message text selectable so users can copy errors
2026-05-04 12:48:29 +07:00
Mohamed Boudra
acf88f4aca Merge branch 'main' of github.com:getpaseo/paseo 2026-05-04 12:38:16 +07:00
Mohamed Boudra
412c66ce4b Add --mode to schedule and loop CLI, default background runs to unattended mode
Plumb --mode/--verify-mode through paseo schedule create and paseo loop run.
Mark each provider's unattended mode (claude bypassPermissions, codex/opencode
full-access, copilot autopilot) so loop and schedule services pick it by
default when no explicit modeId is given.
2026-05-04 12:37:54 +07:00
Mohamed Boudra
cb906d4223 Type CLI command host/json options at the source
The CommandOptions interface in packages/cli/src/output/with-output.ts had
an open `[key: string]: unknown` indexer, so every consumer of the global
--host and --json options had to cast `options.host as string | undefined`
at the call site. Add `host?: string` and `json?: boolean` to the interface
and remove 29 redundant casts across 12 command files.
2026-05-04 10:51:49 +07:00
github-actions[bot]
bf0d3b9786 fix: update lockfile signatures and Nix hash 2026-05-04 03:37:38 +00:00
Mohamed Boudra
7f37255f88 Remove unnecessary type assertions across codebase
Add oxlint-tsgolint and configure typescript/no-unnecessary-type-assertion
to flag redundant `!` and `as Foo` casts. Type-aware mode is left off by
default to keep `npm run lint` fast; the rule sits configured for when we
turn type-aware on intentionally. Auto-fix removed ~283 redundant casts;
two manual touch-ups: a real tsgolint false positive in split-container.tsx
and a stale ChildProcess import after a double-cast collapsed.
2026-05-04 10:21:52 +07:00
Mohamed Boudra
5c03e25232 Drop subagent task notifications from parent timeline
Subagent task_notification system messages arrive without
parent_tool_use_id but with tool_use_id pointing at the parent's Task
call, so they slip past the sidechain router and render as top-level
"Task Notification" rows in the parent's timeline. Skip them when the
referenced tool_use is a Task call; parent-level background bash
notifications still flow through.
2026-05-04 10:13:44 +07:00
Mohamed Boudra
b440f44111 Replace fictional fastlane action with Spaceship build-processing poll
The Fastfile called wait_for_build_processing_to_be_complete, which
isn't a built-in fastlane action and isn't published as a plugin
anywhere on rubygems. The submit_review lane has therefore failed
on every release that reached it (v0.1.65-beta.1, v0.1.66, v0.1.67).

Replace it with a direct Spaceship::ConnectAPI poll: fetch the build
matching the latest TestFlight build number, wait until its
processing_state is VALID, then hand off to deliver. 30-minute cap
with a clear failure message on INVALID/FAILED or timeout. Spaceship
ships with fastlane so no plugin or extra gem is required.

Lands in the next release; v0.1.67 needs a manual review submission
from App Store Connect.
2026-05-04 03:00:08 +07:00
github-actions[bot]
d433e1a362 fix: update lockfile signatures and Nix hash 2026-05-03 18:45:43 +00:00
723 changed files with 26305 additions and 44263 deletions

View File

@@ -1,121 +0,0 @@
name: Bug report
description: Something is broken or doesn't behave the way it should.
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Before opening, please:
- search existing issues for the same symptom
- try to reproduce on the latest version
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
- type: textarea
id: description
attributes:
label: What's broken
description: What happened, and what did you expect to happen instead?
placeholder: |
I tried to X, expected Y, got Z.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
placeholder: |
1. Open the desktop app
2. Pair a daemon
3. Click X
4. ...
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where did this happen
description: The surface you saw the bug on. Pick the closest match.
options:
- iOS app
- Android app
- Web (browser)
- Desktop (Electron)
- CLI
- Daemon
- Other
validations:
required: true
- type: input
id: paseo-version
attributes:
label: Paseo version
description: Settings → About in the app, or `paseo --version` from the CLI.
placeholder: "0.1.71"
validations:
required: true
- type: input
id: os-version
attributes:
label: OS version
description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04"
- type: dropdown
id: provider
attributes:
label: Agent provider
description: If the bug involves a specific agent provider, pick which one.
options:
- Not relevant
- Claude Code
- Codex
- OpenCode
- Custom provider
- type: textarea
id: provider-details
attributes:
label: Provider configuration
description: |
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
placeholder: |
Claude Code v1.2.3 with Anthropic API
Codex CLI v0.5.0 with OpenAI API
OpenCode v0.3.1
(or paste the relevant section of ~/.paseo/config.json for custom providers)
- type: textarea
id: logs
attributes:
label: Logs
description: |
Paste relevant log output. Strongly preferred for crashes and daemon issues.
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
- **Electron log (macOS):** `~/Library/Logs/Paseo/main.log`
- **Electron log (Windows):** `%APPDATA%\Paseo\logs\main.log`
- **Electron log (Linux):** `~/.config/Paseo/logs/main.log`
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
render: text
- type: textarea
id: screenshots
attributes:
label: Screenshots or video
description: |
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
- type: markdown
attributes:
value: |
---
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/jz8T2uahpH
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.

View File

@@ -1,43 +0,0 @@
name: Feature request
description: Propose a new feature or a change to existing behavior.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type: checkboxes
id: prior-search
attributes:
label: Prior search
options:
- label: I searched existing issues and discussions, and this isn't already proposed.
required: true
- type: textarea
id: problem
attributes:
label: What's the problem
description: What are you actually trying to do, and why is the current behavior in the way?
placeholder: |
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would solve it
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
description: Optional. What else did you try, and why doesn't it work?

View File

@@ -1,43 +0,0 @@
<!--
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
### Type of change
- [ ] Bug fix
- [ ] New feature (with prior issue + design alignment)
- [ ] Refactor / code improvement
- [ ] Docs
### What does this PR do
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
### How did you verify it
<!--
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
- For behavior changes: the actual steps you ran, and what you observed.
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
-->
### Checklist
- [ ] One focused change. Unrelated cleanups split out.
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
- [ ] `npm run format` ran (Biome)
- [ ] UI changes include screenshots or video for every affected platform
- [ ] Tests added or updated where it made sense

View File

@@ -19,7 +19,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: npm install
- name: Check formatting
run: npx oxfmt --check .
@@ -35,13 +35,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint lockfile
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
- name: Verify dependency signatures
run: npm audit signatures
run: npm install
- name: Lint
run: npm run lint
@@ -57,7 +51,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -72,12 +66,7 @@ jobs:
run: npm run typecheck
server-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
@@ -92,10 +81,7 @@ jobs:
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm ci
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -109,6 +95,49 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: >
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.percent-escape.test.ts
src/utils/spawn.test.ts
src/utils/process-tree.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/terminal/worker-terminal-manager.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
desktop-tests:
strategy:
fail-fast: false
@@ -124,7 +153,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -149,7 +178,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
@@ -171,7 +200,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
@@ -186,7 +215,7 @@ jobs:
run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
@@ -214,7 +243,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: npm install
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
@@ -238,10 +267,10 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: npm install
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight

View File

@@ -21,7 +21,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
run: npm install --workspace=@getpaseo/relay --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/relay

View File

@@ -28,7 +28,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci --workspace=@getpaseo/website --include-workspace-root
run: npm install --workspace=@getpaseo/website --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/website

45
.github/workflows/fix-nix-hash.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- "package.json"
- "package-lock.json"
pull_request:
paths:
- "package.json"
- "package-lock.json"
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push

View File

@@ -13,9 +13,6 @@ on:
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
pull_request:
branches: [main]
paths:
@@ -28,9 +25,6 @@ on:
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
permissions:
contents: read
@@ -38,35 +32,28 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
continue-on-error: false
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates (main push only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check

52
.github/workflows/server-ci.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Server CI
on:
push:
branches: [main]
paths:
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
pull_request:
branches: [main]
paths:
- "packages/server/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/server-ci.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/server
- name: Test
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

4
.gitignore vendored
View File

@@ -13,8 +13,6 @@ out/
.env.local
.env.test.local
.env*.local
.dev.vars
**/.dev.vars
# Logs
*.log
@@ -66,7 +64,6 @@ CLAUDE.local.md
.paseo/
.wrangler/
**/.wrangler/
**/.tanstack/
# Local agent/tooling artifacts (do not commit)
PLAN.md
@@ -86,4 +83,3 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript
/artifacts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json

View File

@@ -11,5 +11,5 @@
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
"ignorePatterns": ["*.lock"]
}

View File

@@ -1,104 +1,5 @@
# Changelog
## 0.1.71 - 2026-05-09
### Added
- **Import existing Claude, Codex, and OpenCode sessions** into Paseo — pick up a conversation you started in the terminal and keep going from the app, with the full timeline. ([#766](https://github.com/getpaseo/paseo/pull/766), [#833](https://github.com/getpaseo/paseo/pull/833))
- **Subagents now appear in a collapsible section above the composer** so you can jump into agents your main agent spawned. ([#532](https://github.com/getpaseo/paseo/pull/532))
- Merge a pull request directly from the checkout pane. ([#814](https://github.com/getpaseo/paseo/pull/814))
- Customize the per-project prompts Paseo uses to auto-generate agent titles, branch names, commit messages, and pull request descriptions. ([#836](https://github.com/getpaseo/paseo/pull/836))
- Open an empty workspace without typing a prompt first. ([#834](https://github.com/getpaseo/paseo/pull/834))
- Project settings are now grouped with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
- Rich context menu on desktop — copy link, copy image, and spellcheck suggestions.
- Archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
### Improved
- Opening a workspace auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
- An unattended agent that spawns a sub-agent on a different provider via MCP now starts the sub-agent in unattended mode too.
### Fixed
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
### Security
- Local state files (daemon keypair, stored credentials, persisted config) are now readable only by the owning user. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
## 0.1.70 - 2026-05-08
### Breaking
- **Claude agents now require `claude` on your PATH.** Install Claude Code globally (`npm install -g @anthropic-ai/claude-code`) before running a Claude agent — Paseo no longer ships a bundled fallback binary. Same posture as Codex and OpenCode, and shrinks the desktop install by ~210 MB per platform.
### Added
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
- Claude's Sonnet 4.6 1M context model is now selectable in the model picker.
- Detect GitHub issue and PR URLs pasted into the composer search.
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
- `paseo schedule update` to edit a schedule in place without recreating it.
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
### Improved
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
- Self-hosted relays can opt into `wss://` for TLS connections.
- Workspace open targets only show options reachable from the current daemon.
- Combobox search matches model descriptions, not just names.
- Codex image attachments render inline as path markdown.
- Subagent task notifications no longer clutter the parent agent's timeline.
- Voice mode: quieter thinking tone and small UI polish.
- Settings sidebar order: Projects now appears after General.
- Electron upgraded to 41.2.0 for the desktop app.
### Fixed
- **Claude agent: daemon no longer crashes mid-turn** when the underlying SDK fires a stray control message after the connection has been torn down.
- **Windows:** Terminals start reliably and shut down cleanly without leaving stuck processes behind.
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
- Daemon shutdown terminates all child processes cleanly using tree-kill.
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
- OpenCode now forwards provider retry errors instead of silently swallowing them.
- Codex import no longer reverts to the wrong default mode.
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
- Workspace navigation regression on web fixed.
- Duplicate workspace shell navigation eliminated.
- The 'Update installed' callout no longer flashes incorrectly.
- Browser pane reload focus and devtools handling.
- MCP terminal capture now includes scrollback.
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
## 0.1.69 - 2026-05-05
### Fixed
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
## 0.1.68 - 2026-05-05
### Fixed
- The desktop app no longer fails on first launch after a fresh install.
## 0.1.67 - 2026-05-03
### Fixed

View File

@@ -15,32 +15,19 @@ This is an npm workspace monorepo:
- `packages/desktop` — Electron desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Docs
## Documentation
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| Doc | What's in it |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| Doc | What's in it |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/design.md](docs/design.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
@@ -75,19 +62,12 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
- `npm run lint -- packages/app/src/components/message.tsx`
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- Never flip optional → required, remove fields, or narrow types (`string``enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string``enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Platform gating

View File

@@ -1,24 +1,31 @@
# Contributing to Paseo
Thanks for taking the time to contribute.
## How this project works
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
- **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
This means:
## Reporting bugs
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
- There is no obligation to merge a PR as-submitted, regardless of code quality.
Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
- **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
- **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
- **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
- **One bug per issue.** Three findings, three issues.
## How to contribute
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
2. **Keep it small.** One bug, one flow, one focused change.
3. **Open a PR** once there is alignment on scope.
If you want to propose a direction change, start a conversation.
## Before you start
Please read these first:
- [README.md](README.md)
- [docs/architecture.md](docs/architecture.md)
- [docs/development.md](docs/development.md)
@@ -28,36 +35,134 @@ Fill in the bug report form. The fields are there because asking back for the su
## What is most helpful
- bug fixes (especially Windows and Linux)
The most useful contributions right now are:
- bug fixes
- windows and linux specific fixes
- regression fixes
- doc improvements
- packaging and platform fixes
- focused UX improvements that fit the product direction
- packaging / platform fixes
- focused UX improvements that fit the existing product direction
- tests that lock down important behavior
## Scope expectations
Please keep PRs narrow.
Good:
- fix one bug
- improve one flow
- add one focused panel or command
- tighten one piece of UI
Bad:
- combine multiple product ideas in one PR
- bundle unrelated refactors with a feature
- sneak in roadmap decisions
If a contribution contains multiple ideas, split it up.
## Product fit matters
Paseo is an opinionated product.
When reviewing contributions, the bar is not just:
- is this useful?
- is this well implemented?
It is also:
- does this fit Paseo?
- does this add product surface that will be hard to maintain?
- does the value justify the maintenance surface it adds?
- does this solve a common need or over-serve an edge case?
- does this preserve the product's current direction?
## Development setup
### Prerequisites
- Node.js matching `.tool-versions`
- npm workspaces
### Start local development
```bash
# runs both daemon and expo app
npm run dev
```
Useful commands:
```bash
npm run dev # daemon + expo
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
npm run cli -- ls -a -g
```
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
## Pull requests
## Multi-platform testing
- One focused change per PR. Split unrelated cleanups out.
- Reference the issue you're fixing, unless it's a small objective bug.
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
- `npm run typecheck` and `npm run lint` must pass.
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
**On AI-assisted PRs.** AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
Common checks:
```bash
npm run typecheck
npm run test --workspaces --if-present
```
Important rules:
- always run `npm run typecheck` after changes
- tests should be deterministic
- prefer real dependencies over mocks when possible
- do not make breaking WebSocket / protocol changes
- app and daemon versions in the wild lag each other, so compatibility matters
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
## Coding standards
Paseo has explicit standards. Follow them.
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
## PR checklist
Before opening a PR, make sure:
- there was prior discussion and alignment on scope (issue or conversation)
- the change is focused, one idea per PR
- the PR description explains what changed and why
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
- UI changes have been tested on mobile and web at minimum
- typecheck passes
- tests pass, or you clearly explain what could not be run
- relevant docs were updated if needed
## Communication
If you are unsure whether something fits, ask first.
That is especially true for:
- new core UX
- naming / terminology changes
- new extension points
- new orchestration models
- anything that would be hard to remove later
Early alignment saves everyone time.
## Forks are fine
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.
If you want to explore a different product direction, a fork is completely fine.
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.

View File

@@ -91,9 +91,9 @@ paseo --host workstation.local:6767 run "run the full test suite"
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Skills
## Orchestration skills (Unstable)
Skills teach your agent to use Paseo to orchestrate other agents.
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
@@ -101,10 +101,18 @@ npx skills add getpaseo/paseo
Then use them in any agent conversation:
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development
@@ -136,58 +144,6 @@ npm run build:daemon
npm run typecheck
```
## Community
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
### Self-hosted relay TLS
Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with:
```bash
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
PASEO_RELAY_USE_TLS=true \
paseo daemon start
```
Equivalent config:
```json
{
"daemon": {
"relay": {
"enabled": true,
"endpoint": "127.0.0.1:8080",
"publicEndpoint": "relay.example.com:443",
"useTls": true
}
}
}
```
Minimal nginx WebSocket proxy:
```nginx
server {
listen 443 ssl;
server_name relay.example.com;
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
---
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>

View File

@@ -19,19 +19,18 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
### How it works
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
### Why the relay can't attack you
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
@@ -42,11 +41,11 @@ The QR code or pairing link is the trust anchor. It contains the daemon's public
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
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.
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
@@ -56,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
## Agent authentication

View File

@@ -1,10 +1,6 @@
# Ad-hoc daemon testing
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
This is for test code only. Executable daemon processes must start through
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
do not use `createPaseoDaemon` as a product launch path.
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
## Quick start
@@ -48,7 +44,7 @@ const port = target!.type === "tcp" ? target!.port : null;
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.70", // see gotcha #1
appVersion: "0.1.54", // see gotcha #1
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
@@ -78,7 +74,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js";
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.70",
appVersion: "0.1.54",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
@@ -89,7 +85,7 @@ await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
```
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods
@@ -116,7 +112,7 @@ Always pass `appVersion`:
```typescript
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
appVersion: "0.1.70",
appVersion: "0.1.54",
});
```

View File

@@ -1,107 +0,0 @@
# Agent lifecycle
How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track).
## States
```
initializing → idle → running → idle (or error → closed)
↑ │
└────────┘ (agent completes a turn, awaits next prompt)
```
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Relationships
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
1. Snapshot the current session into the registry
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
3. Notify subscribers
4. Close the runtime (kills the process if still running)
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
Cascade is what keeps subagent fleets from outliving their orchestrator.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
| Concept | Scope | Triggers |
| -------------------------- | ---------- | -------------------------- |
| **Tab** (workspace layout) | Per-client | User opens/closes a view |
| **Archive** (lifecycle) | Global | Explicit lifecycle gesture |
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
## The subagents track
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
```
parentAgentId === thisAgent.id AND !archivedAt
```
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
## Why this shape
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
## Limitations
### Detached agents are cascade-archived
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
### Cross-client tab dismissal
Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
## Storage
```
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | ------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -53,8 +53,6 @@ Stable tag pushes like `v0.1.0` trigger:
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag.
### Useful commands
```bash

View File

@@ -51,51 +51,39 @@ The heart of Paseo. A Node.js process that:
- Exposes an MCP server for agent-to-agent control
- Optionally connects outbound to a relay for remote access
All paths are under `packages/server/src/`.
**Key modules:**
| Module | Responsibility |
| ------------------------------- | ---------------------------------------------------------------------------- |
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
| Module | Responsibility |
| ------------------------- | ----------------------------------------------------------------------------- |
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
- `DaemonRegistryContext` manages saved daemon connections
- `SessionContext` wraps the daemon client for the active session
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
- Voice features: dictation (STT) and voice agent (realtime)
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
Commander.js CLI with Docker-style commands:
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo chat ls/create/inspect/post/read/wait/delete`
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
- `paseo daemon start/stop/restart/status/pair`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree create/ls/archive`
- `paseo speech …`
- `paseo worktree ls/archive`
Communicates with the daemon via the same WebSocket protocol as the app.
@@ -103,11 +91,10 @@ Communicates with the daemon via the same WebSocket protocol as the app.
Enables remote access when the daemon is behind a firewall.
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
- ECDH key exchange + AES-256-GCM encryption
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`
See [SECURITY.md](../SECURITY.md) for the full threat model.
@@ -125,48 +112,30 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`.
All clients speak the same binary-multiplexed WebSocket protocol.
**Handshake:**
```
Client → Server: WSHelloMessage {
type: "hello",
clientId,
clientType: "mobile" | "browser" | "cli" | "mcp",
protocolVersion,
appVersion?,
capabilities?: { voice?, pushNotifications?, ... },
}
Server → Client: status message with payload { status: "server_info",
serverId, hostname, version, capabilities?, features }
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
```
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
**Notable session message types:**
**Message types:**
- `agent_update` — Agent state changed (status, title, labels)
- `agent_stream` — New timeline event from a running agent
- `workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
- Terminal subscribe/input/capture commands
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
- `workspace_update` — Workspace state changed
- `agent_permission_request` — Agent needs user approval for a tool call
- Command-response pairs for fetch, list, create, etc.
**Binary frames (terminal stream protocol):**
**Binary multiplexing:**
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- 1-byte opcode: `Output (0x01)`, `Input (0x02)`, `Resize (0x03)`, `Snapshot (0x04)`
- 1-byte slot: terminal slot id
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
- Channel 0: control messages
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
### Compatibility rules
@@ -185,44 +154,26 @@ Example: adding a new enum value
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
```
initializing → idle running
error
closed
initializing → idle running → idle (or error → closed)
└────────┘ (agent completes a turn, awaits next prompt)
```
- `initializing` — provider session is being created
- `idle` — has a live session, awaiting the next prompt
- `running` — provider is currently producing a turn
- `error` — last attempt failed; session is still attached
- `closed` — terminal state, no live session
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
- **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
- **AgentManager** tracks up to 200 timeline items per agent
- Timeline is append-only with epochs (each run starts a new epoch)
- Events stream to all subscribed clients in real time
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
## Agent providers
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
Each provider implements a common `AgentClient` interface:
The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode server / CLI | Provider-managed |
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
| Generic ACP | ACP wrapper | Provider-managed |
| Pi-direct | Direct Anthropic API call | Stateless |
| Mock load test | In-process fake | In-memory |
| Provider | Wraps | Session format |
| -------- | ------------------- | -------------------------------------------------- |
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
All providers:
@@ -243,21 +194,12 @@ All providers:
## Storage
`$PASEO_HOME` defaults to `~/.paseo`. The most important files:
```
$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
── chat/ # Chat rooms
├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
├── push-tokens.json # Mobile push tokens
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
└── daemon.log # Daemon trace logs (rotated)
── daemon.log # Daemon trace logs
```
## Deployment models

View File

@@ -1,98 +1,188 @@
# Coding Standards
The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision.
For testing rules, see [testing.md](testing.md).
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
## Core principles
- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit.
- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction.
- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff.
- **Functional and declarative** over object-oriented.
- **`function` declarations** over arrow function assignments.
- **`interface`** over `type` when both work.
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
- **Zero complexity budget** — justify every abstraction with specific benefits
- **Fully typed TypeScript** — no `any`, no untyped boundaries
- **YAGNI** — build features and abstractions only when needed
- **Functional and declarative** over object-oriented
- **`interface`** over `type` when possible
- **`function` declarations** over arrow function assignments
- **Single-purpose functions** — one function, one job
- **Design for edge cases through types** rather than explicit handling
- **Don't catch errors** unless there's a strong reason to
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
- **No "while I'm at it" improvements** — stay focused on the task
## Comments and noise
## Type hygiene
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
- No tutorial comments explaining language features (`// Use destructuring to...`).
- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art.
- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate.
- No commented-out code. Git remembers.
- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it.
### Infer from schemas
## Confidence: commit to a shape
Never hand-write a TypeScript type that can be inferred from a Zod schema.
- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says.
- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality.
- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter).
- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch.
- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries.
```typescript
// Bad: duplicate type that can drift
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
type RPCArgs = { procedure: string; args: Record<string, unknown> };
## Types
// Good: infer from schema
type RPCArgs = z.infer<typeof schema>;
```
- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less.
- If a Zod schema exists, the TypeScript type is `z.infer<typeof schema>`. Never hand-write a parallel type.
- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields.
- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args.
- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time.
- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site.
- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags.
### Named types over inline
## Errors
No complex inline types in public function signatures.
- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string.
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
```typescript
// Bad
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
## Density
// Good
interface EnqueueJobInput {
userId: string;
priority: "low" | "normal" | "high";
}
function enqueueJob(input: EnqueueJobInput) {}
```
- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`).
- Boolean expressions with 2+ clauses or mixed concerns → name the conditions.
- Object literals assemble pre-computed values; don't pack branching and lookups into property positions.
- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates.
- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract.
### Object parameters
## Structure and modules
If a function needs more than one argument, use a single object parameter.
- A directory is a module, not a namespace. One intentional public surface; internal files stay internal.
- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path.
- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain.
- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere).
- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it.
- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it.
- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case.
- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace.
- Don't drop new files at the nearest root just because placement is unclear — say so and ask.
```typescript
// Bad: positional args
function createToolCall(provider: string, toolName: string, payload: unknown) {}
## Refactoring is a bolt-on test
// Good: object param
interface CreateToolCallInput {
provider: string;
toolName: string;
payload: unknown;
}
function createToolCall(input: CreateToolCallInput) {}
```
- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead.
- Refactors preserve behavior by default. No removing features to simplify code without explicit approval.
- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md).
- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed.
### One canonical type per concept
## React
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
- `useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`.
- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer.
- `useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union.
- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse.
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
- Never define components inside other components. Module-scope only.
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
- Use stable ids for `key`, never array index for reorderable/filterable lists.
- Context for stable values (theme, auth). Store with selectors for state that changes.
```typescript
// Bad: duplicated fields across layers
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
## Naming
// Good: canonical type + wrappers
type ToolCall = { toolName: string; args: Record<string, unknown> };
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
```
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`.
- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape.
- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong.
- Booleans read as yes/no questions: `isX`, `hasX`, `canX`. Avoid negative booleans (`isNotConnected`).
- `data`, `result`, `info`, `manager`, `temp` are smells — say what the thing _is_.
## Make impossible states impossible
Use discriminated unions instead of bags of booleans and optionals.
```typescript
// Bad
interface FetchState {
isLoading: boolean;
error?: Error;
data?: Data;
}
// Good
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: Data };
```
## Optionality is a design decision
Don't mark fields optional to avoid migrations. Decide deliberately:
1. Is optionality actually needed?
2. If there are distinct valid states → discriminated union
3. If value can be intentionally empty → explicit `null`
4. Keep optionality at real boundaries (external input), then resolve it
## Validate at boundaries, trust internally
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
```typescript
// Bad: optional chaining because shape is unclear
const value = response?.data?.items?.[0]?.name;
// Good: validate at boundary, trust the types
const parsed = responseSchema.parse(rawResponse);
const value = parsed.data.items[0].name;
```
## Error handling
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
```typescript
class TimeoutError extends Error {
constructor(
public readonly operation: string,
public readonly waitedMs: number,
) {
super(`${operation} timed out after ${waitedMs}ms`);
this.name = "TimeoutError";
}
}
```
## Keep logic density low
Avoid packing branching, lookup, and transformation into single dense expressions.
```typescript
// Bad: nested ternaries + inline lookups
const billing = shouldUseLegacy(account)
? getLegacy(account)
: buildBilling(
account,
rates.find((r) => r.region === account.region),
);
// Good: named steps, then assemble
const rate = rates.find((r) => r.region === account.region);
if (!rate) throw new MissingRateError(account.region);
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
```
## Centralize policy
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
## React: keep components dumb
- Components render state and dispatch events — they don't compute transitions
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
- Never mirror a source of truth into local state; derive from it
- Test state logic as pure functions without rendering
## File organization
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
- Name files after the main export (`create-toolcall.ts`)
- Use `index.ts` as an entrypoint, not a dumping ground
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
## Refactoring contract
Refactoring is structure work, not feature work.
- Preserve behavior by default, especially user-facing behavior
- Do not remove features to simplify code without explicit approval
- Have a verification strategy before you start
- Fully migrate callers and remove old paths in the same refactor
- No fallback behavior by default — prefer explicit error over silent degradation

View File

@@ -83,7 +83,6 @@ Required fields for custom providers:
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
@@ -137,7 +136,6 @@ Required fields for custom providers:
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
@@ -346,7 +344,7 @@ Required fields for ACP providers:
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
2. Authenticate with Google (Gemini CLI handles its own auth)
3. Add to config.json:
@@ -552,7 +550,6 @@ A config.json with multiple custom providers:
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
@@ -567,7 +564,6 @@ A config.json with multiple custom providers:
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }

View File

@@ -1,6 +1,6 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
@@ -11,12 +11,8 @@ All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
├── paseo.pid # Daemon PID lock file
├── daemon.log # Default log file (path configurable)
├── agents/
│ └── {sanitized-cwd}/
│ └── {project-dir}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
@@ -30,8 +26,6 @@ $PASEO_HOME/
└── push-tokens.json # Expo push notification tokens
```
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
---
## 1. Agent Record
@@ -40,29 +34,28 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
@@ -121,7 +114,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string \| null` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
@@ -137,11 +130,10 @@ Single file, validated with `PersistedConfigSchema`.
version: 1,
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
mcp: { enabled: boolean, injectIntoAgents: boolean },
hostnames: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean },
auth: { password: string } // bcrypt hash, optional
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
},
app: {
baseUrl: string
@@ -151,10 +143,12 @@ Single file, validated with `PersistedConfigSchema`.
local: { modelsDir: string }
},
agents: {
// 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: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
@@ -176,7 +170,7 @@ All fields are optional with sensible defaults.
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
@@ -261,7 +255,7 @@ Single file containing all rooms and messages.
**Path:** `$PASEO_HOME/loops/loops.json`
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
Single file containing an array of all loop records.
| Field | Type | Description |
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
@@ -271,12 +265,10 @@ Single file containing an array of all loop records. Writes are direct (not atom
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `modeId` | `string?` | Default mode ID |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifierModeId` | `string?` | Override mode ID for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
@@ -352,15 +344,15 @@ Single file containing an array of all loop records. Writes are direct (not atom
Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | ---------------------------------------- |
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
| Field | Type | Description |
| ------------- | -------------------- | ------------------------------ |
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
@@ -370,16 +362,16 @@ Array of project records.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------ |
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ----------------------- |
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
@@ -393,20 +385,7 @@ Array of workspace records. A workspace is a specific working directory within a
}
```
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
---
## 9. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
| Path | Format | Notes |
| --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
---

247
docs/design-system.md Normal file
View File

@@ -0,0 +1,247 @@
# Design system
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
---
## 1. Character
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
---
## 2. Component reuse
A semantic element used in three or more places is a primitive. One of a kind is a screen.
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
Before adding a new component, read `components/ui/`. The primitive usually exists.
---
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Weight has three tiers, applied by role:
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
---
## 4. Buttons
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 812px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
---
## 14. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |

View File

@@ -1,247 +1,73 @@
# Design
# Designing Features
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
How to think through a feature before writing code.
---
## Start from the user
## 1. Character
Even for backend work, start from the user's perspective:
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
- What problem does this solve?
- What triggers it? User action, schedule, event?
- What does success look like from the user's perspective?
- What data does it need? Where does that data come from?
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
## Map existing code
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
Before designing anything new, understand what exists:
---
- Where does similar functionality live?
- What patterns does the codebase already use?
- What layers exist? (See [architecture.md](./architecture.md))
- What types and data shapes are already defined?
## 2. Component reuse
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
A semantic element used in three or more places is a primitive. One of a kind is a screen.
## Define verification before implementation
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
Before designing the solution, define how you'll know it works:
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
- What tests will prove this feature is correct?
- At what layer? Unit, integration, E2E?
- What's the simplest way to verify the core behavior?
Before adding a new component, read `components/ui/`. The primitive usually exists.
If you can't define verification, you don't understand the feature well enough yet.
---
## Design the shape
## 3. Hierarchy
### Data
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
Weight has three tiers, applied by role:
### Layers
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
- What belongs in each layer?
- Where are the boundaries?
- What does each layer expose to the layer above?
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
### Interactions
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
- How does data flow through the system?
- What triggers what?
- Where do side effects happen?
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
### Refactoring
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
- What existing code needs to change?
- Is existing code testable enough? If not, that's part of the plan.
---
## Create a concrete plan
## 4. Buttons
Once the design is clear:
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
3. **What to refactor** before adding new code
4. **How to verify** each step
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
## Principles
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 812px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
---
## 14. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
- **Fit, don't force** — new code should fit existing patterns, or refactor first
- **Simple** — the best design is the simplest one that works
- **Verify early** — define how to test before designing the implementation

View File

@@ -11,36 +11,55 @@
npm run dev
```
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
### PASEO_HOME
### Running alongside the main checkout
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
Override knobs:
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
```bash
PASEO_HOME=~/.paseo-blue npm run dev # explicit home
PASEO_DEV_SEED_HOME=/path/to/home npm run dev # seed from a different source home
PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived worktree home
PASEO_HOME=~/.paseo-blue npm run dev
```
### Daemon endpoints
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
and `config.json`; actual checkout/worktree directories are not copied.
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
- Stable daemon launched by the desktop app: `localhost:6767`.
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
- `npm run dev` (Windows): `localhost:6767` for the daemon.
### Default ports
In any worktree-style or portless setup, never assume default ports.
In the main checkout:
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for trace-level logs.
### Database queries
Run arbitrary SQL against the SQLite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
@@ -82,25 +101,29 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
## Build sync gotchas
The daemon and CLI consume sibling workspaces from compiled `dist/` output, not `src/`. When you change a workspace that something else imports, rebuild the producer first or the consumer will speak a stale protocol and fail with handshake warnings, timeouts, or stale type errors.
### Relay → Daemon
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
When changing `packages/relay/src/*`, rebuild before running the daemon:
```bash
npm run build:daemon
npm run build --workspace=@getpaseo/relay
```
This rebuilds, in order, `@getpaseo/highlight``@getpaseo/relay``@getpaseo/server``@getpaseo/cli`. Use it whenever you have changed any of those four and need clean cross-package types or runtime behavior.
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
For tighter loops, you can rebuild a single workspace:
### Server → CLI
- Changed `packages/relay/src/*`: `npm run build --workspace=@getpaseo/relay` (server imports `@getpaseo/relay` from `dist/*`).
- Changed `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types: `npm run build --workspace=@getpaseo/server` (CLI imports `@getpaseo/server` via package exports resolving to `dist/*`).
- Changed `packages/highlight/src/*`: `npm run build --workspace=@getpaseo/highlight` (server depends on it).
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
```bash
npm run build --workspace=@getpaseo/server
```
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
## CLI reference
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
```bash
npm run cli -- ls -a -g # List all agents globally
@@ -154,7 +177,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
## Testing with Playwright MCP
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.

View File

@@ -15,7 +15,6 @@ This file is auto-generated. Do not edit it by hand.
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
## Adding a new icon

View File

@@ -2,28 +2,44 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/server/src/shared/messages.ts:1936`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2113`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/server/src/shared/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2128`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:9`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:576`); (b) **git merge conflict** (no current UI string).
## Open question — per-host project entry (TBD)
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
## Rename before landing (in-progress `Checkout*` references)
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155``ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90``checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600``ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521``checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
- `packages/app/src/utils/projects.test.ts:107,108,111,259``checkoutCount`, `onlineCheckoutCount`, `checkouts`.
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.

View File

@@ -143,7 +143,7 @@ Maestro `inputText` fires one character at a time. React Native's **controlled**
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
See `pair-link-modal.tsx` for the pattern (`useRef`-backed `onChangeText`, no `value=` prop). Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
### Dropdowns that launch native presenters (iOS)

View File

@@ -67,14 +67,10 @@ Anyone who builds software:
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
## Current state (May 2026)
## Current state (March 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes
- Key UX: split panes, keybinding customization, workspace model, in-app browser
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
- Daily releases
- Community contributions starting (packaging, bug fixes)
- Key UX: split panes, keybinding customization, workspace model

View File

@@ -6,15 +6,15 @@ This guide walks through adding a new agent provider end-to-end. There are two i
### ACP (Agent Client Protocol) -- recommended
Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-agent.ts`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
Existing ACP providers: `claude-acp`, `copilot`.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude`, `codex`, `opencode`.
---
@@ -151,7 +151,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
### 3. Add the factory to the provider registry
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry to `PROVIDER_CLIENT_FACTORIES`:
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
```ts
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
@@ -161,13 +161,11 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
"my-provider": (logger, runtimeSettings) =>
new MyProviderACPAgentClient({
logger,
runtimeSettings,
runtimeSettings: runtimeSettings?.["my-provider"],
}),
};
```
The factory is invoked with `(logger, runtimeSettings, options)`; `options.workspaceGitService` is also available if you need it (see the `codex` factory for an example). The registry already passes the per-provider runtime settings slice through, so you don't index into the map yourself.
### 4. Add a provider icon (app)
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
@@ -189,18 +187,19 @@ export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProvider
}
```
Then register it in `packages/app/src/components/provider-icons.ts` by adding an entry to the existing `PROVIDER_ICONS` map (which already covers the built-in providers):
Then register it in `packages/app/src/components/provider-icons.ts`:
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
// ... existing entries ...
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
"my-provider": MyProviderIcon as unknown as typeof Bot,
};
```
If no icon is registered, `getProviderIcon()` falls back to a generic `Bot` icon from lucide.
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
### 5. Add E2E test config
@@ -220,25 +219,25 @@ export const agentConfigs = {
} as const satisfies Record<string, AgentTestConfig>;
```
Add an availability check in `isProviderAvailable()`. Note `isCommandAvailable` is async, so all branches `await` it:
Add an availability check in `isProviderAvailable()`:
```ts
case "my-provider":
return (
(await isCommandAvailable("my-agent-binary")) &&
isCommandAvailable("my-agent-binary") &&
Boolean(process.env.MY_PROVIDER_API_KEY)
);
```
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
Add to the `allProviders` array:
```ts
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"pi",
"my-provider",
];
```
@@ -259,9 +258,7 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
### Interfaces to implement
The interfaces below are abridged signatures — read `agent-sdk-types.ts` for the full source of truth (option bag types, generics, etc.).
**`AgentClient`** -- factory for sessions and model/mode listing:
**`AgentClient`** -- factory for sessions and model listing:
```ts
interface AgentClient {
@@ -270,19 +267,16 @@ interface AgentClient {
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession>;
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
}
```
@@ -293,7 +287,6 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
@@ -303,10 +296,7 @@ interface AgentSession {
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
@@ -314,10 +304,6 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null;
}
```

View File

@@ -14,14 +14,14 @@ There are two supported ways to ship from `main`:
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Make sure local `npm run typecheck` passes on that commit.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
```bash
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering `Desktop Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
@@ -147,16 +147,11 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
## Release notes on GitHub
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
## Fixing a failed release build

View File

@@ -98,37 +98,6 @@ When a test is labeled end-to-end, it calls the real service. No environment var
- Test bodies should read like plain English
- Build a vocabulary of test helpers that make complex flows simple
### File naming
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
| Suffix | What it is | Where it runs |
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/OpenCode) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
### Test setup
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
- App: `packages/app/vitest.setup.ts` provides `expo`/`__DEV__` shims and stubs a few native-only modules (`react-native-unistyles`, `react-native-svg`, `expo-linking`, `@xterm/addon-ligatures`). Stubbing here is for modules that have no meaningful Node behavior — not a license to mock app code.
## Running tests locally
Test suites in this repo are heavy. Running them in bulk freezes the machine, especially with multiple agents in parallel.
- Run only the file you changed: `npx vitest run <path> --bail=1`
- Never run `npm run test` for a whole workspace unless asked.
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI.
## Agent authentication in tests
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.

View File

@@ -64,9 +64,9 @@ If you add a new `useUnistyles()` call, leave a comment on the line explaining w
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection`
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
- `WorkspaceScreen` shell, tabs row, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
@@ -124,9 +124,18 @@ const styles = StyleSheet.create((theme) => ({
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
In practice the wrapper-`View` pattern is the one we use. Across the app, `withUnistyles` is now reserved for wrapping leaf components — mostly lucide icons (`ThemedActivityIndicator`, `ThemedChevronDown`, …) and small third-party components like `MarkdownWithStableRenderer` — so they pick up theme-reactive `color`/`tintColor` props without re-rendering their parent.
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
In principle, [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles) can also wrap a `ScrollView` to make `contentContainerStyle` theme-reactive via its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props). We previously did this on the welcome screen and hit the `> *` child-selector leak documented below; we have since moved the welcome screen to the wrapper-`View` pattern. If you find yourself reaching for `withUnistyles(ScrollView)`, treat it as a smell and check whether a wrapper view works first.
```tsx
import { ScrollView } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const ThemedScrollView = withUnistyles(ScrollView);
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
```
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
@@ -146,7 +155,7 @@ Use this sparingly. It works because React re-renders the prop, but it gives up
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `panels/agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
```css
.unistyles_j2k2iilhfz {
@@ -222,49 +231,15 @@ If a style factory is cheap, skipping `useMemo` entirely is also fine.
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
Wrap the icon (or other leaf component) with `withUnistyles` instead, so only that node re-renders when the theme changes:
Use `useUnistyles()` inside the component instead:
```tsx
import { ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const { theme } = useUnistyles();
const ThemedChevronDown = withUnistyles(ChevronDown);
const styles = StyleSheet.create((theme) => ({
icon: { color: theme.colors.foregroundMuted },
}));
<ThemedChevronDown size={theme.iconSize.md} style={styles.icon} />;
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
```
This is the dominant pattern in the app today (see `sidebar-workspace-list.tsx`, `message.tsx`, the workspace screens). Reserve `useUnistyles()` for the last-resort cases described at the top of this file. Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
## Reanimated `Animated.View` + Dynamic Styles Crashes
Do not apply `StyleSheet.create((theme) => ...)` styles to a Reanimated `Animated.View`. Unistyles wraps styled components in a `<UnistylesComponent>` and patches native view props from C++ via the ShadowRegistry. Reanimated also reaches into the same native node from its worklet runtime. When a theme change fires, both systems try to mutate the same node and the app crashes with `Unable to find node on an unmounted component.` This was a real iOS sidebar crash on theme toggle (commit `4896cfe9`).
Fix: keep static positioning on the `Animated.View` in plain React Native `StyleSheet`, and pass theme-dependent values (e.g. `backgroundColor`) as inline style from `useUnistyles()` — the inline path is acceptable here because no other escape works:
```tsx
import { StyleSheet as RNStyleSheet } from "react-native";
import Animated from "react-native-reanimated";
import { useUnistyles } from "react-native-unistyles";
const positionStyles = RNStyleSheet.create({
sidebar: { position: "absolute", inset: 0, width: 280 },
});
function Sidebar() {
const { theme } = useUnistyles();
return (
<Animated.View
style={[positionStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surface1 }]}
/>
);
}
```
This is one of the rare places `useUnistyles()` is the right tool: there is no `withUnistyles(Animated.View)` equivalent, the affected component is small, and the alternative is a crash.
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
## Adaptive Themes And Persisted Settings

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -123,7 +123,7 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI

2585
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.71",
"version": "0.1.67",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -1,21 +0,0 @@
name: Resubmit iOS for App Store review
# Standalone re-trigger for the App Store review submission step. The iOS
# binary is already uploaded to TestFlight via the main release-mobile flow;
# this workflow just runs the fastlane submit_review lane against the latest
# TestFlight build, without rebuilding or re-uploading.
on:
workflow_dispatch: {}
jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Submit for review
run: bundle exec fastlane ios submit_review

View File

@@ -1,37 +0,0 @@
// "00-" prefix is intentional: this file must sort before every other spec.
// 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";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectArchiveTabDaemonClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {
throw new Error(
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
`or the daemon has stale history from a previous run.`,
);
}
} finally {
await client.close().catch(() => undefined);
}
});
test("shows empty placeholder when there is no session history", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
await workspace.navigateTo();
await openSessions(page);
await expectSessionsEmptyState(page);
});
});

View File

@@ -1,33 +0,0 @@
import { test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
expectProviderInstalledInSettings,
installAcpCatalogProvider,
openAddProviderModal,
openSettingsHost,
} from "./helpers/settings";
const ACP_PROVIDER = {
id: "hermes",
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 openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
});
});

View File

@@ -1,45 +0,0 @@
import { test } from "./fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
expectInlineWorkingIndicator,
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
});
try {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
});
try {
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";

View File

@@ -1,284 +0,0 @@
import { expect, test } from "./fixtures";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { expectAgentIdle } from "./helpers/agent-stream";
import {
openAttachmentMenu,
openGithubPickerFromMenu,
attachImageFromMenu,
expectAttachmentPill,
removeAttachmentPill,
openImageLightbox,
closeImageLightbox,
pressInterruptShortcut,
expectComposerDraft,
expectComposerDisabled,
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
selectGithubOption,
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import {
connectNewWorkspaceDaemonClient,
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-plus-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("GitHub combobox does not render until the picker is opened", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
await openGithubPickerFromMenu(page);
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
await expect(
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
).toBeVisible({ timeout: 10_000 });
});
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-issue",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(
page,
"attach-issue-unique-alpha",
`issue:${ghRepo.issues[0].number}`,
);
await expectGithubAttachmentPill(page, {
number: ghRepo.issues[0].number,
title: ghRepo.issues[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-pr",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, {
number: ghRepo.prs[0].number,
title: ghRepo.prs[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
// needed before this can be exercised end-to-end.
});
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
});
test("image lightbox opens on pill click and closes on Escape", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await openImageLightbox(page);
await closeImageLightbox(page);
});
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-pill-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
timeout: 5_000,
});
});
test("submitting while agent is running queues the message and clears the draft", async ({
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
});
try {
await fillComposerDraft(page, "queued draft text");
await sendDraftToQueue(page);
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await client.close();
await repo.cleanup();
}
});
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
});
try {
await fillComposerDraft(page, "preserve me");
await pressInterruptShortcut(page);
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await client.close();
await repo.cleanup();
}
});
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 repo = await createTempGitRepo("attach-lock-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const daemonClient = await connectNewWorkspaceDaemonClient();
try {
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
await expectComposerDisabled(page);
await expectAttachButtonDisabled(page);
agentCreatedDelay.release();
await expectComposerEditable(page);
} finally {
agentCreatedDelay.release();
await daemonClient.close().catch(() => undefined);
await repo.cleanup();
}
});
});

View File

@@ -1,151 +0,0 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopSettings,
expectUpdateBanner,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
toggleDaemonManagement,
expectDaemonManagementConfirmDialog,
expectDaemonManagementEnabled,
expectDaemonManagementDisabled,
expectDaemonStatusPid,
expectDaemonStatusLogPath,
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(),
updateAvailable: true,
latestVersion: "1.2.3",
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
});
test("clicking install shows the installing state on the callout", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getSeededServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
slowInstall: true,
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
});
test.describe("Desktop daemon management", () => {
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
page,
}) => {
const serverId = getSeededServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
expectDaemonManagementConfirmDialog(dialogArgs);
await expectDaemonManagementEnabled(page);
});
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
const serverId = getSeededServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonManagementEnabled(page);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementEnabled(page);
});
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
const serverId = getSeededServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
});
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: false,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusVersion(page, realState.version);
await expectDaemonStatusPid(page, realState.pid);
await expectDaemonStatusLogPath(page, realState.logPath);
});
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
const serverId = getSeededServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusPid(page, realState.pid);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
await expectDaemonStatusPid(page, null);
await toggleDaemonManagement(page, "enable");
await expectDaemonManagementEnabled(page);
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
await expectDaemonStatusPid(page, newPid);
});
});

View File

@@ -1,13 +1,12 @@
import { test as base, expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
// reliably for every test that uses this `test` object.
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
const test = base.extend<{ paseoE2ESetup: void }>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -103,11 +102,6 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
{ auto: true },
],
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);
await handle.cleanup();
},
});
export { test, expect, type Page };

View File

@@ -215,28 +215,8 @@ async function createFakeGhBin(): Promise<string> {
await writeFile(
ghPath,
`#!/usr/bin/env node
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
function findRealGh() {
const fakeBinDir = __dirname;
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (dir === fakeBinDir) continue;
const candidate = path.join(dir, "gh");
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
}
return null;
}
function forwardToRealGh() {
const realGh = findRealGh();
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
process.exit(result.status ?? 1);
}
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
@@ -258,21 +238,8 @@ if (args[0] === "pr" && args[1] === "list") {
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
}
if (args[0] === "api" && args[1] === "graphql") {
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
console.error("no pull requests found for branch");
process.exit(1);
}
if (args[0] === "issue" && args[1] === "list") {
@@ -280,7 +247,8 @@ if (args[0] === "issue" && args[1] === "list") {
process.exit(0);
}
forwardToRealGh();
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
@@ -414,19 +382,15 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
// Fork PRs run without secrets and usually without local models. Don't crash
// the whole Playwright run — disable dictation/voice and let tests that need
// them gate on PASEO_DICTATION_ENABLED.
if (!openAiUsable && !hasDefaultLocalModelsDir) {
console.warn(
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
throw new Error(
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
);
return { openAiUsable: false, localModelsDir: null };
}
const dictationProvider = openAiUsable ? "openai" : "local";
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
@@ -609,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
const child = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
@@ -741,7 +705,6 @@ export default async function globalSetup() {
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
process.env.E2E_PASEO_HOME = paseoHome;
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);

View File

@@ -1,35 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
const messages = page.getByTestId("assistant-message");
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
await expect(target).toBeVisible({ timeout: 30_000 });
}
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
}
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
}
export async function expectTurnCopyButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
const { contentHeight } = await readScrollMetrics(page);
await waitForContentGrowth(page, contentHeight);
await expectNearBottom(page);
}

View File

@@ -1,36 +1,217 @@
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
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 is 6767. Refusing to run e2e against the default local daemon.",
);
}
return port;
}
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const needsReset = await page.evaluate(
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as { serverId?: string; connections?: unknown };
if (entry?.serverId !== serverId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
)
)
return true;
return !connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === endpoint,
);
} catch {
return true;
}
},
{ expectedEndpoint, expectedServerId },
);
if (!needsReset) {
return;
}
const nowIso = new Date().toISOString();
const daemon = buildSeededHost({
serverId: expectedServerId,
endpoint: expectedEndpoint,
nowIso,
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.reload();
}
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
let registry: unknown;
try {
registry = JSON.parse(registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0] as { serverId?: string; connections?: unknown };
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
return { serverId: daemon.serverId, connections: daemon.connections };
}
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
if (
!Array.isArray(connections) ||
!connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
}
}
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
try {
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (prefs?.serverId !== serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
cause: error,
});
}
}
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
if (serverId !== expectedServerId) {
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
);
}
assertDaemonConnections(connections, expectedEndpoint);
if (!snapshot.prefsRaw) {
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
assertPreferencesMatch(snapshot.prefsRaw, serverId);
}
export const gotoAppShell = async (page: Page) => {
await page.goto("/");
await ensureE2EStorageSeeded(page);
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const entryButton = page
.getByText("Add a project", { exact: true })
.or(page.getByText("Add project", { exact: true }))
.or(page.getByText("New agent", { exact: true }))
.first();
const composer = page.getByRole("textbox", { name: "Message agent..." });
if (
!(await composer
.first()
.isVisible()
.catch(() => false))
) {
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
const newAgentButton = page.getByText("New agent", { exact: true }).first();
await expect
.poll(
async () =>
(await composer.isVisible().catch(() => false)) ||
(await entryButton.isVisible().catch(() => false)),
{ timeout: 10_000 },
)
.toBe(true);
await expect
.poll(
async () =>
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 },
)
.toBe(true);
if (!(await composer.isVisible().catch(() => false))) {
await entryButton.click();
if (await addProjectCta.isVisible().catch(() => false)) {
await addProjectCta.click();
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
await addProjectSidebar.click();
} else {
await newAgentButton.click();
}
}
await expect(composer).toBeVisible({ timeout: 30_000 });
await expect(composer.first()).toBeVisible({ timeout: 30000 });
};
export const openSettings = async (page: Page) => {
@@ -160,6 +341,46 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
};
export const ensureHostSelected = async (page: Page) => {
await ensureE2EStorageSeeded(page);
// Absolute verification that we're using the per-run e2e daemon (never :6767).
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
// realigning create-agent-preferences.serverId to the sole seeded daemon.
try {
await assertE2EUsesSeededTestDaemon(page);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/create-agent-preferences\.serverId/i.test(message)) {
throw error;
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;
if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
prefs.serverId = serverId;
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const;
});
if (!fix.ok) {
throw error;
}
await page.reload();
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible();
@@ -171,7 +392,7 @@ export const ensureHostSelected = async (page: Page) => {
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// We enforce a single seeded daemon, so the option should be unambiguous.
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
@@ -436,3 +657,57 @@ export const createAgentInRepo = async (
await setWorkingDirectory(page, config.directory);
await createAgent(page, config.prompt);
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId("permission-request-question").first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000);
// Check if stop button is visible
const isVisible = await stopButton.isVisible().catch(() => false);
if (isVisible) {
// If stop button is still visible after permission denial,
// it might be that the agent is waiting for something.
// Let's check if there's a tool call result or other UI indication
// Look for any indication that the agent has processed the permission denial
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });
}
}
export async function getToolCallCount(page: Page): Promise<number> {
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
// They appear as pressable badges in the agent stream
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
return toolCallBadges.count();
}

View File

@@ -36,9 +36,6 @@ interface ArchiveTabDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
}
function getDaemonPort(): string {
@@ -268,10 +265,8 @@ export async function openSessions(page: Page): Promise<void> {
});
}
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
function getSessionRowByTitle(page: Page, title: string) {
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
@@ -288,12 +283,6 @@ export async function clickSessionRow(page: Page, title: string): Promise<void>
await row.click();
}
export async function expectSessionsEmptyState(page: Page): Promise<void> {
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },

View File

@@ -1,195 +0,0 @@
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 { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { waitForTabBar } from "./launcher";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
export function composerLocator(page: Page) {
return composerInput(page);
}
export async function expectComposerVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
}
export async function expectComposerDisabled(page: Page): Promise<void> {
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
// not <textarea disabled>. Use not.toBeEditable() to match either form.
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
}
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
}
export async function expectComposerEditable(page: Page): Promise<void> {
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(text);
await input.press("Enter");
}
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
await composerInput(page).fill(text);
}
export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
});
}
export async function cancelAgent(page: Page): Promise<void> {
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stopButton).toBeVisible({ timeout: 10_000 });
await stopButton.click();
}
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
export async function pressInterruptShortcut(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function openAttachmentMenu(page: Page): Promise<void> {
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
}
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
await expect(
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
).toBeDisabled({ timeout: 10_000 });
}
export async function attachImageFromMenu(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-image").click();
const chooser = await chooserPromise;
await chooser.setFiles([file]);
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
pillTestId: string,
removeAccessibilityLabel: string,
): Promise<void> {
await page.getByTestId(pillTestId).first().hover();
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
}
export async function expectGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pill = page.getByTestId("composer-github-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 10_000 });
await expect(pill).toContainText(`#${input.number}`);
await expect(pill).toContainText(input.title);
}
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
export async function selectGithubOption(
page: Page,
searchTerm: string,
optionId: string,
): Promise<void> {
await openGithubPickerFromMenu(page);
const searchInput = page.getByPlaceholder("Search issues and PRs...");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill(searchTerm);
const option = page.getByTestId(`composer-github-option-${optionId}`);
await expect(option).toBeVisible({ timeout: 15_000 });
await option.click();
}
export interface MockAgentSetup {
client: TerminalPerfDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
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 repo = await createTempGitRepo(opts.prefix);
const client = await connectTerminalClient();
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
model: opts.model,
initialPrompt: opts.prompt,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
return { client, repo };
}
export interface GithubWorkspaceHandle {
cleanup: () => Promise<void>;
}
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
export async function openGithubWorkspace(
page: Page,
repoPath: string,
): Promise<GithubWorkspaceHandle> {
const client = await connectWorkspaceSetupClient();
const opened = await client.openProject(repoPath);
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
await openHomeWithProject(page, repoPath);
await selectWorkspaceInSidebar(page, opened.workspace.id);
await waitForTabBar(page);
return { cleanup: () => client.close().catch(() => undefined) };
}

View File

@@ -30,15 +30,10 @@ export function buildSeededHost(input: {
};
}
export const TEST_MOCK_PROVIDER_PREFERENCES = {
...TEST_PROVIDER_PREFERENCES,
mock: { model: "ten-second-stream" },
} as const;
export function buildCreateAgentPreferences(serverId: string) {
return {
serverId,
provider: "mock" as const,
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
provider: "codex" as const,
providerPreferences: TEST_PROVIDER_PREFERENCES,
};
}

View File

@@ -1,289 +0,0 @@
import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsHost } from "./settings";
interface DaemonApiStatus {
version: string;
serverId: string;
hostname: string;
}
interface PidFileContent {
pid: number;
desktopManaged: boolean;
}
export interface RealDaemonState {
version: string;
pid: number | null;
logPath: string;
}
/**
* Reads live state from the running E2E test daemon: version from the HTTP
* status endpoint, PID from the paseo.pid lock file, log path from the
* 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 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`);
const data: DaemonApiStatus = await resp.json();
let pid: number | null = null;
try {
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
const pidContent: PidFileContent = JSON.parse(raw);
pid = pidContent.pid ?? null;
} catch (err) {
// PID file may not be present yet on a very fresh daemon start
console.warn("[desktop-updates] paseo.pid not found:", err);
}
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
}
export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
daemonVersion?: string | null;
daemonLogPath?: string;
/** Initial manageBuiltInDaemon setting. Defaults to false. */
manageBuiltInDaemon?: boolean;
/**
* Controls what dialog.ask returns when the daemon management confirm dialog
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
}
export interface ConfirmDialogCall {
message: string;
title: string | undefined;
}
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
}
}
/**
* Injects window.paseoDesktop before app load so all Electron-gated code
* activates. The update-check IPC is mocked at the boundary so the real
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
* tracks running state and assigns a fresh PID on each start, letting tests
* observe PID changes without touching the real E2E daemon process.
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
* can assert dialog copy without depending on window.confirm concatenation.
*/
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
await page.addInitScript((cfg) => {
// Mutable state shared across IPC calls within this page
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0;
function buildDaemonStatus() {
return {
serverId: cfg.serverId,
status: daemonRunning ? "running" : "stopped",
listen: null,
hostname: null,
pid: currentPid,
home: "",
version: cfg.daemonVersion ?? null,
desktopManaged: manageDaemon,
error: null,
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
date: null,
}
: {
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.0.0",
latestVersion: null,
body: null,
date: null,
};
}
if (command === "install_app_update") {
if (cfg.slowInstall) {
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
}
return {
installed: true,
version: cfg.latestVersion ?? "1.2.3",
message: "App update installed. Restart required.",
};
}
if (command === "desktop_daemon_status") {
return buildDaemonStatus();
}
if (command === "desktop_daemon_logs") {
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "patch_desktop_settings") {
const daemon = args?.daemon;
if (
daemon !== null &&
typeof daemon === "object" &&
"manageBuiltInDaemon" in daemon &&
typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
}
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "stop_desktop_daemon") {
daemonRunning = false;
currentPid = null;
return buildDaemonStatus();
}
if (command === "start_desktop_daemon") {
startCount += 1;
daemonRunning = true;
// First start (bootstrap) returns the configured PID; subsequent starts
// (after a stop) get a fresh PID so tests can observe the change.
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
return buildDaemonStatus();
}
return null;
},
dialog: {
ask: async (message: string, options?: Record<string, unknown>) => {
window.__capturedDialogCall = {
message,
title: typeof options?.title === "string" ? options.title : undefined,
};
return cfg.confirmShouldAccept ?? false;
},
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
}, config);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}
export async function expectInstallInProgress(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
}
/**
* Clicks the daemon management switch and waits for dialog.ask to fire in the
* mock, then returns the captured call args (message + title). The mock auto-
* dismisses via confirmShouldAccept=false so callers can assert copy without
* worrying about state changes.
*/
export async function interceptDaemonManagementConfirmDialog(
page: Page,
): Promise<ConfirmDialogCall> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
return page.evaluate(() => window.__capturedDialogCall!);
}
export async function toggleDaemonManagement(
page: Page,
_action: "enable" | "disable",
): Promise<void> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
}
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
expect(args.title).toBe("Pause built-in daemon");
expect(args.message).toContain("stop the built-in daemon immediately");
}
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
}
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
}
/**
* Asserts the daemon status card shows the given PID. Pass null to assert
* the cleared state (shown as "PID —" when the daemon is stopped).
*/
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
const expected = pid !== null ? `PID ${pid}` : "PID —";
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
).toBeVisible();
}
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
).toBeVisible();
}
/**
* Asserts the host page identity badge shows the given version string.
* The badge is populated from the live WebSocket session's serverInfo.version.
*/
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
await expect(
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
).toBeVisible({ timeout: 15_000 });
}

View File

@@ -1,249 +0,0 @@
import { execFileSync, execSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export function hasGithubAuth(): boolean {
try {
execSync("gh auth status", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export interface CheckSpec {
context: string;
state: "success" | "failure" | "pending";
}
export interface PrSpec {
title: string;
state: "open" | "merged" | "closed" | "draft";
checks?: CheckSpec[];
commentCount?: number;
}
export interface IssueSpec {
title: string;
body?: string;
labels?: string[];
state?: "open" | "closed";
}
export interface GhPrFixture {
number: number;
title: string;
url: string;
branch: string;
localPath: string;
}
export interface GhIssueFixture {
number: number;
title: string;
url: string;
}
export interface GhRepoFixture {
owner: string;
name: string;
fullName: string;
prs: GhPrFixture[];
issues: GhIssueFixture[];
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, {
cwd: opts?.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function git(args: string[], cwd: string): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
async function seedPr(args: {
spec: PrSpec;
branch: string;
index: number;
basePath: string;
authedUrl: string;
fullName: string;
repoName: string;
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
const createArgs = [
"pr",
"create",
"--title",
spec.title,
"--base",
"main",
"--head",
branch,
"--body",
"",
];
if (spec.state === "draft") createArgs.push("--draft");
const prUrl = gh(createArgs, { cwd: basePath });
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
if (spec.checks && spec.checks.length > 0) {
const sha = git(["rev-parse", branch], basePath);
for (const check of spec.checks) {
gh([
"api",
`repos/${fullName}/statuses/${sha}`,
"--method",
"POST",
"-f",
`state=${check.state}`,
"-f",
`context=${check.context}`,
"-f",
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
]);
}
}
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
}
if (spec.state === "merged") {
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
} else if (spec.state === "closed") {
gh(["pr", "close", String(prNumber)], { cwd: basePath });
}
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
// Clean remote URL (no embedded token) so gh can parse owner/repo
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
git(["config", "user.email", "e2e@paseo.test"], localPath);
git(["config", "user.name", "Paseo E2E"], localPath);
git(["config", "commit.gpgsign", "false"], localPath);
return {
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
localPath,
};
}
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
const { spec, basePath } = args;
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
for (const label of spec.labels ?? []) {
createArgs.push("--label", label);
}
const issueUrl = gh(createArgs, { cwd: basePath });
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
if (spec.state === "closed") {
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
}
return { number: issueNumber, title: spec.title, url: issueUrl };
}
// Single namespace for temporary GitHub repos created by Paseo tests.
// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-`
// (collides with real repos like `paseo`, `paseo-website`).
const TEMP_GITHUB_REPO_PREFIX = "paseotmp-";
export async function createTempGithubRepo(options: {
category: string;
prs?: PrSpec[];
issues?: IssueSpec[];
}): Promise<GhRepoFixture> {
const { category, prs = [], issues = [] } = options;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
// Bootstrap local git repo
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
git(["init", "-b", "main"], basePath);
git(["config", "user.email", "e2e@paseo.test"], basePath);
git(["config", "user.name", "Paseo E2E"], basePath);
git(["config", "commit.gpgsign", "false"], basePath);
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
git(["add", "README.md"], basePath);
git(["commit", "-m", "Initial commit"], basePath);
// Create GitHub repo and push initial commit
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
const owner = gh(["api", "user", "--jq", ".login"]);
const fullName = `${owner}/${repoName}`;
const token = gh(["auth", "token"]);
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
// Switch remote to authed URL for subsequent pushes
git(["remote", "set-url", "origin", authedUrl], basePath);
// Create a branch + commit for each PR spec
const branches: string[] = [];
for (let i = 0; i < prs.length; i++) {
const branch = `pr-branch-${i + 1}`;
branches.push(branch);
git(["checkout", "-b", branch], basePath);
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
git(["add", `pr-${i + 1}.txt`], basePath);
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
git(["checkout", "main"], basePath);
}
if (branches.length > 0) {
git(["push", "origin", ...branches], basePath);
}
// Create PRs, seed checks/comments, apply state changes, clone workspaces
const prFixtures: GhPrFixture[] = [];
const localPaths: string[] = [];
for (let i = 0; i < prs.length; i++) {
const { fixture, localPath } = await seedPr({
spec: prs[i],
branch: branches[i],
index: i,
basePath,
authedUrl,
fullName,
repoName,
});
localPaths.push(localPath);
prFixtures.push(fixture);
}
// Create issues
const issueFixtures: GhIssueFixture[] = [];
for (const spec of issues) {
issueFixtures.push(seedIssue({ spec, basePath }));
}
return {
owner,
name: repoName,
fullName,
prs: prFixtures,
issues: issueFixtures,
cleanup: async () => {
try {
gh(["repo", "delete", fullName, "--yes"]);
} catch {
// Best-effort cleanup
}
await Promise.all([
rm(basePath, { recursive: true, force: true }),
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
]);
},
};
}

View File

@@ -68,6 +68,20 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
@@ -76,6 +90,11 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(_page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
@@ -100,7 +119,7 @@ export async function clickNewChat(page: Page): Promise<void> {
}
/** Click the new terminal button to create a terminal tab. */
export async function clickNewTerminal(page: Page): Promise<void> {
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
@@ -174,19 +193,6 @@ export async function sampleTabsDuringTransition(
return snapshots;
}
export function terminalSurfaceLocator(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
}
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
const tabTestId = `workspace-tab-agent_${agentId}`;
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
}
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */

View File

@@ -170,12 +170,9 @@ export async function openNewWorkspaceComposer(
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string },
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill(input.prompt ?? "Hello from e2e");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -205,45 +202,12 @@ export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function openBranchPicker(page: Page): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await page.keyboard.type(label);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
}
export async function closeBranchPicker(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function expectPickerOpen(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
}
export async function expectPickerClosed(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
timeout: 30_000,
});
}
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toContainText(label);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
@@ -286,110 +250,3 @@ export async function assertNewWorkspaceSidebarAndHeader(
return { workspaceId };
}
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
export interface AgentCreatedDelayControl {
release(): void;
waitForCreateRequest(): Promise<void>;
waitForDelayedCreatedStatus(): Promise<void>;
}
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 createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}

View File

@@ -1,17 +0,0 @@
import { expect, type Page } from "@playwright/test";
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
}
export async function allowPermission(page: Page): Promise<void> {
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
await acceptButton.click();
}
export async function denyPermission(page: Page): Promise<void> {
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5_000 });
await denyButton.click();
}

View File

@@ -1,42 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/git/pr-pane-data";
export async function openPrPane(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-pr").click();
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
}
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 });
}
export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise<void> {
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
timeout: 15_000,
});
}
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
const locator = page.getByTestId(testId);
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
if (count > 0) {
await expect(locator).toContainText(String(count));
}
}
export async function expectPrPaneCheckSummary(
page: Page,
counts: { passed: number; failed: number; pending: number },
): Promise<void> {
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
}
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
}

View File

@@ -1,269 +0,0 @@
import { chmod, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
// --- Navigation ---
export async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
}
// --- Form interactions ---
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
// action-0 is always "Try again"; action-1 is always "Reload".
// The write-failed callout renders these two buttons in a fixed order.
await page.getByTestId("write-failed-callout-action-0").click();
}
export async function clickReloadProjectSettings(page: Page): Promise<void> {
// Scope to the active error callout so the locator is unambiguous.
// At most one error callout renders at a time.
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
}
// --- Error-state assertions ---
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
const errorCalloutTestId: Record<ErrorKind, string> = {
stale: "stale-callout",
invalid: "invalid-callout",
write_failed: "write-failed-callout",
transport: "read-transport-callout",
read_failed: "read-failed-callout",
};
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
}
export async function expectNoProjectSettingsError(
page: Page,
kind: ErrorKind,
timeout = 15_000,
): Promise<void> {
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
}
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
}
// --- Form-state assertions ---
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 15_000,
});
}
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
}
export async function expectNoEditableTarget(page: Page): Promise<void> {
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
}
// --- Host-section assertions ---
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).toBeVisible();
}
export async function expectHostPickerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-picker")).not.toBeVisible();
}
// --- Script-list assertions and interactions ---
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
// prefix but contain "-menu-").
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
await expect(
page
.getByTestId("scripts-list")
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
).toHaveCount(count);
}
export async function expectEmptyScriptList(page: Page): Promise<void> {
await expect(page.getByText("No scripts yet.")).toBeVisible();
}
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
const row = page
.getByTestId("scripts-list")
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
.filter({ hasText: scriptName })
.first();
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
// from the row's testID to avoid scoped locator unreliability.
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
await page.getByTestId(`script-row-menu-${id}`).click();
page.once("dialog", (dialog) => void dialog.accept());
await page.getByRole("button", { name: "Remove" }).click();
}
// --- File manipulation ---
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
}
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
const configPath = path.join(repoPath, "paseo.json");
const raw = await readFile(configPath, "utf8");
const config = JSON.parse(raw) as Record<string, unknown>;
config._bump = Date.now();
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
}
export async function restorePaseoConfig(
repoPath: string,
config: Record<string, unknown>,
): Promise<void> {
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
}
// The daemon writes atomically via a temp file + rename, so blocking writes requires
// removing write permission from the *directory*, not just the file.
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
await chmod(repoPath, 0o555);
}
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
await chmod(repoPath, 0o755);
}
// --- 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) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (armed && typeof message === "string") {
try {
const envelope = JSON.parse(message) as {
type?: string;
message?: { type?: string };
};
if (
envelope.type === "session" &&
envelope.message?.type === "read_project_config_request"
) {
armed = false;
void ws.close({ code: 1001 });
return;
}
} catch {
// binary or malformed frame — pass through
}
}
try {
server.send(message);
} catch {
// server socket already closed
}
});
server.onMessage((message) => {
try {
ws.send(message);
} catch {
// client socket already closed
}
});
});
}
// Installs a transparent WS proxy that can later drop all active daemon connections
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
// in DaemonClient due to describeTransportClose returning a non-empty string.
export async function installDaemonConnectionGate(
page: Page,
): Promise<{ drop: () => Promise<void> }> {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1001 });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) return;
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) return;
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
},
};
}

View File

@@ -1,273 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { requireServerId } from "./sidebar";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const SECTION_LABELS = {
general: "General",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
diagnostics: "Diagnostics",
about: "About",
} as const;
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
if (section === "projects") {
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
return;
}
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
}
export async function openAddHostFlow(page: Page): Promise<void> {
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
}
export async function selectHostConnectionType(
page: Page,
type: "direct" | "relay",
): Promise<void> {
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
await page.getByRole("button", { name: label }).click();
}
export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
export async function openCompactSettings(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
}
export async function expectSettingsSidebarSections(
page: Page,
sections: Array<Exclude<SettingsSection, "projects">>,
): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
for (const section of sections) {
await expect(
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
).toBeVisible();
}
}
export async function goBackInSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
export async function expectSettingsBackButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
}
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
}
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
const serverId = requireServerId();
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expectHostSettingsUrl(page, serverId);
}
export async function openCompactSettingsHost(page: Page): Promise<void> {
const serverId = requireServerId();
await openSettingsHost(page, serverId);
await expectHostSettingsUrl(page, serverId);
}
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
}
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
await page.getByTestId("direct-host-uri-input").fill(uri);
}
export async function expectDirectHostFormValues(
page: Page,
fields: { host: string; port: string; password: string },
): Promise<void> {
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
}
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
}
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
}
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
}
export async function expectDiagnosticsContent(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
}
export async function expectAboutContent(page: Page): Promise<void> {
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
}
export async function expectGeneralContent(page: Page): Promise<void> {
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
}
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
export async function clickEditHostLabel(page: Page): Promise<void> {
await page.getByTestId("host-page-label-edit-button").click();
}
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
}
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
const card = page.getByTestId("host-page-connections-card");
await expect(card).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
await expect(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
}
export async function expectHostInjectMcpCard(page: Page): Promise<void> {
const card = page.getByTestId("host-page-inject-mcp-card");
await expect(card).toBeVisible();
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
export async function expectHostActionCards(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
}
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
await page.route(url, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
});
}
export async function openAddProviderModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Add provider", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
}
export async function findAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
await page.getByRole("textbox", { name: "Search providers" }).fill(providerName);
await expect(page.getByText(providerName, { exact: true })).toBeVisible();
}
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
await findAcpCatalogProvider(page, providerName);
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
}
export async function expectProviderInstalledInSettings(
page: Page,
providerName: string,
): Promise<void> {
await expect(
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
).toBeVisible();
}
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
}
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
}
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
}

View File

@@ -1,39 +0,0 @@
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;
}
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
await expect(
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click();
}
// force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click({ force: true });
}
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
}
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}

View File

@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
@@ -263,34 +263,6 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
);
}
export async function expectTerminalSurfaceVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.locator('[data-testid="terminal-surface"]').first()).toBeVisible({
timeout: options?.timeout ?? 20_000,
});
}
export async function focusTerminalSurface(page: Page): Promise<void> {
await expectTerminalSurfaceVisible(page);
await page.locator('[data-testid="terminal-surface"]').first().click();
}
export async function typeInTerminal(page: Page, text: string): Promise<void> {
await page
.locator('[data-testid="terminal-surface"]')
.first()
.pressSequentially(text, { delay: 0 });
}
export async function waitForTerminalAttached(page: Page): Promise<void> {
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => undefined);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;

View File

@@ -1,105 +0,0 @@
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 {
connectWorkspaceSetupClient,
openHomeWithProject,
type WorkspaceSetupDaemonClient,
} from "./workspace-setup";
export interface CreatedWorkspace {
workspaceId: string;
repoPath: string;
navigateTo(): Promise<void>;
}
export interface WithWorkspaceOptions {
worktree?: boolean;
prefix?: string;
}
export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise<CreatedWorkspace>;
interface WorktreeRecord {
repoPath: string;
worktreePath: string;
}
export interface WithWorkspaceHandle {
withWorkspace: WithWorkspace;
cleanup: () => Promise<void>;
}
export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let client: WorkspaceSetupDaemonClient | null = null;
const repos: Array<{ cleanup: () => Promise<void> }> = [];
const worktrees: WorktreeRecord[] = [];
const withWorkspace: WithWorkspace = async (options) => {
if (!client) {
client = await connectWorkspaceSetupClient();
}
const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-");
const repo = await createTempGitRepo(prefix);
repos.push(repo);
let workspacePath = repo.path;
if (options?.worktree) {
const tempRoot = await realpath("/tmp");
workspacePath = path.join(
tempRoot,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `paseo-wt-${Date.now()}`;
execSync(
`git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`,
{ cwd: repo.path, stdio: "ignore" },
);
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
// Register the parent project so the sidebar lists it before we navigate.
await client.openProject(repo.path);
}
const opened = await client.openProject(workspacePath);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
}
const workspaceId = opened.workspace.id;
return {
workspaceId,
repoPath: workspacePath,
navigateTo: async () => {
await openHomeWithProject(page, repo.path);
await selectWorkspaceInSidebar(page, workspaceId);
await waitForTabBar(page);
},
};
};
return {
withWorkspace,
cleanup: async () => {
for (const { repoPath, worktreePath } of worktrees) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repoPath,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so the original test failure is preserved.
}
}
for (const repo of repos) {
await repo.cleanup();
}
if (client) {
await client.close().catch(() => undefined);
}
},
};
}

View File

@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { clickNewChat, clickNewTerminal } from "./launcher";
import { clickNewChat, clickTerminal } from "./launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
@@ -20,7 +20,7 @@ export async function expectTerminalCwd(page: Page, expectedPath: string): Promi
}
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
await clickNewTerminal(page);
await clickTerminal(page);
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
}

View File

@@ -6,7 +6,6 @@ import { expect, type Page } from "@playwright/test";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { switchWorkspaceViaSidebar } from "./workspace-ui";
import type { SessionOutboundMessage } from "@server/shared/messages";
interface WorkspaceSetupDaemonClient {
@@ -45,7 +44,7 @@ interface WorkspaceSetupDaemonClient {
}>;
fetchAgent(agentId: string): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown;
project: unknown | null;
} | null>;
listTerminals(cwd: string): Promise<{
cwd?: string;
@@ -316,30 +315,6 @@ export async function fetchWorkspaceById(
return workspace;
}
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 });
}
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
}
export async function startWorkspaceScriptFromMenu(page: Page, scriptName: string): Promise<void> {
await page.getByTestId(`workspace-scripts-start-${scriptName}`).click();
}
export async function closeWorkspaceScriptsMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-scripts-menu-backdrop").click();
}
export async function waitForWorkspaceSetupProgress(
client: WorkspaceSetupDaemonClient,
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,

View File

@@ -70,29 +70,6 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
}
}
export async function expectWorkspaceTabsAbsent(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
}
export async function expectNoTerminalTabs(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
}
export async function clickFirstTerminalTab(
page: Page,
options?: { timeout?: number },
): Promise<void> {
const tab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(tab).toBeVisible({ timeout: options?.timeout ?? 30_000 });
await tab.click();
}
export async function expectFirstTerminalTabContains(page: Page, text: string): Promise<void> {
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]').first()).toContainText(
text,
);
}
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {},

View File

@@ -114,49 +114,6 @@ export async function expectWorkspaceHeader(
});
}
export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: options?.timeout ?? 30_000,
});
}
export async function expectReconnectingToastGone(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: options?.timeout ?? 30_000,
});
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
}
export async function expectMenuButtonVisible(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button")).toBeVisible();
}
export async function expectWorkspaceHeaderAbsent(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
}
export function workspaceDeckEntryLocator(page: Page, serverId: string, workspaceId: string) {
return page.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`);
}
export async function expectWorkspaceDeckEntryCount(page: Page, count: number): Promise<void> {
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(count);
}
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable({ timeout: 30_000 });

View File

@@ -5,18 +5,16 @@ import {
assertNewChatTileVisible,
assertTerminalTileVisible,
assertSingleNewTabButton,
clickNewTabButton,
pressNewTabShortcut,
clickNewChat,
clickNewTerminal,
clickTerminal,
countTabsOfKind,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
terminalSurfaceLocator,
} from "./helpers/launcher";
import { expectComposerVisible, composerLocator } from "./helpers/composer";
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
import {
connectTerminalClient,
setupDeterministicPrompt,
@@ -52,7 +50,9 @@ test.describe("Tab creation", () => {
await pressNewTabShortcut(page);
await expectComposerVisible(page);
// Should show the composer directly (no launcher panel)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
@@ -77,9 +77,11 @@ test.describe("Tab creation", () => {
test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await clickNewTabButton(page);
await expectComposerVisible(page);
// Draft composer should appear (the agent message input)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
const tabsAfter = await getTabTestIds(page);
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
@@ -90,9 +92,11 @@ test.describe("Tab creation", () => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspaceId);
await clickNewTerminal(page);
await clickTerminal(page);
await expectTerminalSurfaceVisible(page);
// Terminal surface should appear
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
const tabsAfter = await getTabTestIds(page);
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
@@ -137,18 +141,19 @@ test.describe("Terminal title propagation", () => {
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspaceId);
await clickNewTerminal(page);
await clickTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
delay: 0,
});
await terminal
.first()
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
@@ -166,24 +171,24 @@ test.describe("Terminal title propagation", () => {
try {
await gotoWorkspace(page, workspaceId);
await clickNewTerminal(page);
await clickTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Fire many rapid title changes — only the last should stick
const finalTitle = `Final-${Date.now()}`;
for (let i = 0; i < 5; i++) {
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
delay: 0,
});
await terminal
.first()
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
}
await terminalSurfaceLocator(page).pressSequentially(
`printf '\\033]0;${finalTitle}\\007'\n`,
{ delay: 0 },
);
await terminal
.first()
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
@@ -223,10 +228,11 @@ test.describe("Tab transitions (no flash)", () => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspaceId);
const terminal = page.locator('[data-testid="terminal-surface"]');
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
() => clickTerminal(page),
terminal.first(),
20_000,
);
@@ -239,12 +245,9 @@ test.describe("Tab transitions (no flash)", () => {
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion

View File

@@ -1,29 +1,22 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
closeBranchPicker,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
delayBrowserAgentCreatedStatus,
expectComposerGithubAttachmentPill,
expectPickerClosed,
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
@@ -35,6 +28,108 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
async function delayBrowserAgentCreatedStatus(page: Page) {
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 createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -306,10 +401,6 @@ test.describe("New workspace flow", () => {
projectDisplayName: openedProject.projectDisplayName,
});
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill("Hello from e2e");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -414,55 +505,6 @@ test.describe("New workspace flow", () => {
}
});
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openBranchPicker(page);
await expectPickerOpen(page);
await selectPickerOptionByKeyboard(page, "dev");
await expectPickerSelected(page, "dev");
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
const tempRepo = await createTempGitRepo("picker-escape-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openBranchPicker(page);
await expectPickerOpen(page);
await closeBranchPicker(page);
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");

View File

@@ -1,124 +0,0 @@
import { test } from "./fixtures";
import {
openPrPane,
expectPrPaneTitle,
expectPrPaneState,
expectPrPaneCheckSummary,
expectPrPaneActivityCount,
} from "./helpers/pr-pane";
import { gotoWorkspace } from "./helpers/launcher";
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
const GITHUB_AUTH = hasGithubAuth();
test.describe("PR pane", () => {
test.describe.configure({ retries: 1 });
let seedClient: WorkspaceSetupDaemonClient;
let repoFixture: GhRepoFixture;
const workspaceByTitle = new Map<string, string>();
test.beforeAll(async () => {
if (!GITHUB_AUTH) return;
seedClient = await connectWorkspaceSetupClient();
repoFixture = await createTempGithubRepo({
category: "pr-pane",
prs: [
{ title: "Review selected start ref", state: "open" },
{ title: "Merged feature branch", state: "merged" },
{ title: "Closed without merge", state: "closed" },
{ title: "Work in progress", state: "draft" },
{
title: "PR with mixed checks",
state: "open",
checks: [
{ context: "build-1", state: "success" },
{ context: "build-2", state: "success" },
{ context: "deploy", state: "failure" },
{ context: "security", state: "pending" },
],
},
{ title: "PR with reviews", state: "open", commentCount: 3 },
{ title: "PR with no checks", state: "open" },
],
});
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);
}
});
test.afterAll(async () => {
await repoFixture?.cleanup().catch(() => undefined);
await seedClient?.close().catch(() => undefined);
});
test.beforeEach(async () => {
test.skip(!GITHUB_AUTH, "Requires GitHub authentication (gh auth login)");
test.setTimeout(60_000);
});
test("renders an open PR with title, state, and repo line", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Review selected start ref")!);
await openPrPane(page);
await expectPrPaneTitle(page, "Review selected start ref");
await expectPrPaneState(page, "open");
});
test("renders merged state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Merged feature branch")!);
await openPrPane(page);
await expectPrPaneState(page, "merged");
await expectPrPaneTitle(page, "Merged feature branch");
});
test("renders closed state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Closed without merge")!);
await openPrPane(page);
await expectPrPaneState(page, "closed");
await expectPrPaneTitle(page, "Closed without merge");
});
test("renders draft state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Work in progress")!);
await openPrPane(page);
await expectPrPaneState(page, "draft");
await expectPrPaneTitle(page, "Work in progress");
});
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
});
test("renders activity rows with correct count", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
await openPrPane(page);
await expectPrPaneActivityCount(page, 3);
});
test("renders gracefully with zero checks", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
await expectPrPaneTitle(page, "PR with no checks");
});
});

View File

@@ -1,36 +1,9 @@
import { chmod, readFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base } from "./fixtures";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectHostIndicatorVisible,
expectHostPickerHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectProjectSettingsError,
expectProjectSettingsFormHidden,
expectProjectSettingsFormVisible,
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
installDaemonConnectionGate,
installReadTransportFailure,
navigateToProjectSettings,
openProjectSettings,
openProjects,
removeProjectScript,
restorePaseoConfig,
unblockPaseoConfigWrites,
} from "./helpers/project-settings";
const updatedSetup = ["npm install", "npm run build"];
@@ -75,9 +48,6 @@ const test = base.extend<ProjectsSettingsFixtures>({
});
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();
},
gitlabRemoteProject: async ({ page: _page }, provide) => {
@@ -98,6 +68,30 @@ const test = base.extend<ProjectsSettingsFixtures>({
},
});
async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
async function saveProjectConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
await expect
.poll(
@@ -139,7 +133,7 @@ test.describe("Projects settings", () => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
@@ -151,138 +145,7 @@ test.describe("Projects settings", () => {
await openProjects(page);
await openProjectSettings(page, gitlabRemoteProject.name);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await saveProjectConfig(page);
await expectProjectConfigSaved(gitlabRemoteProject);
});
});
test.describe("Projects settings — error UX", () => {
test("stale-write callout appears on save, disables save, and reload clears it", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
// Bump the file on disk so the daemon detects a revision mismatch on save.
await bumpPaseoConfigOnDisk(editableProject.path);
await clickSaveProjectSettings(page);
await expectProjectSettingsError(page, "stale");
await expectSaveButtonDisabled(page);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "stale");
await expectProjectSettingsFormVisible(page);
});
test("invalid paseo.json shows read-error callout, reload after fix shows form", async ({
page,
editableProject,
}) => {
await corruptPaseoConfig(editableProject.path);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
await expectProjectSettingsError(page, "invalid");
await expectProjectSettingsFormHidden(page);
// Restore a valid config so the reload succeeds.
await restorePaseoConfig(editableProject.path, initialPaseoConfig);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "invalid");
await expectProjectSettingsFormVisible(page);
});
test("write_failed callout appears on save with blocked directory, retry re-attempts, reload clears it", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await blockPaseoConfigWrites(editableProject.path);
await clickSaveProjectSettings(page);
await expectProjectSettingsError(page, "write_failed");
await expectWriteFailedCalloutActions(page);
await clickRetryProjectSettingsSave(page);
await expectProjectSettingsError(page, "write_failed");
await unblockPaseoConfigWrites(editableProject.path);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "write_failed");
await expectProjectSettingsFormVisible(page);
});
test("read-transport failure shows callout, reload recovers", async ({
page,
editableProject,
}) => {
// Drop the WS connection the moment a read_project_config_request is sent.
// Subsequent connections are proxied transparently so Reload can succeed.
await installReadTransportFailure(page);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
await expectProjectSettingsError(page, "transport");
await expectProjectSettingsFormHidden(page);
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
await expect(async () => {
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "transport", 3_000);
}).toPass({ timeout: 15_000 });
await expectProjectSettingsFormVisible(page);
});
test("project settings shows no-target state when daemon connection drops", async ({
page,
editableProject,
}) => {
const gate = await installDaemonConnectionGate(page);
await openProjects(page);
await openProjectSettings(page, editableProject.name);
// Closing with code 1001 (Going Away) transitions DaemonClient to "error" state.
// The NoEditableTarget UI renders via isHostGone check regardless of state.
await gate.drop();
await expectNoEditableTarget(page);
});
test("single-host project renders static host indicator, not a picker chip", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectHostIndicatorVisible(page);
await expectHostPickerHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
});
});

View File

@@ -1,20 +1,6 @@
import { test } from "./fixtures";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
import {
expectSettingsHeader,
openSettingsHost,
expectHostLabelDisplayed,
clickEditHostLabel,
expectHostLabelEditMode,
expectHostConnectionsCard,
expectHostInjectMcpCard,
expectHostActionCards,
expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
expectLocalHostEntryFirst,
} from "./helpers/settings";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
@@ -32,6 +18,17 @@ function getSeededDaemonPort(): string {
return port;
}
async function openHostPage(page: Page, serverId: string) {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
async function expectHostLabelHeader(page: Page) {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
@@ -41,13 +38,28 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostPage(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectHostConnectionsCard(page, port);
await expectHostInjectMcpCard(page);
await expectHostActionCards(page);
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
await expectHostLabelHeader(page);
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
const connectionsCard = page.getByTestId("host-page-connections-card");
await expect(connectionsCard).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
await expect(
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
await expect(injectMcpCard).toBeVisible();
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
@@ -55,11 +67,15 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostPage(page, serverId);
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
await page.getByTestId("host-page-label-edit-button").click();
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
});
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
@@ -69,17 +85,31 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostPage(page, serverId);
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
// Pair-device now lives behind a row that only the local host sees
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
// not appear. The daemon-lifecycle card is still local-host only.
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
});
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
});
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
@@ -90,10 +120,9 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectHostActionCards(page);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expectHostLabelHeader(page);
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
@@ -135,6 +164,18 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await expectLocalHostEntryFirst(page, serverId);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15000 });
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
await expect(hostEntries.first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
});
});

View File

@@ -1,60 +1,62 @@
import { test, expect } from "./fixtures";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
openSettingsSection,
expectSettingsHeader,
openAddHostFlow,
selectHostConnectionType,
toggleHostAdvanced,
openCompactSettings,
expectCompactSettingsList,
expectSettingsSidebarVisible,
expectSettingsSidebarHidden,
expectSettingsSidebarSections,
goBackInSettings,
expectSettingsBackButton,
clickSettingsBackToWorkspace,
verifyLegacyHostSettingsRedirect,
openCompactSettingsHost,
expectAddHostMethodOptions,
fillDirectHostUri,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostUriValue,
expectDirectHostUriHidden,
expectDiagnosticsContent,
expectAboutContent,
expectGeneralContent,
} from "./helpers/settings";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
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;
}
async function clickSidebarSection(page: Page, label: string) {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await sidebar.getByRole("button", { name: label, exact: true }).click();
}
test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "diagnostics");
await expectSettingsHeader(page, "Diagnostics");
await expectDiagnosticsContent(page);
await clickSidebarSection(page, "Diagnostics");
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
await openSettingsSection(page, "about");
await expectSettingsHeader(page, "About");
await expectAboutContent(page);
await clickSidebarSection(page, "About");
await expect(page).toHaveURL(/\/settings\/about$/);
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
await openSettingsSection(page, "general");
await expectSettingsHeader(page, "General");
await expectGeneralContent(page);
await clickSidebarSection(page, "General");
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
});
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
});
test("the + Add host button opens the add-host method modal", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await expectAddHostMethodOptions(page);
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("direct connection advanced URI round-trips SSL and password into the form", async ({
@@ -62,34 +64,38 @@ test.describe("Settings sidebar navigation", () => {
}) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await toggleHostAdvanced(page);
await page.getByTestId("settings-add-host").click();
await page.getByRole("button", { name: "Direct connection" }).click();
await expectDirectHostFormValues(page, {
host: "example.paseo.test",
port: "7443",
password: "shared-secret",
});
await expectDirectHostSslEnabled(page);
await expectDirectHostUriHidden(page);
await page.getByTestId("direct-host-advanced-toggle").click();
await page
.getByTestId("direct-host-uri-input")
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await page.getByTestId("direct-host-advanced-toggle").click();
await toggleHostAdvanced(page);
await expectDirectHostUriValue(
page,
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
);
await toggleHostAdvanced(page);
await expectDirectHostUriHidden(page);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
const backRow = page.getByTestId("settings-back-to-workspace");
await expect(backRow).toBeVisible();
await backRow.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
});
@@ -97,57 +103,113 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
async function openCompactSettingsRoot(page: Page) {
await gotoAppShell(page);
await openCompactSettings(page);
// Wait for bootstrap to settle on a host route so storeReady is true before
// we navigate into the protected settings stack. A direct page.goto("/settings")
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
await expectCompactSettingsList(page);
// Drive navigation the same way a user would: open the mobile drawer and tap
// the Settings footer icon. This preserves the in-app router state instead of
// triggering a full reload through Stack.Protected.
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(sidebarSettingsButton).toBeVisible();
await sidebarSettingsButton.click();
await expectSettingsBackButton(page);
await goBackInSettings(page);
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
async function expectCompactSettingsRootList(page: Page) {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await openCompactSettingsRoot(page);
// Sidebar rows are present.
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
).toBeVisible();
await expect(
page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true }),
).toBeVisible();
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
).toBeVisible();
// Section detail content is NOT rendered at the root.
await expectCompactSettingsRootList(page);
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
await expect(rootBackButton).toBeVisible();
await rootBackButton.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettingsRoot(page);
await openSettingsSection(page, "diagnostics");
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expectDiagnosticsContent(page);
await expectSettingsSidebarHidden(page);
await expectSettingsBackButton(page);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
// Sidebar is no longer visible — we are on a detail screen.
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
// only visible instances.)
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("back from a section detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettingsRoot(page);
await openSettingsSection(page, "about");
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "About", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/about$/);
await goBackInSettings(page);
await expectCompactSettingsList(page);
await expectSettingsBackButton(page);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expectCompactSettingsRootList(page);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
const serverId = getServerId();
await openCompactSettingsRoot(page);
await openCompactSettingsHost(page);
await expectSettingsBackButton(page);
await expectSettingsSidebarHidden(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
});
test("back from a host detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
const serverId = getServerId();
await openCompactSettingsRoot(page);
await openCompactSettingsHost(page);
await goBackInSettings(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
});
});

View File

@@ -1,12 +1,12 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
@@ -17,15 +17,12 @@ function getServerId(): string {
return serverId;
}
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
async function pressSettingsToggleShortcut(page: Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
}
async function expectSendBehavior(
page: import("@playwright/test").Page,
expected: "interrupt" | "queue",
) {
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
@@ -38,11 +35,12 @@ async function expectSendBehavior(
}
async function openAgentRouteAndExpectFocused(input: {
page: import("@playwright/test").Page;
page: Page;
serverId: string;
workspaceId: string;
agentId: string;
}) {
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
await input.page.goto(
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
);
@@ -51,7 +49,10 @@ async function openAgentRouteAndExpectFocused(input: {
{ timeout: 60_000 },
);
await waitForTabBar(input.page);
await expectAgentTabActive(input.page, input.agentId);
await expect(
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
}
test.describe("Settings toggle tab regression", () => {
@@ -78,7 +79,13 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(/\/settings\/general$/);
@@ -91,11 +98,19 @@ test.describe("Settings toggle tab regression", () => {
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await page.reload();
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
@@ -137,10 +152,14 @@ test.describe("Settings toggle tab regression", () => {
agentId: secondAgent.id,
});
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
await page.reload();
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
await expect(
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
}
} finally {
for (const agentId of agentIds) {

View File

@@ -4,12 +4,6 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
@@ -186,16 +180,3 @@ test.describe("Sidebar workspace list", () => {
}
});
});
test.describe("Mobile sidebar panelState transition", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("showMobileAgent open and close transition", async ({ page }) => {
await gotoAppShell(page);
await expectMobileAgentSidebarHidden(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
});
});

View File

@@ -1,120 +1,129 @@
import { expect, type Page, test } from "./fixtures";
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
typeInTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
} from "./helpers/terminal-perf";
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
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);
};
});
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 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);
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({
page,
withWorkspace,
}) => {
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
await workspace.navigateTo();
await clickNewTerminal(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-main-");
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = workspaceResult.workspace.id;
// Use sidebar navigation to avoid Expo Router hydration issues
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
await installCreateAgentRequestRecorder(page);
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
await workspace.navigateTo();
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(async () => getRecordedCreateAgentCwd(page, message), { timeout: 30_000 })
.toBe(workspace.repoPath);
});
test("worktree workspace opens terminals in the worktree directory", async ({
page,
withWorkspace,
}) => {
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
await workspace.navigateTo();
await clickNewTerminal(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-worktree-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `workspace-cwd-${Date.now()}`;
let worktreeCreated = false;
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = workspaceResult.workspace.id;
// Use sidebar navigation to avoid Expo Router hydration issues
// with direct URL navigation to the 2nd+ workspace.
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -1,9 +1,39 @@
import { test } from "./fixtures";
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
expectTerminalCwd,
} from "./helpers/workspace-lifecycle";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace lifecycle", () => {
// The first test after a spec-file switch can intermittently fail because
@@ -12,36 +42,154 @@ test.describe("Workspace lifecycle", () => {
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
await workspace.navigateTo();
await createAgentChatFromLauncher(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-chat-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-shell-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, repo.path);
} finally {
await client.close();
await repo.cleanup();
}
});
});
test.describe("Worktree workspace", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
await workspace.navigateTo();
await createAgentChatFromLauncher(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-chat-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-chat-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-shell-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-shell-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, worktreePath);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
});

View File

@@ -1,7 +1,7 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
@@ -15,28 +15,18 @@ import {
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { expectComposerVisible } from "./helpers/composer";
import { createTempGitRepo } from "./helpers/workspace";
import {
getVisibleWorkspaceAgentTabIds,
expectOnlyWorkspaceAgentTabsVisible,
waitForWorkspaceTabsVisible,
expectWorkspaceTabsAbsent,
} from "./helpers/workspace-tabs";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
expectWorkspaceHeaderAbsent,
expectMenuButtonVisible,
expectHostConnectingOrOffline,
expectReconnectingToastVisible,
expectReconnectingToastGone,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
workspaceDeckEntryLocator,
expectWorkspaceDeckEntryCount,
} from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -72,22 +62,6 @@ async function expectNoLoadingWorkspacePane(
}
}
async function expectNoLoadingPane(page: Page): Promise<void> {
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
}
async function getVisibleDraftTabCount(page: Page): Promise<number> {
return page.locator('[data-testid^="workspace-tab-draft"]').filter({ visible: true }).count();
}
async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
const closeButton = page.locator('[data-testid^="workspace-draft-close-"]').filter({
visible: true,
});
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
await closeButton.first().click();
}
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
@@ -146,25 +120,6 @@ async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "workspace-settings-back-tab-" });
await workspace.navigateTo();
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await closeFirstVisibleDraftTab(page);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
});
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;
@@ -204,31 +159,35 @@ test.describe("Workspace navigation regression", () => {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await waitForWorkspaceTabsVisible(page);
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
await expectWorkspaceTabVisible(page, agent.id);
await daemonGate.drop();
await expectReconnectingToastVisible(page);
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await waitForWorkspaceTabsVisible(page);
await expectComposerVisible(page);
await expectNoLoadingPane(page);
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
label: "host reconnect",
});
daemonGate.restore();
await expectReconnectingToastGone(page);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: 30_000,
});
await monitorReconnect;
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await waitForWorkspaceTabsVisible(page);
await expectComposerVisible(page);
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
} finally {
daemonGate.restore();
for (const agentId of agentIds) {
@@ -263,56 +222,19 @@ test.describe("Workspace navigation regression", () => {
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
await expectHostConnectingOrOffline(page);
await expectMenuButtonVisible(page);
await expectWorkspaceHeaderAbsent(page);
await expectWorkspaceTabsAbsent(page);
await openSettings(page);
await expect(
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("menu-button")).toBeVisible();
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
await settingsButton.click();
await expect(page).toHaveURL(/\/settings\/general$/);
});
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 workspaceClient = await connectNewWorkspaceDaemonClient();
const workspaceIds = new Set<string>();
const firstRepo = await createTempGitRepo("workspace-cold-url-a-");
const secondRepo = await createTempGitRepo("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), {
timeout: 30_000,
});
const secondRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${secondWorkspace.workspaceId}`,
);
await expect(secondRow).toBeVisible({ timeout: 30_000 });
await secondRow.click();
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
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();
}
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
@@ -348,13 +270,6 @@ test.describe("Workspace navigation regression", () => {
await waitForSidebarHydration(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
const firstDeckEntry = workspaceDeckEntryLocator(page, serverId, firstWorkspace.workspaceId);
const secondDeckEntry = workspaceDeckEntryLocator(
page,
serverId,
secondWorkspace.workspaceId,
);
await switchWorkspaceViaSidebar({
page,
serverId,
@@ -385,6 +300,13 @@ test.describe("Workspace navigation regression", () => {
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${firstAgent.id}`,
]);
const firstDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
);
const secondDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
);
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await switchWorkspaceViaSidebar({
@@ -420,7 +342,7 @@ test.describe("Workspace navigation regression", () => {
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expectWorkspaceDeckEntryCount(page, 2);
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.evaluate(
({ agentId, serverId: targetServerId }) => {
@@ -449,7 +371,7 @@ test.describe("Workspace navigation regression", () => {
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await switchWorkspaceViaSidebar({
page,
@@ -463,7 +385,7 @@ test.describe("Workspace navigation regression", () => {
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(secondDeckEntry).toBeAttached();
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.reload();
await waitForSidebarHydration(page);

View File

@@ -1,16 +1,32 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { clickNewTerminal } from "./helpers/launcher";
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
findWorktreeWorkspaceForProject,
navigateToWorkspaceViaSidebar,
openHomeWithProject,
} from "./helpers/workspace-setup";
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 navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace setup runtime authority", () => {
test.describe.configure({ retries: 1 });
@@ -32,6 +48,7 @@ test.describe("Workspace setup runtime authority", () => {
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
// Navigate to the workspace via sidebar
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
@@ -69,8 +86,10 @@ test.describe("Workspace setup runtime authority", () => {
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
// Verify terminal is listed under the worktree directory, not the original repo
await expect

View File

@@ -1,28 +1,23 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
waitForWorkspaceTabsVisible,
expectNoTerminalTabs,
clickFirstTerminalTab,
expectFirstTerminalTabContains,
} from "./helpers/workspace-tabs";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { openFileExplorer, expectExplorerEntryVisible } from "./helpers/file-explorer";
import { expectTerminalSurfaceVisible, waitForTerminalAttached } from "./helpers/terminal-perf";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
navigateToWorkspaceViaSidebar,
openWorkspaceScriptsMenu,
startWorkspaceScriptFromMenu,
closeWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
interface WorkspaceScriptStarter {
startWorkspaceScript(
workspaceId: string,
@@ -35,6 +30,18 @@ interface WorkspaceScriptStarter {
}>;
}
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
test.describe("Workspace setup streaming", () => {
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
@@ -93,15 +100,31 @@ test.describe("Workspace setup streaming", () => {
});
await completed;
// Navigate to workspace and verify it's usable
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await expectComposerVisible(page, { timeout: 30_000 });
await openFileExplorer(page);
await expectExplorerEntryVisible(page, "README.md");
await expectExplorerEntryVisible(page, "src");
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
await explorerToggle.click();
}
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
timeout: 30_000,
});
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await client.close();
await repo.cleanup();
@@ -250,14 +273,33 @@ test.describe("Workspace setup streaming", () => {
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await expectNoTerminalTabs(page);
await openWorkspaceScriptsMenu(page);
await startWorkspaceScriptFromMenu(page, "web");
await closeWorkspaceScriptsMenu(page);
await clickFirstTerminalTab(page);
await expectTerminalSurfaceVisible(page, { timeout: 10_000 });
await waitForTerminalAttached(page);
await expectFirstTerminalTabContains(page, "web");
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("workspace-scripts-start-web").click();
await page.getByTestId("workspace-scripts-menu-backdrop").click();
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
await terminalTab.click();
// Verify the terminal surface rendered
const terminalSurface = page.getByTestId("terminal-surface").first();
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
// Wait for terminal to fully attach (loading overlay gone)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
await expect(terminalTab).toContainText("web");
} finally {
await client.close();
await repo.cleanup();

View File

@@ -25,20 +25,17 @@ platform :ios do
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
valid_build = nil
deadline = Time.now + (30 * 60)
loop do
builds = Spaceship::ConnectAPI::Build.all(
app_id: app.id,
build_number: build_number.to_s,
filter: { "version" => build_number.to_s },
includes: "preReleaseVersion",
)
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
state = build&.processing_state
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
if state == "VALID"
valid_build = build
break
end
break if state == "VALID"
if state == "INVALID" || state == "FAILED"
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
end
@@ -48,30 +45,9 @@ platform :ios do
sleep(30)
end
app_version = valid_build.pre_release_version.version
UI.message("Submitting build #{build_number} as App Store version #{app_version} for review")
# Ensure an editable App Store version exists for this marketing version.
# deliver requires a "Prepare for Submission" version on App Store Connect
# and won't auto-create one when skip_metadata + skip_screenshots are set.
app.ensure_version!(app_version, platform: Spaceship::ConnectAPI::Platform::IOS)
# Apple requires a non-empty "What's New" on every new App Store version
# before it can be submitted for review. With skip_metadata: true, deliver
# never sets this, so we populate it directly per-locale.
edit_version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
release_notes = "Bug fixes and improvements."
edit_version.get_app_store_version_localizations.each do |localization|
if localization.whats_new.to_s.strip.empty?
UI.message("Setting whatsNew on #{localization.locale}")
localization.update(attributes: { whatsNew: release_notes })
end
end
deliver(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
app_version: app_version,
build_number: build_number.to_s,
submit_for_review: true,
automatic_release: true,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.71",
"version": "0.1.67",
"private": true,
"main": "index.ts",
"scripts": {
@@ -115,7 +115,7 @@
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
"wrangler": "^4.75.0",
"wrangler": "^4.59.1",
"ws": "^8.20.0"
}
}

View File

@@ -3,7 +3,13 @@ import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import {
createContext,
type ReactNode,
@@ -59,18 +65,23 @@ import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostMutations,
useHostRuntimeClient,
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
@@ -83,12 +94,13 @@ import {
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
polyfillCrypto();
@@ -115,8 +127,25 @@ function PushNotificationRouter() {
const serverId = target.serverId;
const agentId = target.agentId;
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
return;
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId);
const workspaceId =
target.workspaceId ??
resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces.values(),
workspaceDirectory: agent?.cwd,
});
if (workspaceId) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
pin: true,
});
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
return;
}
}
router.navigate(buildNotificationRoute(data));
@@ -307,8 +336,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const waitForConfiguredLocalDaemon =
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
useEffect(() => {
@@ -316,7 +343,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
anyOnlineHostServerId ||
daemonStartError ||
daemonStartIsRunning ||
waitForConfiguredLocalDaemon ||
hasGivenUpWaitingForHost
) {
return;
@@ -327,13 +353,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
clearTimeout(handle);
};
}, [
anyOnlineHostServerId,
daemonStartError,
daemonStartIsRunning,
waitForConfiguredLocalDaemon,
hasGivenUpWaitingForHost,
]);
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
@@ -843,6 +863,28 @@ function RootStack() {
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
useEffect(() => {
syncNavigationActiveWorkspace(navigationRef);
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef);
});
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
syncNavigationActiveWorkspace(navigationRef);
});
return () => {
unsubscribeBrowserLocation();
unsubscribeState();
unsubscribeReady();
};
}, [navigationRef]);
return null;
}
function AppShell() {
return (
<SidebarAnimationProvider>
@@ -869,20 +911,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
);
}
// PortalProvider must remain the innermost global provider here.
// `@gorhom/portal` renders portaled children at the host's location in the
// tree, so any context a portaled sheet might consume (QueryClient, theme,
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
// Adding a new global provider? Put it above PortalProvider.
function RootProviders({ children }: { children: ReactNode }) {
return (
<QueryProvider>
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>{children}</PortalProvider>
<QueryProvider>{children}</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</QueryProvider>
</PortalProvider>
);
}
@@ -890,6 +927,7 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />

View File

@@ -1,12 +1,12 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
return (
@@ -18,7 +18,6 @@ export default function HostAgentReadyRoute() {
function HostAgentReadyRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{
serverId?: string;
agentId?: string;
@@ -51,14 +50,15 @@ function HostAgentReadyRouteContent() {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToPreparedWorkspaceTab({
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
currentPathname: pathname,
});
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as Href,
);
}
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -102,12 +102,13 @@ function HostAgentReadyRouteContent() {
}
redirectedRef.current = true;
if (workspaceId) {
navigateToPreparedWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
currentPathname: pathname,
});
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
}) as Href,
);
return;
}
router.replace(buildHostRootRoute(serverId));
@@ -124,7 +125,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]);
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
return null;
}

View File

@@ -0,0 +1,258 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import {
useGlobalSearchParams,
useLocalSearchParams,
useRouter,
useRootNavigationState,
} from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import {
activateNavigationWorkspaceSelection,
type ActiveWorkspaceSelection,
useNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === "agent") {
return { kind: "agent", agentId: openIntent.agentId };
}
if (openIntent.kind === "terminal") {
return { kind: "terminal", terminalId: openIntent.terminalId };
}
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
if (openIntent.kind === "setup") {
return { kind: "setup", workspaceId: openIntent.workspaceId };
}
return { kind: "draft", draftId: openIntent.draftId };
}
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("open")) {
return;
}
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
function clearConsumedOpenIntent(input: {
navigation: { setParams: (params: { open?: string | undefined }) => void };
router: ReturnType<typeof useRouter>;
serverId: string;
workspaceId: string;
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
stripOpenSearchParamFromBrowserUrl();
}
}
export default function HostWorkspaceLayout() {
return (
<HostRouteBootstrapBoundary>
<HostWorkspaceLayoutContent />
</HostRouteBootstrapBoundary>
);
}
function HostWorkspaceLayoutContent() {
const navigation = useNavigation();
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const routeWorkspaceSelection = useMemo(
() =>
serverId && workspaceId
? {
serverId,
workspaceId,
}
: null,
[serverId, workspaceId],
);
useEffect(() => {
if (!routeWorkspaceSelection) {
return;
}
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
}, [routeWorkspaceSelection]);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
}, [
hasHydratedWorkspaceLayoutStore,
navigation,
openValue,
rootNavigationState?.key,
router,
serverId,
workspaceId,
]);
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
return null;
}
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
}
function areWorkspaceSelectionsEqual(
left: ActiveWorkspaceSelection | null,
right: ActiveWorkspaceSelection | null,
): boolean {
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
}
function WorkspaceDeck({
fallbackSelection,
}: {
fallbackSelection: ActiveWorkspaceSelection | null;
}) {
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
);
useEffect(() => {
if (!activeSelection) {
return;
}
setMountedSelections((current) => {
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
return current;
}
return [...current, activeSelection];
});
}, [activeSelection]);
if (!activeSelection) {
return null;
}
return (
<View style={styles.deck}>
{mountedSelections.map((selection) => {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
return (
<View
key={`${selection.serverId}:${selection.workspaceId}`}
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});

View File

@@ -1,217 +1,3 @@
import { useEffect, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import {
type ActiveWorkspaceSelection,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === "agent") {
return { kind: "agent", agentId: openIntent.agentId };
}
if (openIntent.kind === "terminal") {
return { kind: "terminal", terminalId: openIntent.terminalId };
}
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
if (openIntent.kind === "setup") {
return { kind: "setup", workspaceId: openIntent.workspaceId };
}
return { kind: "draft", draftId: openIntent.draftId };
}
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("open")) {
return;
}
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
function clearConsumedOpenIntent(input: {
navigation: { setParams: (params: { open?: string | undefined }) => void };
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
stripOpenSearchParamFromBrowserUrl();
}
}
export default function HostWorkspaceIndexRoute() {
return (
<HostRouteBootstrapBoundary>
<HostWorkspaceRouteContent />
</HostRouteBootstrapBoundary>
);
return null;
}
function HostWorkspaceRouteContent() {
const navigation = useNavigation();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
setIntentConsumed(true);
}, [
hasHydratedWorkspaceLayoutStore,
navigation,
openValue,
rootNavigationState?.key,
serverId,
workspaceId,
]);
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
return null;
}
return <WorkspaceDeck />;
}
function areWorkspaceSelectionsEqual(
left: ActiveWorkspaceSelection | null,
right: ActiveWorkspaceSelection | null,
): boolean {
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
}
function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
);
useEffect(() => {
if (!activeSelection) {
return;
}
setMountedSelections((current) => {
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
return current;
}
return [...current, activeSelection];
});
}, [activeSelection]);
if (!activeSelection) {
return null;
}
return (
<View style={styles.deck}>
{mountedSelections.map((selection) => {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
return (
<View
key={`${selection.serverId}:${selection.workspaceId}`}
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});

View File

@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection,
startHostRuntimeBootstrap,
WELCOME_ROUTE,
} from "./host-runtime-bootstrap";
@@ -151,24 +150,14 @@ describe("resolveStartupRedirectRoute", () => {
});
describe("scenario: saved-host-online", () => {
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
it("redirects to the workspace route when the online host matches the persisted workspace", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-1",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(route).toBeNull();
});
it("resolves the persisted workspace when the online host matches it", () => {
const selection = resolveStartupWorkspaceSelection({
...baseInput,
anyOnlineHostServerId: "server-1",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
expect(route).toBe("/h/server-1/workspace/workspace-a");
});
it("redirects to the host root when the persisted workspace targets a different server", () => {
@@ -203,14 +192,14 @@ describe("resolveStartupRedirectRoute", () => {
});
describe("scenario: both-succeed", () => {
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
it("redirects to the host that the host runtime selects as earliest online", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
});
expect(route).toBeNull();
expect(route).toBe("/h/server-saved/workspace/workspace-a");
});
});

View File

@@ -1,7 +1,7 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type { Href } from "expo-router";
import { buildHostRootRoute } from "@/utils/host-routes";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
boot: () => void;
@@ -66,31 +66,8 @@ export interface ResolveStartupRedirectInput {
hasGivenUpWaitingForHost: boolean;
}
function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === "";
}
export function resolveStartupWorkspaceSelection(
input: ResolveStartupRedirectInput,
): ActiveWorkspaceSelection | null {
if (!isIndexPathname(input.pathname)) {
return null;
}
if (!input.isWorkspaceSelectionLoaded) {
return null;
}
if (
!input.anyOnlineHostServerId ||
!input.workspaceSelection ||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
) {
return null;
}
return input.workspaceSelection;
}
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
if (!isIndexPathname(input.pathname)) {
if (input.pathname !== "/" && input.pathname !== "") {
return null;
}
if (!input.isWorkspaceSelectionLoaded) {
@@ -98,8 +75,14 @@ export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput):
}
if (input.anyOnlineHostServerId) {
if (resolveStartupWorkspaceSelection(input)) {
return null;
if (
input.workspaceSelection &&
input.workspaceSelection.serverId === input.anyOnlineHostServerId
) {
return buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
);
}
return buildHostRootRoute(input.anyOnlineHostServerId);
}

View File

@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
const { redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
@@ -18,11 +18,11 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
return {
navigateToWorkspaceMock: vi.fn(),
redirectMock: vi.fn(),
state: hoistedState,
};
@@ -50,8 +50,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
useActiveWorkspaceSelection: () => state.workspaceSelection,
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
}));
describe("Index route startup navigation", () => {
@@ -68,8 +68,8 @@ describe("Index route startup navigation", () => {
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
navigateToWorkspaceMock.mockReset();
redirectMock.mockReset();
container = document.createElement("div");
@@ -98,17 +98,23 @@ describe("Index route startup navigation", () => {
expect(redirectMock).not.toHaveBeenCalled();
});
it("shows the startup splash while the workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("navigates to the host root when the persisted workspace targets a different server", async () => {

View File

@@ -2,13 +2,10 @@ import React from "react";
import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
import {
resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection,
} from "@/app/host-runtime-bootstrap";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
getLastNavigationWorkspaceRouteSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded,
} from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
@@ -18,35 +15,17 @@ export default function Index() {
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const workspaceSelection = useActiveWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
const redirectRoute = resolveStartupRedirectRoute({
pathname,
anyOnlineHostServerId,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
workspaceSelection: isWorkspaceSelectionLoaded
? getLastNavigationWorkspaceRouteSelection()
: null,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
pathname,
anyOnlineHostServerId,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
React.useEffect(() => {
if (!startupWorkspaceSelection) {
return;
}
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
currentPathname: pathname,
});
}, [pathname, startupWorkspaceSelection]);
if (startupWorkspaceSelection) {
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
if (redirectRoute) {
return <Redirect href={redirectRoute} />;

View File

@@ -178,7 +178,6 @@ export default function PairScanScreen() {
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
},
{ serverId: offer.serverId },

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M5.5 10.5L8 4L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="6.25" y1="8.75" x2="9.75" y2="8.75" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 416 B

View File

@@ -1,6 +0,0 @@
<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="currentColor"/>
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="currentColor"/>
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="currentColor"/>
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 650 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path fill="currentColor" d="M9.972 13.193h2.577q.187 0 .277-.09t.091-.294V10.47q0-.324.133-.59.135-.27.36-.424a1 1 0 0 1 .099-.06 1 1 0 0 1-.1-.058 1.1 1.1 0 0 1-.359-.424 1.3 1.3 0 0 1-.133-.59v-2.33q0-.202-.091-.293t-.277-.089H9.972a.38.38 0 0 1-.294-.124.4.4 0 0 1-.108-.281q.002-.176.131-.293a.46.46 0 0 1 .313-.111h2.775c.36 0 .642.097.836.288q.293.29.294.832V8.24q0 .39.152.562.148.168.555.178a.4.4 0 0 1 .27.128.41.41 0 0 1 .104.287.44.44 0 0 1-.1.282.34.34 0 0 1-.277.132c-.266.006-.452.066-.55.177q-.151.174-.152.573v2.318c0 .235-.043.44-.128.607a.85.85 0 0 1-.389.387 1.3 1.3 0 0 1-.534.124V14h-2.854a.45.45 0 0 1-.315-.118.38.38 0 0 1-.129-.286q0-.164.109-.282a.38.38 0 0 1 .293-.123zM1.103 9.108a.4.4 0 0 1 .276-.128q.4-.008.55-.177.15-.173.151-.562V5.923q.002-.543.294-.832c.195-.191.477-.288.836-.288h2.775q.188 0 .313.111a.37.37 0 0 1 .131.293.4.4 0 0 1-.108.281.38.38 0 0 1-.293.123H3.45q-.186 0-.277.09t-.092.292v2.33c0 .213-.044.413-.133.59a1.1 1.1 0 0 1-.359.424 1 1 0 0 1-.1.059 1 1 0 0 1 .1.059q.225.156.359.423t.133.59v2.34q0 .203.092.293.088.09.277.089h2.577a.38.38 0 0 1 .294.123q.108.12.108.281a.38.38 0 0 1-.13.286.45.45 0 0 1-.314.118l-2.775-.003a1.4 1.4 0 0 1-.613-.126.87.87 0 0 1-.388-.387 1.34 1.34 0 0 1-.129-.608v-2.318q0-.4-.151-.572-.151-.168-.55-.177a.35.35 0 0 1-.278-.132.42.42 0 0 1-.102-.28q0-.173.103-.287"/>
<path fill="currentColor" d="M5.437 10.36a.986.986 0 0 1-.994-.975c0-.538.446-.976.994-.976s.994.438.994.976a.986.986 0 0 1-.994.976M10.562 8.41c.548 0 .994.437.994.975a.986.986 0 0 1-.994.976.986.986 0 0 1-.994-.976c0-.538.446-.976.994-.976M8.74 2c.37 0 .446.15.446.275v.034q0 .034-.009.201-.01.162-.017.549-.018.378-.044 1.06c0 .106-.065.233-.375.233-.311 0-.376-.126-.376-.23l-.07-1.835v-.003c0-.13.078-.284.446-.284M7.256 2c.369 0 .446.15.446.275v.034q0 .034-.01.201-.007.162-.017.549-.017.378-.043 1.06c0 .106-.065.233-.376.233-.31 0-.375-.126-.375-.23l-.07-1.835v-.003C6.81 2.154 6.887 2 7.256 2"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,26 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.66949 9.56637C4.66949 8.92122 4.13288 8.39823 3.47094 8.39823C2.809 8.39823 2.2724 8.92122 2.2724 9.56637C2.2724 10.2115 2.809 10.7345 3.47094 10.7345V11C2.65856 11 2 10.3581 2 9.56637C2 8.7746 2.65856 8.13274 3.47094 8.13274C4.28332 8.13274 4.94189 8.7746 4.94189 9.56637C4.94189 10.3581 4.28332 11 3.47094 11V10.7345C4.13288 10.7345 4.66949 10.2115 4.66949 9.56637Z" fill="currentColor"/>
<path d="M3.90678 9.59292C3.90678 9.81286 3.72385 9.99115 3.49818 9.99115C3.27252 9.99115 3.08959 9.81286 3.08959 9.59292C3.08959 9.37298 3.27252 9.19469 3.49818 9.19469C3.72385 9.19469 3.90678 9.37298 3.90678 9.59292Z" fill="currentColor"/>
<path d="M3.8523 9.59292C3.8523 9.40231 3.69376 9.24779 3.49818 9.24779C3.30261 9.24779 3.14407 9.40231 3.14407 9.59292C3.14407 9.78353 3.30261 9.93805 3.49818 9.93805V9.99115C3.27252 9.99115 3.08959 9.81286 3.08959 9.59292C3.08959 9.37298 3.27252 9.19469 3.49818 9.19469C3.72385 9.19469 3.90678 9.37298 3.90678 9.59292C3.90678 9.81286 3.72385 9.99115 3.49818 9.99115V9.93805C3.69376 9.93805 3.8523 9.78353 3.8523 9.59292Z" fill="currentColor"/>
<path d="M4.66949 6.43363C4.66949 5.78848 4.13288 5.26549 3.47094 5.26549C2.809 5.26549 2.2724 5.78848 2.2724 6.43363C2.2724 7.07878 2.809 7.60177 3.47094 7.60177V7.86726C2.65856 7.86726 2 7.2254 2 6.43363C2 5.64186 2.65856 5 3.47094 5C4.28332 5 4.94189 5.64186 4.94189 6.43363C4.94189 7.2254 4.28332 7.86726 3.47094 7.86726V7.60177C4.13288 7.60177 4.66949 7.07878 4.66949 6.43363Z" fill="currentColor"/>
<path d="M3.90678 6.46018C3.90678 6.68011 3.72385 6.85841 3.49818 6.85841C3.27252 6.85841 3.08959 6.68011 3.08959 6.46018C3.08959 6.24024 3.27252 6.06195 3.49818 6.06195C3.72385 6.06195 3.90678 6.24024 3.90678 6.46018Z" fill="currentColor"/>
<path d="M3.8523 6.46018C3.8523 6.26957 3.69376 6.11504 3.49818 6.11504C3.30261 6.11504 3.14407 6.26957 3.14407 6.46018C3.14407 6.65079 3.30261 6.80531 3.49818 6.80531V6.85841C3.27252 6.85841 3.08959 6.68011 3.08959 6.46018C3.08959 6.24024 3.27252 6.06195 3.49818 6.06195C3.72385 6.06195 3.90678 6.24024 3.90678 6.46018C3.90678 6.68011 3.72385 6.85841 3.49818 6.85841V6.80531C3.69376 6.80531 3.8523 6.65079 3.8523 6.46018Z" fill="currentColor"/>
<path d="M8.04722 9.56637C8.04722 8.92122 7.51061 8.39823 6.84867 8.39823C6.18673 8.39823 5.65012 8.92122 5.65012 9.56637C5.65012 10.2115 6.18673 10.7345 6.84867 10.7345V11C6.03629 11 5.37772 10.3581 5.37772 9.56637C5.37772 8.7746 6.03629 8.13274 6.84867 8.13274C7.66105 8.13274 8.31961 8.7746 8.31961 9.56637C8.31961 10.3581 7.66105 11 6.84867 11V10.7345C7.51061 10.7345 8.04722 10.2115 8.04722 9.56637Z" fill="currentColor"/>
<path d="M7.2845 9.59292C7.2845 9.81286 7.10157 9.99115 6.87591 9.99115C6.65025 9.99115 6.46731 9.81286 6.46731 9.59292C6.46731 9.37298 6.65025 9.19469 6.87591 9.19469C7.10157 9.19469 7.2845 9.37298 7.2845 9.59292Z" fill="currentColor"/>
<path d="M7.23002 9.59292C7.23002 9.40231 7.07148 9.24779 6.87591 9.24779C6.68033 9.24779 6.52179 9.40231 6.52179 9.59292C6.52179 9.78353 6.68033 9.93805 6.87591 9.93805V9.99115C6.65025 9.99115 6.46731 9.81286 6.46731 9.59292C6.46731 9.37298 6.65025 9.19469 6.87591 9.19469C7.10157 9.19469 7.2845 9.37298 7.2845 9.59292C7.2845 9.81286 7.10157 9.99115 6.87591 9.99115V9.93805C7.07148 9.93805 7.23002 9.78353 7.23002 9.59292Z" fill="currentColor"/>
<path d="M8.04722 6.43363C8.04722 5.78848 7.51061 5.26549 6.84867 5.26549C6.18673 5.26549 5.65012 5.78848 5.65012 6.43363C5.65012 7.07878 6.18673 7.60177 6.84867 7.60177V7.86726C6.03629 7.86726 5.37772 7.2254 5.37772 6.43363C5.37772 5.64186 6.03629 5 6.84867 5C7.66105 5 8.31961 5.64186 8.31961 6.43363C8.31961 7.2254 7.66105 7.86726 6.84867 7.86726V7.60177C7.51061 7.60177 8.04722 7.07878 8.04722 6.43363Z" fill="currentColor"/>
<path d="M7.2845 6.46018C7.2845 6.68011 7.10157 6.85841 6.87591 6.85841C6.65025 6.85841 6.46731 6.68011 6.46731 6.46018C6.46731 6.24024 6.65025 6.06195 6.87591 6.06195C7.10157 6.06195 7.2845 6.24024 7.2845 6.46018Z" fill="currentColor"/>
<path d="M7.23002 6.46018C7.23002 6.26957 7.07148 6.11504 6.87591 6.11504C6.68033 6.11504 6.52179 6.26957 6.52179 6.46018C6.52179 6.65079 6.68033 6.80531 6.87591 6.80531V6.85841C6.65025 6.85841 6.46731 6.68011 6.46731 6.46018C6.46731 6.24024 6.65025 6.06195 6.87591 6.06195C7.10157 6.06195 7.2845 6.24024 7.2845 6.46018C7.2845 6.68011 7.10157 6.85841 6.87591 6.85841V6.80531C7.07148 6.80531 7.23002 6.65079 7.23002 6.46018Z" fill="currentColor"/>
<path d="M11.207 9.56637C11.207 8.92122 10.6704 8.39823 10.0085 8.39823C9.34653 8.39823 8.80993 8.92122 8.80993 9.56637C8.80993 10.2115 9.34653 10.7345 10.0085 10.7345V11C9.19609 11 8.53753 10.3581 8.53753 9.56637C8.53753 8.7746 9.19609 8.13274 10.0085 8.13274C10.8209 8.13274 11.4794 8.7746 11.4794 9.56637C11.4794 10.3581 10.8209 11 10.0085 11V10.7345C10.6704 10.7345 11.207 10.2115 11.207 9.56637Z" fill="currentColor"/>
<path d="M10.4443 9.59292C10.4443 9.81286 10.2614 9.99115 10.0357 9.99115C9.81005 9.99115 9.62712 9.81286 9.62712 9.59292C9.62712 9.37298 9.81005 9.19469 10.0357 9.19469C10.2614 9.19469 10.4443 9.37298 10.4443 9.59292Z" fill="currentColor"/>
<path d="M10.3898 9.59292C10.3898 9.40231 10.2313 9.24779 10.0357 9.24779C9.84014 9.24779 9.6816 9.40231 9.6816 9.59292C9.6816 9.78353 9.84014 9.93805 10.0357 9.93805V9.99115C9.81005 9.99115 9.62712 9.81286 9.62712 9.59292C9.62712 9.37298 9.81005 9.19469 10.0357 9.19469C10.2614 9.19469 10.4443 9.37298 10.4443 9.59292C10.4443 9.81286 10.2614 9.99115 10.0357 9.99115V9.93805C10.2313 9.93805 10.3898 9.78353 10.3898 9.59292Z" fill="currentColor"/>
<path d="M11.207 6.43363C11.207 5.78848 10.6704 5.26549 10.0085 5.26549C9.34653 5.26549 8.80993 5.78848 8.80993 6.43363C8.80993 7.07878 9.34653 7.60177 10.0085 7.60177V7.86726C9.19609 7.86726 8.53753 7.2254 8.53753 6.43363C8.53753 5.64186 9.19609 5 10.0085 5C10.8209 5 11.4794 5.64186 11.4794 6.43363C11.4794 7.2254 10.8209 7.86726 10.0085 7.86726V7.60177C10.6704 7.60177 11.207 7.07878 11.207 6.43363Z" fill="currentColor"/>
<path d="M10.4443 6.46018C10.4443 6.68011 10.2614 6.85841 10.0357 6.85841C9.81005 6.85841 9.62712 6.68011 9.62712 6.46018C9.62712 6.24024 9.81005 6.06195 10.0357 6.06195C10.2614 6.06195 10.4443 6.24024 10.4443 6.46018Z" fill="currentColor"/>
<path d="M10.3898 6.46018C10.3898 6.26957 10.2313 6.11504 10.0357 6.11504C9.84014 6.11504 9.6816 6.26957 9.6816 6.46018C9.6816 6.65079 9.84014 6.80531 10.0357 6.80531V6.85841C9.81005 6.85841 9.62712 6.68011 9.62712 6.46018C9.62712 6.24024 9.81005 6.06195 10.0357 6.06195C10.2614 6.06195 10.4443 6.24024 10.4443 6.46018C10.4443 6.68011 10.2614 6.85841 10.0357 6.85841V6.80531C10.2313 6.80531 10.3898 6.65079 10.3898 6.46018Z" fill="currentColor"/>
<path d="M14.5847 9.56637C14.5847 8.92122 14.0481 8.39823 13.3862 8.39823C12.7243 8.39823 12.1877 8.92122 12.1877 9.56637C12.1877 10.2115 12.7243 10.7345 13.3862 10.7345V11C12.5738 11 11.9153 10.3581 11.9153 9.56637C11.9153 8.7746 12.5738 8.13274 13.3862 8.13274C14.1986 8.13274 14.8571 8.7746 14.8571 9.56637C14.8571 10.3581 14.1986 11 13.3862 11V10.7345C14.0481 10.7345 14.5847 10.2115 14.5847 9.56637Z" fill="currentColor"/>
<path d="M13.822 9.59292C13.822 9.81286 13.6391 9.99115 13.4134 9.99115C13.1878 9.99115 13.0048 9.81286 13.0048 9.59292C13.0048 9.37298 13.1878 9.19469 13.4134 9.19469C13.6391 9.19469 13.822 9.37298 13.822 9.59292Z" fill="currentColor"/>
<path d="M13.7676 9.59292C13.7676 9.40231 13.609 9.24779 13.4134 9.24779C13.2179 9.24779 13.0593 9.40231 13.0593 9.59292C13.0593 9.78353 13.2179 9.93805 13.4134 9.93805V9.99115C13.1878 9.99115 13.0048 9.81286 13.0048 9.59292C13.0048 9.37298 13.1878 9.19469 13.4134 9.19469C13.6391 9.19469 13.822 9.37298 13.822 9.59292C13.822 9.81286 13.6391 9.99115 13.4134 9.99115V9.93805C13.609 9.93805 13.7676 9.78353 13.7676 9.59292Z" fill="currentColor"/>
<path d="M14.5847 6.43363C14.5847 5.78848 14.0481 5.26549 13.3862 5.26549C12.7243 5.26549 12.1877 5.78848 12.1877 6.43363C12.1877 7.07878 12.7243 7.60177 13.3862 7.60177V7.86726C12.5738 7.86726 11.9153 7.2254 11.9153 6.43363C11.9153 5.64186 12.5738 5 13.3862 5C14.1986 5 14.8571 5.64186 14.8571 6.43363C14.8571 7.2254 14.1986 7.86726 13.3862 7.86726V7.60177C14.0481 7.60177 14.5847 7.07878 14.5847 6.43363Z" fill="currentColor"/>
<path d="M13.822 6.46018C13.822 6.68011 13.6391 6.85841 13.4134 6.85841C13.1878 6.85841 13.0048 6.68011 13.0048 6.46018C13.0048 6.24024 13.1878 6.06195 13.4134 6.06195C13.6391 6.06195 13.822 6.24024 13.822 6.46018Z" fill="currentColor"/>
<path d="M13.7676 6.46018C13.7676 6.26957 13.609 6.11504 13.4134 6.11504C13.2179 6.11504 13.0593 6.26957 13.0593 6.46018C13.0593 6.65079 13.2179 6.80531 13.4134 6.80531V6.85841C13.1878 6.85841 13.0048 6.68011 13.0048 6.46018C13.0048 6.24024 13.1878 6.06195 13.4134 6.06195C13.6391 6.06195 13.822 6.24024 13.822 6.46018C13.822 6.68011 13.6391 6.85841 13.4134 6.85841V6.80531C13.609 6.80531 13.7676 6.65079 13.7676 6.46018Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1200 1200">
<path fill="currentColor" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 92 96" fill="currentColor">
<path d="M65.45 16.3c10.89 0 19.71 8.86 19.71 19.8v6.6l5.74 11.46a4.48 4.48 0 0 1-.01 3.6L85.16 69.1v6.6c0 10.94-8.83 19.8-19.71 19.8H26.02c-10.89 0-19.71-8.86-19.71-19.8v-6.6L.45 57.8a4.48 4.48 0 0 1-.01-3.67L6.31 42.7v-6.6c0-10.94 8.83-19.8 19.71-19.8h39.43zm-2.52 5.7H29.19c-9.32 0-16.87 7.56-16.87 16.88v5.62l-4.88 9.46a4.48 4.48 0 0 0 .01 3.68l4.87 9.36v5.62c0 9.32 7.55 16.88 16.87 16.88h33.74c9.32 0 16.87-7.55 16.87-16.88V67l4.77-9.39a4.48 4.48 0 0 0 0-3.61L79.8 44.5v-5.62C79.8 29.56 72.25 22 62.93 22z" fill-rule="nonzero"/>
<circle cx="45.73" cy="11" r="11"/>
<rect stroke="currentColor" stroke-width="8" x="31" y="44.5" width="5" height="22" rx="2.5"/>
<rect stroke="currentColor" stroke-width="8" x="55" y="44.5" width="5" height="22" rx="2.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 878 B

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M12.13 1.25c.15-.14.16-.14.28-.15.18 0 .36.08.64.34.68.62 1.61 1.89 2.2 2.97l.22.42.32.16c.3.16.81.48 1.02.65.09.08.11.08.21.04.45-.18 1.09.05 1.65.6.51.49 1 1.33 1.18 2.03.03.11.06.36.07.54.04.64-.16 1.14-.55 1.37-.08.04-.08.06-.08.26.02.95-.23 1.89-.74 2.82-.58 1.04-1.6 2.11-2.98 3.12-.74.55-2.5 1.58-3.3 1.95-1.9.86-3.43 1.2-4.76 1.03-.79-.09-1.69-.41-2.22-.77-.14-.1-.16-.1-.27-.07-.57.16-1.31-.18-1.94-.88-.25-.28-.66-.98-.79-1.35-.3-.87-.25-1.65.16-2.12.11-.12.09-.33.09-.33-.04-.33-.05-.82-.04-1.14v-.3l-.43-.79c-.68-1.23-1.12-2.25-1.28-3.04-.09-.43-.09-.62.02-.77.07-.09.28-.18.54-.22.66-.12 2.09-.01 3.69.27l.16.03.36-.32c.61-.54 1.01-.84 1.75-1.31.77-.48 1.64-.89 2.62-1.2l.32-.1.17-.46c.62-1.65 1.25-2.86 1.71-3.27h0Zm-5.19 8.45c-.7.41-1.05.61-1.3.84-1.04.93-1.43 2.39-.99 3.71.11.33.31.68.71 1.39.41.71.61 1.06.83 1.32.92 1.05 2.37 1.45 3.68 1 .32-.11.68-.31 1.38-.72l4.02-2.34c.7-.41 1.05-.61 1.3-.84 1.04-.93 1.43-2.39.99-3.71-.11-.33-.31-.68-.71-1.39s-.61-1.06-.83-1.32c-.92-1.05-2.37-1.45-3.68-1-.32.11-.67.31-1.38.72l-4.02 2.34Z"/>
<path fill="currentColor" fill-opacity="0.4" d="M8.02 12.13c.38-.22.87-.09 1.09.29l.86 1.5c.22.38.09.88-.29 1.09-.38.22-.86.09-1.09-.29l-.86-1.5c-.22-.38-.09-.87.29-1.09Z"/>
<path fill="currentColor" fill-opacity="0.4" d="M12.31 9.64c.38-.22.87-.09 1.09.29l.86 1.5c.22.38.09.87-.29 1.09s-.87.09-1.09-.29l-.86-1.5c-.22-.38-.09-.88.29-1.09Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

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