Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 2c83dea127 refactor(app): extract daemon management toggle coordinator with unit tests
Fills the coverage gap left by deleting desktop-updates-section.test.tsx.
Extracts executeDaemonManagementToggle from useDaemonManagementToggle — pure
async coordinator with injected deps (no vi.mock). Unit tests verify the three
key invariants: settings persist before stop, stop is skipped when not
desktop-managed, and start/stop are invoked on enable/disable.
2026-05-04 22:33:07 +07:00
Mohamed Boudra 96b39d4c56 test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store assertion
Removes desktop-updates-section.test.tsx (16 internal mocks, JSDOM, toHaveBeenCalledWith chains on component renders).
Tightens desktop-attachment-store.test.ts: toHaveBeenCalled() → toHaveBeenCalledWith(attachment).
2026-05-04 22:27:31 +07:00
Mohamed Boudra 13adfee3a4 refactor(app): make use-agent-input-draft storage injectable (#716)
Extract DraftStorage interface and createAgentInputDraftCore factory
to a new use-agent-input-draft-core.ts. Pure helper functions (resolveDraftKey,
resolveEffectiveComposerModelId, etc.) move there too, breaking the test
file's transitive dependency on AsyncStorage and useAgentFormState.

The test now imports directly from the core module, uses a Map-backed
in-memory storage, and has zero vi.mock calls. Tests assert behavior
("save then load returns the same draft") rather than call counts.
2026-05-04 23:23:36 +08:00
Mohamed Boudra 5387a16bb6 feat(app/e2e): introduce withWorkspace fixture and DSL helpers (#717)
Adds a `withWorkspace` Playwright fixture plus composable helpers
(permissions, sidebar, composer, agent-stream, settings) so specs read
as user-level intent. Migrates workspace-lifecycle and settings-host-page
to the new DSL as proof.
2026-05-04 23:22:59 +08:00
Mohamed Boudra 6d7848059e test(app): tighten weak assertions in utils tests (#714)
Replace toBeDefined/toBeTruthy guards with concrete shape assertions
using toContainEqual(expect.objectContaining(...)) in diff-highlighter
tests, and toMatch(/^script-draft-\d+$/) for the ID check in
project-config-form tests.
2026-05-04 23:13:09 +08:00
Mohamed Boudra a324ac5d71 refactor(app): extract pr-pane derivations into pure utils and unit tests (#713)
Move getStateLabel and getActivityVerb out of pr-pane.tsx into pr-pane-data.ts
so they can be tested without JSDOM, React, or vi.mock. Add exhaustive unit tests
for both helpers alongside the existing mapPrPaneData/formatAge/deriveAvatarColor
coverage. Delete pr-pane.test.tsx — the component test was asserting on derived
label/icon strings that are now covered by pure function tests.
2026-05-04 23:13:05 +08: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 KurzandMathias 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
Mohamed Boudra 15a2e3bdcb chore(release): cut 0.1.67 2026-05-04 01:43:24 +07:00
Mohamed Boudra 64ba05cea5 Add v0.1.67 changelog entry 2026-05-04 01:42:23 +07:00
Mohamed Boudra 1521ceb381 Surface desktop daemon gate failures instead of silent no-op
The async settings gate added in f0d96f8e called
shouldStartDaemon() inside Promise.resolve(...).then(...) with no
catch. If loadDesktopSettings() rejects (corrupted JSON, FS error),
the daemon never starts, no error renders on the splash, and the
retry button repeats the same silent path.

Add a recordError method to DaemonStartService and an onGateError
callback to startDaemonIfGateAllows / startHostRuntimeBootstrap.
_layout.tsx wires it through so a gate rejection populates
lastError, the splash shows the failure, and retry has signal.
2026-05-04 01:40:47 +07:00
Mohamed Boudra 4338f5b46c Fix workspace reconnect playwright tests after toast move
Test #1 was asserting agent-reconnecting-toast on a workspace with no
agents — but commit 1daa1314 moved the reconnect indicator from the
workspace banner into the per-agent panel toast. Create an idle agent
and navigate to it so the agent panel mounts before dropping the
daemon gate.

Test #2 regex still pinned the old "Connecting to localhost" copy;
commit c534c857 tightened it to just "Connecting" with the hostname in
the description. Match the new title plus the existing offline copies.
2026-05-04 01:18:37 +07:00
Mohamed Boudra e8a64fb569 ci: build server before desktop tests
f0d96f8e added packages/desktop/src/daemon/daemon-manager.test.ts,
which calls vi.mock("@getpaseo/server", ...). Vite's module
resolution still needs to find the package's main entry, so
desktop CI must build the server (and its dependency chain) before
running the tests. Mirrors the prebuild steps used by typecheck,
server-tests, and playwright.
2026-05-04 00:40:38 +07:00
Mohamed Boudra cf1f849b68 Update MCP create_agent test to match async branch auto-name
7c85341a moved first-agent branch auto-naming out of the worktree
service (sync slugify) into the workflow layer (async LLM call).
The MCP test stubs createPaseoWorktree, bypassing the workflow,
so the placeholder mnemonic branch is never renamed at this layer.
Replace the obsolete sync-slugify outcome assertion with a
placeholder-presence check; the auto-name logic itself is covered
in worktree-branch-name-generator.test.ts.
2026-05-04 00:31:33 +07:00
Mohamed Boudra bc0886ad7c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-04 00:16:08 +07:00
Mohamed Boudra 12fcfe53eb Make structured response generation ephemeral end-to-end
Plumbs persistSession through the structured response pipeline so
metadata, branch name, commit message, and PR text generators no
longer leave provider-side artifacts behind:

- Codex: passes ephemeral: true on thread/start (no rollout file)
- OpenCode: deletes the session via session.delete on close
- Claude: forwards persistSession: false to the SDK query

Adds a real-credentials e2e test per provider that asserts the
generated title is applied AND no persistence artifact is produced
(rollout for Codex, project JSONL for Claude, leftover session for
OpenCode).
2026-05-04 00:10:50 +07:00
github-actions[bot] a198a33ff5 fix: update lockfile signatures and Nix hash 2026-05-03 16:24:36 +00:00
Mohamed Boudra 7c85341a42 Fix worktree creation and archive flow
Server: drop the synchronous branch-name generator dep, use the
worktree slug as the initial branch, and move first-agent branch
auto-naming to an async post-creation step backed by a structured
LLM call. Guards against renaming when the placeholder branch has
already been changed.

App: make workspace and worktree archive optimistic, rolling back
the local hide if the daemon call fails, and suppress incoming
workspace upserts while an archive is pending.
2026-05-03 23:22:54 +07:00
Mohamed Boudra c534c857a4 Tighten workspace host connecting copy 2026-05-03 23:01:29 +07:00
Mohamed Boudra 6d8c7c4d2d Fix desktop settings legacy override 2026-05-03 22:57:33 +07:00
Mohamed Boudra f0d96f8e5a Fix built-in daemon management toggle 2026-05-03 22:02:40 +07:00
Mohamed Boudra f8d4758e6b Run iOS Fastlane review-submit on macOS
Linux EAS runners don't ship Ruby/Bundler, so `bundle install`
fails with "bundle: command not found". macOS workflow runners
have Ruby 3.2 + Fastlane preinstalled, so switching this job to
runs_on: macos-medium gets us there without a manual install step.
2026-05-03 21:27:24 +07:00
github-actions[bot] 09b498da61 fix: update lockfile signatures and Nix hash 2026-05-03 13:50:32 +00:00
Mohamed Boudra 221b6665a0 chore(release): cut 0.1.66 2026-05-03 20:48:53 +07:00
Mohamed Boudra 94dd36a4c9 Fix EAS Fastlane working_directory doubling
The eas/checkout step already cd's into packages/app (where eas.json
lives), so the explicit `working_directory: ./packages/app` on the
Fastlane steps resolved to /home/expo/workingdir/build/packages/app/packages/app
and failed every release with ENOENT.
2026-05-03 20:44:01 +07:00
Mohamed Boudra 84c1ff560f Add 0.1.66 changelog entry 2026-05-03 20:39:26 +07:00
Mohamed Boudra b7f9092e93 Fix terminal renderer remount loading 2026-05-03 20:38:12 +07:00
Mohamed Boudra ade05607d2 fix(app): latch startup store readiness 2026-05-03 20:15:19 +07:00
Mohamed Boudra 6d13796b2d Move workspace gate background and root layout off useUnistyles
The workspace gate (loading/unreachable/missing) used surfaceWorkspace,
which clashed with the panels' surface0. Switch the gate shell to
surface0 so it matches the agent panel.

Also remove three hot-path useUnistyles() calls from _layout.tsx that
were re-rendering the whole app on every theme/breakpoint/insets change:
RootLayout and AppContainer move to StyleSheet.create, and the desktop
window-controls effect lifts into a small leaf component.
2026-05-03 19:49:10 +07:00
Mohamed Boudra 191cea47a0 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-03 19:25:23 +07:00
Mohamed Boudra 0aa4868b31 Fix terminal DSR replies 2026-05-03 19:24:30 +07:00
github-actions[bot] e7016ef6b8 fix: update lockfile signatures and Nix hash 2026-05-03 12:19:32 +00:00
Mohamed Boudra 4332eca9ca Shorten agent initialization timeout 2026-05-03 19:12:36 +07:00
Mohamed Boudra fc31d31493 Disable dev client runtime metrics logging 2026-05-03 19:11:25 +07:00
Mohamed Boudra 8ba71cc189 Preserve live markdown block newlines 2026-05-03 19:09:24 +07:00
Mohamed Boudra 38c1b08433 chore(release): cut 0.1.65 2026-05-03 17:20:28 +07:00
Mohamed Boudra 21a5d4f061 Promote 0.1.65 changelog heading to stable 2026-05-03 17:19:34 +07:00
Mohamed Boudra 1daa131480 Replace workspace reconnect banner with persistent agent panel toast
The workspace reconnect banner ignored safe-area insets on Android,
overlapping the system status bar. The agent panel already shows a
"Reconnecting..." toast on disconnect, so consolidate on that and drop
the banner entirely. Add persistent toast support (durationMs: null)
and dismiss the toast when the connection returns to online instead of
auto-dismissing after 2.2s.
2026-05-03 17:17:02 +07:00
Mohamed Boudra f33a5191df Drop obsolete Connecting… assertion from reconnect welcome DSL
The 'Connecting…' placeholder was removed from welcome-screen.tsx in
02adadbb (Always show welcome copy on welcome screen) but the e2e
helper still asserted it visible, breaking startup-loading.spec.ts.
2026-05-03 15:42:57 +07:00
Mohamed Boudra d6c3896fd9 Update test mocks for new serviceUrlBehavior setting
f58cfe9 (Open script service URLs in-app or external) added
serviceUrlBehavior to AppSettings and a new useDropdownMenuClose
export from dropdown-menu. The unit tests were not updated:

- use-settings.test.ts: expected settings objects missed the new
  serviceUrlBehavior field.
- workspace-scripts-button.test.tsx: dropdown-menu mock missed
  useDropdownMenuClose, breaking HostLinkRow render.
2026-05-03 15:38:01 +07:00
Mohamed Boudra f2da4b861b Fix sidebar workspace list test AsyncStorage mock
fa20f313 wired AsyncStorage persistence into
activateNavigationWorkspaceSelection. The mock here returned undefined
from setItem, so the .catch() in persistLastWorkspaceRouteSelection
threw. Make get/set/removeItem return resolved promises like real
AsyncStorage.
2026-05-03 15:25:04 +07:00
Mohamed Boudra eec9804567 Tighten 0.1.65-beta.5 changelog and document unreleased-fix trap 2026-05-03 15:20:35 +07:00
Mohamed Boudra f58cfe9008 Open script service URLs in-app or external
Adds a Service URLs setting (ask / in-Paseo / external browser) that
controls where URLs from running scripts open on desktop. First click
prompts with a "don't ask again" checkbox; in-Paseo opens a workspace
browser tab. Closing a browser tab now clears its session partition.
2026-05-03 15:14:28 +07:00
Mohamed Boudra 02adadbbc8 Always show welcome copy on welcome screen
Drop the "Connecting…" placeholder shown while reconnecting to saved
hosts. The screen now keeps the welcome copy and add-host actions
visible until the auto-redirect to an online host fires.
2026-05-03 15:05:59 +07:00
Mohamed Boudra 086bc2beee Fix desktop release build args 2026-05-03 15:03:32 +07:00
github-actions[bot] 1eb3b31cfa fix: update lockfile signatures and Nix hash 2026-05-03 07:37:21 +00:00
Mohamed Boudra 831d0bbf6e Hide element picker in production 2026-05-03 14:36:01 +07:00
Mohamed Boudra d4fa12c3f6 fix(desktop): centralize desktop build script 2026-05-03 14:35:20 +07:00
Mohamed Boudra f993b1d856 Dismiss keyboard when opening mobile sidebar 2026-05-03 14:17:15 +07:00
github-actions[bot] f3de568969 fix: update lockfile signatures and Nix hash 2026-05-03 07:00:01 +00:00
Mohamed Boudra 2faacc59f1 chore(release): cut 0.1.65-beta.5 2026-05-03 13:58:46 +07:00
Mohamed Boudra 65cfe4bda4 Reduce agent timeline focus catch-up 2026-05-03 13:57:48 +07:00
Mohamed Boudra fa20f313d3 Simplify workspace reconnect gating 2026-05-03 13:50:20 +07:00
Mohamed Boudra b2247450f9 Fix terminal snapshot flushing 2026-05-03 13:25:19 +07:00
Mohamed Boudra d89ecae615 fix(server): clean up OpenCode process trees
Spawn the shared OpenCode server in its own POSIX process group and terminate provider child process trees with graceful timeout escalation before forcing SIGKILL. Reuse the same helper for Codex app-server cleanup so process-tree behavior is covered consistently across providers.

Add cross-platform unit coverage for macOS/Linux process groups, Windows taskkill cleanup, and timeout escalation. Add the process-tree test to the Windows-critical CI allowlist; Linux full server tests already include it.

Refs #227
2026-05-02 21:44:59 +07:00
github-actions[bot] 24dff12652 fix: update lockfile signatures and Nix hash 2026-05-02 13:42:48 +00:00
Mohamed Boudra d06e3e9494 chore(release): cut 0.1.65-beta.4 2026-05-02 20:41:37 +07:00
Mohamed Boudra b7802ae271 chore(release): prep 0.1.65-beta.4 changelog
Update CHANGELOG.md in place per beta policy and add a compat note on the
`sub_agent.actions` field flagging it as drop-on-cleanup once clients
<= 0.1.65-beta.3 are out of the field.
2026-05-02 20:40:41 +07:00
Mohamed Boudra d53d601043 refactor(server): inline client capability parsing
With capability negotiation in place, the consumer-side tolerance pattern is unnecessary. Drop the dead readDeclaredClientCapabilities helper, inline the parsing in ClientCapabilities.fromHello, delete a tautological assertion in the relay reconnect test, and document the compatibility rules in architecture.md.
2026-05-02 20:30:25 +07:00
Mohamed Boudra ca318f29cf feat(client): advertise reasoning merge capability 2026-05-02 20:30:25 +07:00
Mohamed Boudra ee1c7e6956 test(server): pin wire compatibility regressions 2026-05-02 20:30:25 +07:00
Mohamed Boudra e3e1c08525 fix(server): restore wire compatibility for old clients 2026-05-02 20:30:25 +07:00
Mohamed Boudra 71e83dc42f fix(app): keep live assistant continuation with hydrated tail 2026-05-02 19:57:43 +07:00
Mohamed Boudra 1fcca3b32c fix(app): browser pane keyboard tweaks
- Refresh icon: use single-arrow RotateCw instead of two-arrow RefreshCw
- URL bar selects all text on focus
- Cmd+R now reloads the active browser pane (tracked via IPC) when
  the URL bar has focus, instead of reloading the Electron window
- Forward Paseo shortcut keys (b, e, w, t, k, /, \, comma, digits,
  enter, arrows) from the embedded webview to the main window so
  sidebar/tab/etc. shortcuts still fire when focus is inside the
  loaded page
2026-05-02 19:20:33 +07:00
Mohamed Boudra e925aa5438 fix(app): keep dictation running when agent tab loses focus
Previously, dictation was cancelled the moment the user switched away
from the active agent tab, losing any in-progress recording. Drop the
auto-stop-on-blur behavior so users can start dictating, switch tabs,
and come back to finalize.
2026-05-02 19:13:31 +07:00
fireblueandMohamed Boudra 654439005a fix(app): paginate canonical timeline catch-up to avoid relay 1009 disconnect loop (#657)
@fireblue diagnosed the production failure mode: long canonical timeline catch-up could exceed the relay WebSocket frame cap, close with 1009, and send clients into a reconnect loop. Their seatbelt fix also removed the `limit: 0` footgun that let app callers request an unbounded frame.

This maintainer pass keeps that diagnosis and redirects the UX around bounded projected pages:

- initial agent load fetches the latest canonical tail
- app resume/reconnect re-fetches the latest tail instead of chaining after-cursor pages
- in-stream gaps still use bounded after-cursor catch-up
- older history loads explicitly when the user scrolls to the oldest edge
- shared page size is TIMELINE_FETCH_PAGE_SIZE = 100 projected timeline items, matching the daemon's canonical projection semantics rather than raw delta chunks

Original diagnosis and fix by @fireblue.

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-02 19:53:50 +08:00
Mohamed BoudraandJason Kneen 906205f97a Add browser pane element picker (#670)
* feat: add browser pane element picker

Port the browser pane from PR #198 and route picked elements through workspace attachments so the context follows every agent composer in a workspace.

Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>

* docs: document electron platform overlays

* fix: harden browser pane navigation

* fix: polish browser tab interactions

* fix: route browser pane focus shortcuts

---------

Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
2026-05-02 19:28:40 +08:00
Mohamed Boudra 34b95e7f01 Improve review comment UI 2026-05-02 18:04:18 +07:00
Mohamed Boudra 49e3011a28 Render sub-agent activity from log 2026-05-02 17:20:22 +07:00
Mohamed Boudra 771f164e26 Stream Codex sub-agents as tool calls (#672) 2026-05-02 17:08:07 +08:00
Mohamed Boudra ce8dc46054 refactor(server): extract provider turn runner (#668)
Claude, Codex, OpenCode, and ACP each reimplemented the same `run()`
orchestration: pre-start event buffering, turn-id filtering, terminal
event settling, timeline collection, and final-text reduction. Hoist
the loop into `provider-runner.ts` with a small adapter (startTurn,
subscribe, getSessionId) and pluggable final-text reducer.

Net -252 lines across the four providers. Also fixes a latent bug
where a `turn_failed` emitted before startTurn returned was buffered
and silently swallowed instead of rejecting the run.
2026-05-02 15:45:08 +08:00
Mohamed Boudra b6181c2d31 docs: dark-mode aware star history chart with constrained width 2026-05-02 14:39:33 +07:00
Mohamed Boudra 6225ec3008 refactor(server): extract foreground run state from agent-manager (#666)
Centralize pending-run, waiter, and finalized-turn bookkeeping in a
new ForegroundRunState module so streamAgent, replaceAgentRun,
cancelAgentRun, reload/close, and stream dispatch share one
coordinator instead of hand-rolling the lifecycle in five places.
2026-05-02 15:34:47 +08:00
Mohamed Boudra 26737d3d70 refactor(server): extract WorkspaceDirectory from session.ts (#667)
Move workspace descriptor map building, fetch_workspaces filtering /
sorting / cursor paging, and archiving overlay state out of the Session
god class into a dedicated WorkspaceDirectory module.

Session keeps thin delegation methods so existing tests and callers see
the same surface; cursor errors are still mapped to SessionRequestError
at the session boundary.
2026-05-02 15:34:19 +08:00
Mohamed Boudra da85814940 fix(server): apply get_agent_activity limit to projected entries
The MCP get_agent_activity tool sliced the raw timeline before passing
to the curator, so `limit: 5` could land mid-message and return a
trailing fragment of streamed assistant text. Now goes through the
existing projection pipeline so the limit counts whole projected
entries — assistant deltas merged, tool calls collapsed by callId,
reasoning chunks merged.

Same bug fixed in the wait_for_agent timeout's "Recent activity"
preview, which used the same raw slice.

Consolidates the merge logic: activity-curator no longer ships its own
collapseTimeline, it delegates to projectTimelineRows. One source of
truth for "what is a complete message".

Drops the meaningless `provider` parameter that was threaded through
the projection module — merge logic never branched on it. The wire
schema keeps `provider` per entry; session stamps it once at the emit
boundary. Adds `reasoning_merge` to the projection kind enum (additive
wire change).
2026-05-02 14:24:45 +07:00
Mohamed Boudra 1b5d082d1a refactor(app): fold timeline sequencing helpers into stream reducer (#664)
Three sibling modules (session-stream-lifecycle, session-timeline-seq-gate,
and the bootstrap-tail policy from session-timeline-bootstrap-policy) only
served the stream reducer. Inline them as private helpers in
session-stream-reducers.ts; keep deriveInitialTimelineRequest exported for
the hook caller. Removes shallow seams without changing behavior.
2026-05-02 15:19:09 +08:00
Mohamed Boudra 03e54a1a21 refactor(server): extract terminal stream router from daemon-client (#665)
Move slot map, listener fanout, binary input encoding, and stream-frame
decoding out of daemon-client.ts into TerminalStreamRouter so the
4.5k-line client no longer owns that bookkeeping. Public DaemonClient
API and on-the-wire behavior unchanged.
2026-05-02 14:56:35 +08:00
Mohamed Boudra 91693aba84 fix(desktop): green up desktop CI on main
Two breakages surfaced when CI started running desktop tests:

- Root vitest.config.ts replaced default exclude with `["**/.claude/**"]`,
  losing `**/node_modules/**`. Desktop has no local vitest config, so
  vitest scanned into `node_modules/zod/src/v4/classic/tests/*.test.ts`
  and failed on missing peers (`@web-std/file`, `recheck`). Spread
  `configDefaults.exclude` so node_modules stays excluded.

- window-manager dark titlebar color changed from #18181c to #181B1A in
  963c7926 (white-flash fix) but the test was not updated. Update the
  expected color to match the new intended behavior.
2026-05-02 13:51:36 +07:00
Mohamed Boudra f3a4732dbe docs: clarify notifyOnFinish pattern for paseo agents 2026-05-02 13:36:50 +07:00
Mohamed Boudra 90feb8050c feat(desktop): auto-update bundled skills on app launch
Skills installed via Paseo desktop never updated after first run, so
users were stuck on already-fixed skill content. On every launch, if
the paseo base skill is present in `~/.agents/skills/`, sync each
bundled skill (incl. `references/`) into agents, claude (via symlink/
junction), and codex — overwriting only files whose content differs.
Files on disk that are not in the bundle are left alone; deprecation
goes through tombstones (e.g. `paseo-orchestrate` redirects to
`paseo-epic`).

Also refreshes the bundled skill set: drops `paseo-chat`, adds
`paseo-advisor` and `paseo-epic`, and turns `paseo-orchestrate` into
a tombstone redirecting to `paseo-epic`.

Adds a desktop test job to CI on Ubuntu and Windows so the
junction/symlink path is exercised on both platforms.
2026-05-02 13:28:17 +07:00
Mohamed Boudra d46d92528a fix app stream head replacement during init hydration (#663) 2026-05-02 14:13:39 +08:00
Mohamed Boudra bb889dae99 feat: stream files as binary frames over WebSocket (#659)
Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.

- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
  `readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
  private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
  frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
  opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
  emit binary frames when set; legacy JSON path remains for old clients.
  Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
  `expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
  desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
  removed.
2026-05-02 14:02:27 +08:00
Mohamed Boudra b6c43088fb test(server): align worktree tests with explicit PR context 2026-05-02 12:49:31 +07:00
Mohamed BoudraandRyan Swift fdc510d3a0 feat(opencode): add subagent timeline support (#658)
Map OpenCode `task` tool events into the existing sub_agent timeline shape,
enabling live activity tracking for OpenCode subagent sessions.

- Add `childSessionId` (optional) to sub_agent schema
- Make `actions` optional with default [] for backward compatibility
- Parse task tool input/output into sub_agent detail (tool-call-detail-parser)
- Track child session linkage and buffer/flush child tool parts
- Render sub_agent actions and session IDs in the app component
- Add glob tool detail mapping
- Include backward-compat schema test

Co-authored-by: Ryan Swift <ryan@mlh.io>
2026-05-02 13:19:42 +08:00
Mohamed Boudra a64be66353 docs(release): credit commit author over PR opener
Squash commits keep the original commit author, but `gh pr view N --json author` returns the PR opener — using that field silently mis-credits work to the maintainer who landed the PR (and the skip-@boudra rule then drops the credit). Document the `--json commits | unique logins` query as the source of truth for attribution.
2026-05-02 12:12:44 +07:00
Mohamed Boudra 2f177dde7a fix(server): rename worktree branches from prompt + attachments
Replace top-level `attachments` and `nameContext` on `create_paseo_worktree_request` with `firstAgentContext: { prompt?, attachments? }`, the single signal driving auto-rename. Drop the `worktreeSlug || …` short-circuits so a random placeholder slug no longer blocks renaming, drop the attachment-as-PR fallback in favor of explicit `githubPrNumber`, and reshape the MCP `create_worktree` tool around a `target` discriminated union (`branch-off` / `checkout-branch` / `checkout-pr`) with concise field descriptions so callers stop misreading `prompt` as a name field.
2026-05-02 12:12:44 +07:00
Mohamed Boudra 8e9134d255 Fix isolated bottom sheet modal lifecycle 2026-05-02 12:12:44 +07:00
Mohamed Boudra 84796bc74d fix(app): restore line numbers in diff gutter and add tap-to-comment
The gutter was showing the message-circle icon on every reviewable line on
native and on compact form factors, displacing the line number whenever
review draft mode was active. Restore the default by computing showAction
purely from interaction state (hover/press), comment presence, and editor
visibility — drop the showPersistentAction flag and the isNative bias.

Tap on the gutter directly opens the inline review editor on every
platform. On native, long-pressing a line of code (350ms) also opens the
editor via a new LongPressableLine wrapper, preserving web text selection.
2026-05-02 12:12:44 +07:00
Ryan Swift 3dae11d292 fix(opencode): include all-mode agents in mode picker (#606) 2026-05-02 12:46:27 +08:00
Mohamed Boudra 4173ac0ca0 refactor(server): extract SortablePager for generic pagination 2026-05-01 23:20:23 +07:00
Mohamed Boudra 9954eefdee refactor(server): extract TerminalSessionController from session.ts
Moves terminal RPC, binary streaming, slot allocation, and exit-subscription
bookkeeping out of the 9.7K-line Session class and into a dedicated controller
with a small facade (dispatch, handleBinaryFrame, killTerminalForClose,
killTerminalsUnderPath, dispose, getMetrics). Session loses 7 fields and 22
methods (-651 LOC) and the cluster's invariants now live next to their state.
2026-05-01 23:05:47 +07:00
Mohamed Boudra 9800203a48 refactor(server): collapse tool-call mapper pass2 zod dance
Replace the duplicated 3-stage Zod pipeline (pass1 -> pass2 envelope
union -> pass2 discriminated union) in each provider mapper with a plain
resolveToolKind(name) function over readonly name sets. Opencode's
toolKind was unused; Claude/Codex used it only as a switch discriminator.
2026-05-01 22:53:14 +07:00
Mohamed Boudra 14d78327c3 refactor: extract tool call status normalization 2026-05-01 22:47:01 +07:00
Mohamed Boudra 6eab2a89e9 refactor(server): extract pagination cursor codec 2026-05-01 22:30:12 +07:00
Mohamed Boudra c3d2129472 refactor(server): extract AgentActionResponsePayloadSchema
Five set_agent_*_response and update_agent_response payloads were
pixel-identical. Share one named schema so the concept is explicit and
new agent-action responses don't copy-paste the same four fields.
Wire format and inferred types are unchanged.
2026-05-01 21:22:00 +07:00
Mohamed Boudra 829f3f7460 fix(server): put /api/status behind password auth
Only /api/health remains public for liveness probes. /api/status
exposes hostname, version, and server ID which shouldn't be
accessible without authentication.
2026-05-01 21:16:56 +07:00
github-actions[bot] a4a6713550 fix: update lockfile signatures and Nix hash 2026-05-01 14:12:28 +00:00
Mohamed Boudra 65b814c7dc feat(website): add anchor links to docs headings
Hover a heading to reveal a # link on the left that updates the URL
hash without layout shift, matching GitHub README behavior.
2026-05-01 21:11:14 +07:00
Mohamed Boudra f2a23039cb docs: document password authentication in public docs 2026-05-01 21:06:29 +07:00
github-actions[bot] c49743c4a1 fix: update lockfile signatures and Nix hash 2026-05-01 13:19:44 +00:00
Mohamed Boudra 337820a168 chore(release): cut 0.1.65-beta.3 2026-05-01 20:18:40 +07:00
Mohamed Boudra 43d9e2acc8 changelog: update for 0.1.65-beta.3 2026-05-01 20:17:44 +07:00
Mohamed Boudra 3efe6dcff0 fix(server): preserve codex assistant item boundaries 2026-05-01 19:52:19 +07:00
Mohamed Boudra 1fdfefc4de Fix composer workspace attachment update loop 2026-05-01 19:51:58 +07:00
Mohamed Boudra 68d32eb183 test(server): update stale coalescing and worker title tests 2026-05-01 19:18:16 +07:00
Mohamed Boudra aa547cf66b fix: stabilize streaming footer transition 2026-05-01 19:01:19 +07:00
Mohamed Boudra a9ea9f3e37 Fix web dropdown menu resizing 2026-05-01 18:32:04 +07:00
Mohamed Boudra cb319736a2 Fix native-safe connection offer decoding 2026-05-01 18:29:10 +07:00
Mohamed Boudra 51f7ef3171 Fix terminal worker state mirroring 2026-05-01 16:19:25 +07:00
Mohamed Boudra af38c6e135 fix: restore codex streaming responsiveness 2026-05-01 16:18:46 +07:00
Mohamed Boudra 1c4c22b060 test(server): harden worker terminal cleanup on windows 2026-05-01 14:36:17 +07:00
Mohamed Boudra e3295290ac test(desktop): smoke packaged terminal cli path 2026-05-01 14:31:22 +07:00
Mohamed Boudra 84f5818e09 test(server): run worker terminal coverage on windows 2026-05-01 14:27:55 +07:00
Mohamed Boudra 67707f2716 test(app): keep script menu e2e focused on launch 2026-05-01 13:25:36 +07:00
Mohamed Boudra ed70827f39 test(app): stabilize plan approval and script e2e 2026-05-01 13:02:50 +07:00
Mohamed Boudra 111935f4eb ci: run workflows on node 22 2026-05-01 12:32:19 +07:00
Mohamed Boudra fa0af97aa6 Fix duplicate Codex plan approval panels 2026-05-01 11:42:30 +07:00
Mohamed Boudra eee4a772d7 Fix imported agent title hydration 2026-04-30 22:42:23 +07:00
Mohamed Boudra c783b7b093 Fix Codex skill prompt turn startup 2026-04-30 22:36:26 +07:00
Mohamed Boudra 4d840dafd4 Move terminal sessions to worker process 2026-04-30 22:35:22 +07:00
Mohamed Boudra 39f100b4bb Increase archive worktree RPC timeout 2026-04-30 21:41:43 +07:00
Mohamed Boudraandgithub-actions[bot] 088a6c14c0 feat: direct TCP URI with SSL toggle and optional password auth (#635)
* feat: direct TCP URI with SSL toggle and optional password auth

Replaces the heuristic-driven direct-connection model with a user-controlled
one. The Add Host dialog now exposes structured Host, Port, "Use SSL", and
masked Password fields that compose the canonical
`tcp://host:port?ssl=true&password=xxx` URI used as the storage form.

The daemon gains optional shared-secret auth: `Authorization: Bearer <pw>` on
HTTP and `Sec-WebSocket-Protocol: paseo.bearer.<pw>` on the WS upgrade
(browser WebSocket can't set custom headers). Configured via config.json
`auth.password` or `PASEO_PASSWORD` env. Off by default — old clients keep
working unchanged.

The `port === 443` heuristic for ws/wss is gone; the explicit `useTls` flag
drives scheme selection at every call site.

* fix: stabilize direct tcp auth ci checks

* fix: restore fetch stub in bootstrap smoke test

* fix(app): collapse Advanced section in add-host modal

* feat(server): hash daemon password in config

* fix: update lockfile signatures and Nix hash

* Improve direct TCP auth failures

* Fix workspace cwd updates after rebase

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-30 22:05:49 +08:00
Mohamed Boudra 30f7842e8f feat(cli): connect via relay using pairing offer URL (#639)
Pass `--host https://app.paseo.sh/#offer=...` (or `PASEO_HOST=...`) to
any CLI command and the connection routes through the relay with E2EE,
the same way the mobile and desktop apps connect. Same `paseo daemon
pair` link the app already accepts — no new pairing flow.

Closes #181
2026-04-30 21:37:51 +08:00
Mohamed Boudra c2ad4a02ef refactor: unify worktree creation workflow (#636)
Route app, MCP, and agent worktree creation through one workflow boundary. Move branch auto-naming into workspace creation and keep agent metadata title-only.
2026-04-30 21:37:17 +08:00
Mohamed Boudra 93c6c3e5f8 fix: keep archived worktrees optimistically hidden (#640) 2026-04-30 21:36:41 +08:00
Mohamed Boudra 92afb826d9 Add inline review comments to git diff pane (#530)
* Add inline git diff review comments

Adds a review draft store keyed per diff target, inline gutter + thread
rendering in the diff pane, and a generated review attachment that flows
through the composer and agent providers.

* Strip generated review attachments before saving to draft store

Drafts persist only UserComposerAttachment (no review kind), so filter the
auto-generated review attachment out when seeding the draft or restoring
after a failed auto-submit.

* Fix inline review typecheck and test harness drift

* Unslop inline review feature

- Fix stale-closure bug in composer queueMessage: clear drafts for the
  actually-queued attachments, not the latest props
- Drop dead useEffect mirroring initialBody in inline review editor
  (parent already remounts via key)
- Restore typed AgentAttachment signature on renderPromptAttachmentAsText;
  delete schema redispatch helper and drop null-checks across 5 providers
- Inline single-use review prompt formatters and replace marker switch
  with const map
- Drop normalizeComment field-copy redundancy after type guard
- Remove __reviewDraftStoreTestUtils export; test migration via
  rehydrate() public path
- Refactor findTarget to return the target line directly (no non-null
  assertion at call site)
- Drop redundant isGeneratedReviewAttachment helper; inline kind check

* Update review pill to withUnistyles after rebase

Main's unistyles refactor stripped useUnistyles in favor of withUnistyles +
StyleSheet. Convert ReviewAttachmentPill to use ThemedCircleDot + ICON_SIZE so
the icon picks up the foreground-muted mapping like the other pills.

* Fix lint and typecheck after rebase onto main

Resolve drift after main's withUnistyles refactor:
- Composer complexity: dedup voice?.isVoiceSwitching to drop to 20
- Move per-comment Pressable handlers in git-diff-inline-review into
  CommentRow subcomponent so callbacks are stable
- Hoist style/onPress factories to module level (iconButton,
  iconButtonDestructive, ghostButton)
- Memoize style arrays in git-diff-pane review wrappers
- Replace inline {} placeholders with useMemo styles
- review-draft-store: extract applyCommentUpdates to avoid map-spread,
  include input.key in useMemo deps
- Test fixtures: hoist EMPTY_COMMENTS, add buildReviewActions helper

* Drop GitDiffPane default param to keep complexity at 20

After the rebase, main's `enabled = true` default param combined with the
inline review additions pushed cyclomatic complexity to 21. Drop the
default and resolve `enabled !== false` at the call site (comparison
doesn't count toward complexity).

* Reshape inline review behind @/review module

Drop the `generated: true` attachment flag and consolidate inline-review
logic behind a deep `@/review` module so composer and git-diff-pane stop
hand-composing review primitives. Workspace-scoped review snapshots are
now derived at submit/queue time rather than persisted in the draft
store. Composer review references drop from 51 to 4; git-diff-pane drops
~108 lines and now consumes a controller hook + slot components.

* Fix optimistic review attachments
2026-04-30 21:35:58 +08:00
Mohamed Boudra 4bcf98a9e3 feat(cli): add paseo import --provider <name> <id> to resume existing sessions (#632)
Surfaces the daemon's existing resume-from-persistence capability through a
new top-level CLI command so users can pull existing Claude Code, Codex, or
OpenCode sessions into Paseo without losing history. Adds a new additive
`import_agent_request` WebSocket message; no existing schemas change.

Refs #611, #237, #492; partially addresses #268.
2026-04-30 21:33:44 +08:00
Mohamed Boudra 2025dee9d0 chore(paseo): point worktree app at worktree daemon port directly
The app service was passing the proxy URL (daemon.<branch>.<slug>.localhost:6767)
which routes through the main daemon. Switch to the worktree daemon's own
ephemeral port so the worktree app talks to the worktree daemon without
a main-daemon hop.

Refs #637.
2026-04-30 18:54:25 +07:00
Mohamed Boudra 5bb177d33b fix(app): split local-daemon bootstrap into default vs configured override
EXPO_PUBLIC_LOCAL_DAEMON is a developer directive that the app must show
the configured host even when disconnected, so the user can see and
diagnose connection failures. The previous bootstrap conflated this with
the default "auto-add localhost:6767 if a daemon answers" behavior, and
the silent inner catch hid every failure mode.

- Split bootstrapLocalhost into bootstrapDefaultLocalhost (probe-then-
  upsert, unchanged) and bootstrapConfiguredOverride (unconditional
  upsert with placeholder serverId local:<endpoint>).
- Reconcile the placeholder to the daemon's real serverId in place when
  the probe receives server-info; rekey controllers and listeners.
- Replace the silent catch with console.warn carrying endpoint and error.
- On every override boot, purge any host owning the override connection
  under a non-matching serverId so a fresh dev daemon (rotated keypair)
  no longer leaves the registry pointing at a defunct host.
- Prune cross-endpoint placeholders when EXPO_PUBLIC_LOCAL_DAEMON changes.

Refs #637.
2026-04-30 18:12:03 +07:00
Mohamed Boudra 4cada20e38 fix(server): make Cursor CLI and other ACP custom providers reliable (#628)
* refactor(server): validate ACP mode/model selection and subscribe to live state

Match Zed's selection validity model: resolve the requested mode/model
against availableModes/configOptions before issuing the ACP RPC, log a
warning instead of throwing on unknown values, and adopt the
currentValue echoed back in setSessionConfigOption responses. Subscribe
to current_mode_update / config_option_update so AgentManager re-emits
state when the agent changes selection unprompted.

Also drops the Copilot Autopilot auto-approve shim to mirror Zed's pure
pass-through requestPermission path.

* refactor(server): emit ACP session config drift via namespaced events

Replace the parallel subscribeToSessionState callback channel with three
payload-bearing variants on the existing AgentStreamEvent union
(mode_changed, model_changed, thinking_option_changed). Manager updates
its record from the event payload and emits state — no callbacks back
into the session. Variants suppressed before websocket dispatch so the
wire schema is unchanged.
2026-04-30 18:43:27 +08:00
Mohamed Boudra f9e6a1c5ea fix(opencode): surface invalid mode/model errors instead of hanging
The OpenCode adapter sent the prompt before its SSE event subscription
was active. Since /event does not replay, terminal session.error/idle
events for invalid mode or unsupported model could land before the
subscription, leaving the agent stuck in running forever.

Await subscription readiness before promptAsync, and surface synchronous
or rejected prompt errors as turn_failed. Also force-drop sockets in
daemon shutdown so test harnesses don't hang on lingering WS upgrades.

Adds a real-opencode e2e regression test for the invalid-mode case and a
unit test for the synchronous-throw path.
2026-04-30 16:57:00 +07:00
Mohamed Boudra dec47d72d9 docs: lowercase internal docs + migrate website docs to public-docs/ (#634)
* docs: rename to lowercase + drop leftover plans

Rename docs in docs/ to lowercase kebab-case for consistency and update
all references in CLAUDE.md, CONTRIBUTING.md, CHANGELOG.md,
packages/server/CLAUDE.md, and inter-doc links.

Drop two leftover design plan docs:
- docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md
- docs/plan-approval-normalization.md

* docs: drop stale uppercase entries from case-insensitive rename

* feat(website): power /docs from public-docs/ markdown tree

Move website docs out of TSX route components and into a root-level
public-docs/ directory of plain markdown files with frontmatter
(title, description, nav, order).

- Add packages/website/src/docs.ts loader using import.meta.glob with
  ?raw to compile the markdown into the bundle at build time.
- Replace the 9 hand-written docs/*.tsx routes with a single $.tsx
  catch-all that renders any slug via react-markdown.
- Drive the docs sidebar nav from frontmatter order/nav.
- Auto-discover docs routes in vite.config.ts so the sitemap stays in
  sync without manual edits.

* fix(website): bind dev server to 0.0.0.0 so port collisions trigger fallback

`host: "127.0.0.1"` (or unset) lets macOS coexist with another process
holding an IPv6 dual-stack `*:8082` socket, so Vite never sees
EADDRINUSE and silently binds alongside it. Forcing IPv4 wildcard
makes the conflict real, and Vite's default `strictPort: false`
falls through to the next free port.

* fix(website): restore docs page styling after markdown migration

Add a .docs-prose class that mirrors the styling the original
docs/*.tsx components hand-rolled (h1/h2/h3 sizes, paragraph/list
spacing, link colors, code blocks, callout-style blockquotes).

ReactMarkdown was emitting unstyled HTML because the previous
wrapper class only had inline-code rules — headings and code
blocks fell back to user-agent defaults.
2026-04-30 17:27:15 +08:00
Mohamed Boudra 5d9dc0c4d5 fix(claude): preserve session across query restarts
Fixes the Claude query restart path that could drop conversation context after thinking effort changes or rewind-driven restarts.

Addresses #439.

Related to #156 and #200.
2026-04-30 15:37:31 +07:00
Mohamed Boudra 0b78379b77 fix: percent escaping for git --format on Windows (#629)
* test(server): add real-spawn round-trip for % in argv

RED test: pass --format=%(refname)... through spawnProcess to a child
node script that echoes argv[2]. Asserts the child receives the original
string verbatim. Wired into the Windows CI matrix to expose the
escapeWindowsCmdValue %-doubling bug under cmd.exe.

* test(server): use bare 'node' command so cmd.exe path is exercised

process.execPath has both an extension and a path separator on Windows,
so shouldUseWindowsShell returns false and the broken %-escape pipeline
is never invoked. Use the bare command name 'node' instead.

* fix(server): stop doubling % in cmd.exe arg escaping on Windows

cmd.exe only collapses `%%` → `%` inside batch files; on the command
line / via `cmd /c "..."` `%%` stays literal. Doubling `%` in
escapeWindowsCmdValue meant args like `--format=%(refname)` reached git
as `--format=%%(refname)`, which git interprets as the escape sequence
for a literal `%`, so format atoms appeared verbatim and the branch
picker showed `%(refname)%09%(committerdate:unix)` instead of branch
names.

Update unit tests that pinned the broken doubling behavior.
2026-04-30 14:22:19 +08:00
Mohamed Boudra 6a61066e2c fix(desktop): warn about Rosetta installs on Apple Silicon (#621)
* fix(desktop): warn about Rosetta installs on Apple Silicon

* docs: note workspace build sync before type fixes
2026-04-30 13:13:30 +08:00
Mohamed Boudra f0a8f207f8 docs: add pull-and-push to 0.1.65-beta.2 changelog 2026-04-30 12:10:52 +07:00
Mohamed Boudra ae43b6c4cd fix(desktop): skip packaged-app smoke when build arch differs from host
Cross-arch packaging (e.g. arm64 build on an x64 Windows runner)
can't smoke the unpacked binary because the host can't spawn it.
Skip the smoke instead of crashing with spawn UNKNOWN.
2026-04-30 12:09:13 +07:00
Mohamed Boudra f286a5beea feat(app): add pull-and-push git action (#627)
Combines `git pull` followed by `git push` behind a single action in the
git actions split-button. Push is skipped if pull fails, and errors are
surfaced through the same toast pathway as the existing pull and push
actions.
2026-04-30 12:56:09 +08:00
github-actions[bot] 3f2c33ad46 fix: update lockfile signatures and Nix hash 2026-04-30 04:49:42 +00:00
Mohamed Boudra 461387636a chore(release): cut 0.1.65-beta.2 2026-04-30 11:48:38 +07:00
Mohamed Boudra 0d2a5c04fa docs: 0.1.65-beta.2 changelog 2026-04-30 11:47:20 +07:00
Mohamed Boudra 9c3c0f9140 feat(desktop): build Windows arm64 alongside x64
Multi-arch Windows build in one electron-builder pass, with the website
self-healing across the rename. Asset names go from
`Paseo-Setup-${version}.exe` to `Paseo-Setup-${version}-{x64,arm64}.exe`.

The website now reads the actual installer filename off the latest
GitHub release, so legacy single-arch releases keep rendering a single
"Download" pill while dual-arch releases promote to "Intel / x64" +
"ARM64" automatically. No homepage code change needed when ARM ships.
2026-04-30 11:39:56 +07:00
Mohamed Boudra a7e1753834 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-30 10:38:26 +07:00
Mohamed Boudra 0aa7c768ba Fix visible changes pane diff subscription 2026-04-30 10:38:07 +07:00
Mohamed Boudra bdb8f6f535 fix(app): restore tool detail selection affordance 2026-04-30 10:34:44 +07:00
Yurui ZhouandClaude Opus 4.7 c1cfc7c743 fix(server): preserve fatal log on crash and guard socket.send against close races (#613)
The daemon was dying silently with no log trail. Two compounding causes:

1. uncaughtException/unhandledRejection handlers in index.ts called
   process.exit(1) immediately after logger.fatal, but pino is configured
   with async streams (sync: false console + rotating-file-stream), so the
   fatal entry never flushed. main().catch already worked around this with
   a 200ms setTimeout — extract exitAfterPinoFlush() and apply it to the
   process-level handlers too.

2. socket.send() in the relay transport adapter and ws.send() in
   sendToClient/sendBinaryToClient were unprotected. The ws library throws
   synchronously on send-after-close, and the readyState === 1 check has a
   race window. With an active foreground turn streaming agent_stream
   output, a peer-side disconnect could turn into an uncaughtException —
   which (per #1) then exited without logging.

Wrap the three send sites in try/catch and warn-log on failure; let
onclose/onerror drive cleanup. The readyState fast-path stays as an
optimization.

Refs getpaseo/paseo#612

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:16:48 +08:00
Mohamed Boudra 76c5ec56b5 fix(release): exclude beta versions from mobile release workflow 2026-04-29 21:37:22 +07:00
Mohamed Boudra a1db839306 fix(release): use bash 3.2-compatible array build for macOS upload
`mapfile` is bash 4+, but macOS GitHub runners ship bash 3.2, so the
upload step exited 127 on both arm64 and x64 jobs. Replaced with a
portable while-read loop.
2026-04-29 20:24:44 +07:00
Mohamed Boudra 3f1c841570 docs: 0.1.65-beta.1 changelog
Add release notes for the 0.1.65-beta.1 beta covering the atomic
manifest fix for the Apple Silicon Rosetta race (#555), the Linux
process-name fix (#602), assistant image loading states, and the
diff gutter alignment fix.
2026-04-29 20:12:51 +07:00
github-actions[bot] 1a0249bf5d fix: update lockfile signatures and Nix hash 2026-04-29 12:56:38 +00:00
Mohamed Boudra e0cb0302b9 chore(release): cut 0.1.65-beta.1 2026-04-29 19:54:45 +07:00
Mohamed Boudra 4853433ad7 fix(release): publish desktop manifests atomically to close arch race
The macOS publish matrix had each arch run electron-builder with
--publish always, so each runner uploaded its own latest-mac.yml to the
GitHub release the moment it finished. The slower arch clobbered the
faster one, leaving a window (typically 1-3 minutes) where the live
manifest contained only one arch's files[]. Apple Silicon clients
polling during that window could install the x64 build, ending up
under Rosetta.

Also closed the related window where finalize-rollout stamped
releaseDate and rolloutHours after the merged manifest was already
public — auto-updater treats missing rollout fields as "admit", so 100%
of stable users could grab a release before staged rollout kicked in.

Now every build runs --publish never. Each platform job uploads its
non-yml artifacts directly via gh release upload --clobber, and stages
its manifest as an Actions artifact. The renamed finalize-rollout job
downloads all manifest artifacts, merges mac arm64+x64, stamps rollout
metadata, validates, and uploads every manifest in one pass. Public
manifests are never visible in an unmerged or unstamped state.

Refs #555.
2026-04-29 19:47:12 +07:00
Mohamed Boudra 47223b375f feat(app): show loading and error states for assistant images
Render an activity indicator while image dimensions resolve and an
"Image unavailable" fallback if loading fails, replacing the previous
behavior where the surface stayed blank. Reserves a fixed minimum
height in both states so message layout doesn't shift.
2026-04-29 19:47:01 +07:00
Mohamed Boudra 98f2736233 fix(app): keep diff gutter row aligned when line number is blank
The recently added numberOfLines={1} on the gutter <Text> wraps the
content in display:-webkit-box; overflow:hidden on web, where a plain
ASCII space collapses to zero height. That shifted every line number up
by one row, leaving "1" next to the hunk marker. Use a non-breaking
space so the cell keeps its line height.
2026-04-28 21:58:22 +07:00
Mohamed Boudra f19007cd83 Add diagnostic logging to daemon startup 2026-04-28 21:49:35 +07:00
Mohamed Boudra 2f5f974a0e fix(desktop): match VS Code Linux sandbox handling, scope --no-sandbox to AppImage
The Linux wrapper renamed the executable to Paseo.bin and exec'd it with
--no-sandbox, which surfaced as Paseo.bin in the dock and process list on
Ubuntu. Drop the wrapper and the chrome-sandbox deletion entirely. .deb/.rpm
now ship chrome-sandbox SUID 4755 (electron-builder 26 default postinst,
matches VS Code) and run Paseo directly with the Chromium sandbox on.
AppImage cannot carry SUID, so --no-sandbox is now injected at runtime only
when process.env.APPIMAGE is set.

Fixes #602
Re #381
2026-04-28 21:19:49 +07:00
Mohamed Boudra 88141ab456 fix(server): make /compact slash-command test mock yield session.idle
The empty event-stream mock added in #597 races with the summarize-success
path: consumeEventStream sees the empty stream end immediately and emits a
"stream ended before terminal event" turn_failed which gets buffered before
session.run has assigned its local turnId. The buffered event replays after
startTurn returns, marks settled = true, and rejects completion — but
session.run skips await completion because settled is true, leaving the
rejection unhandled and tripping vitest.

Yield session.idle so the stream resolves the turn the same way real
OpenCode SSE does.
2026-04-28 20:46:40 +07:00
github-actions[bot] 354f64faa8 fix: update lockfile signatures and Nix hash 2026-04-28 13:39:59 +00:00
Mohamed Boudra 17b1d8f732 fix(server): keep setup/teardown/terminal action paths failing loudly on bad paseo.json
Follow-up to the projection fix. The mechanism/policy split landed projection
on a graceful-degrade path, but I also made the setup, teardown, and terminal
wrappers silently absorb parse failures — which broke the existing contract
that `runWorktreeSetupInBackground` surfaces "Failed to parse paseo.json" to
the user when they invoke setup against a workspace with a malformed config.

Restore the throw, but with the path included and the underlying parse error
attached as `cause` (Pino expands it; UI surfaces the path + detail). Extract
`paseoConfigParseError` so spawnWorkspaceScript and the three wrappers throw
the same shape.

Also update opencode-agent.full-access.test.ts mode-order assertion that the
reorder commit forgot to update.
2026-04-28 20:38:11 +07:00
Mohamed Boudra 283390e6d5 chore(release): cut 0.1.64 2026-04-28 20:09:12 +07:00
Mohamed Boudra dcb04a4802 docs: 0.1.64 changelog and rollout docs
Add 0.1.64 release notes and document the instant-admit release path
(release:patch + immediate desktop-rollout.yml dispatch) so future
fast-rollout releases follow a single canonical flow.
2026-04-28 20:07:25 +07:00
Mohamed Boudra 0dfd909646 fix(opencode): reorder default modes to Build -> Plan -> Full Access 2026-04-28 19:53:27 +07:00
Mohamed Boudra 00219cfd04 fix(server): isolate paseo.json parse failures so they don't block fetch_workspaces
A malformed paseo.json (e.g. git-conflict markers) in any registered
workspace caused readPaseoConfig to throw, which propagated through
getScriptConfigs -> buildWorkspaceScriptPayloads ->
Session.describeWorkspaceRecord and failed the entire
fetch_workspaces_request with code=fetch_workspaces_failed. The client
showed no workspaces at all.

Reshape: separate loading (mechanism) from policy (per-caller).

- readPaseoConfig now returns a discriminated result
  ({ ok: true; config } | { ok: false; configPath; error }). Pure load,
  no logging, no opinions.
- Workspace projection owns its own policy via readPaseoConfigForProjection
  in script-status-projection.ts: warn via pino with configPath +
  workspaceDirectory + err, treat workspace as having no scripts, keep the
  list alive.
- spawnWorkspaceScript throws with cause so the existing
  handleStartWorkspaceScriptRequest catch logs it once with full context.
- The setup/teardown/terminal wrappers retain their pre-existing silent
  degradation on missing/broken config.
- getScriptConfigs takes a parsed PaseoConfig | null instead of repoRoot
  so the projection can pass the result through without double-reading.

Closes https://github.com/getpaseo/paseo/issues/598
2026-04-28 19:48:24 +07:00
Thanh MinhandMohamed Boudra fbf10ba4c6 fix(server): support OpenCode full-access approvals (#595)
* fix(server): support OpenCode full-access approvals

* fix(opencode): unconditionally auto-approve tool permissions in full-access mode

OpenCode permissions that resolve to allow do not surface prompts in the first place, so checking runtime permission rules before replying made Paseo full-access misleading. Permissions that default to ask, like external_directory and doom_loop, would still surface even in full-access.

Make full-access match OpenCode's own --dangerously-skip-permissions behavior by replying "once" to every tool permission prompt, while still letting reply failures fall back to surfacing the permission to the user.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-28 20:47:12 +08:00
Yurui ZhouandClaude Opus 4.7 2a4945ad0b fix: keep @ file mention responsive on very large projects (#600)
Typing @ in the chat input to reference a file froze the app on huge
workspaces. Two compounding causes:

1. The autocomplete hook fed fileFilterQuery straight into the React
   Query key, so every keystroke fired a new directory_suggestions
   request and a fresh BFS scan on the daemon. Added a 180 ms debounce
   that mirrors the existing pattern in new-workspace-screen so rapid
   typing coalesces into one request.

2. searchWorkspaceEntries only ignored node_modules, so dist, build,
   target, out, coverage, vendor and __pycache__ each consumed the
   5,000-entry scan budget before any real source file was reached.
   Extended WORKSPACE_IGNORED_DIRECTORY_NAMES with those names and
   added a regression test that proves a deep source file is still
   reachable when those directories are full of bait entries.

Refs #599

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:43:38 +08:00
Thanh Minh 3ba4d09294 fix(opencode): support executable slash commands (#597) 2026-04-28 20:25:04 +08:00
Mohamed Boudra 013e53881b fix(app): prevent infinite loop in @-mention scan when @ is at index 0
`findActiveFileMention` walked back through `@` candidates with
`lastIndexOf("@", atIndex - 1)`, but per spec a negative `position`
clamps to 0 — so once `atIndex` hit 0 with an invalid query (e.g. a
trailing space), the loop stepped from 0 back to 0 forever, freezing
the JS thread on every keystroke and forcing a quit.

Refs #596, #425
2026-04-28 19:16:04 +07:00
Mohamed Boudra 9fb87b9d86 ci(deploy-app): install all workspaces so server/relay sources resolve
Typecheck pulls in @server/* sources via tsconfig path alias, but the
prior `npm install --workspace=@getpaseo/app` skipped server/relay deps,
leaving zod, @anthropic-ai/claude-agent-sdk, and @getpaseo/relay/e2ee
unresolvable on CI.
2026-04-28 18:05:27 +07:00
github-actions[bot] b557688ab3 fix: update lockfile signatures and Nix hash 2026-04-28 10:45:31 +00:00
Mohamed Boudra 599fcb34c5 chore(release): cut 0.1.63 2026-04-28 17:37:29 +07:00
github-actions[bot] 5a777169fd fix: update lockfile signatures and Nix hash 2026-04-28 10:14:44 +00:00
Mohamed Boudra 470368d910 docs: promote changelog header to 0.1.63 2026-04-28 17:13:21 +07:00
Mohamed Boudra e41fee0366 fix(desktop): close two pre-ship rollout edge cases
Bucket divisor now produces [0, 1) so the single UUID hashing to
0xFFFFFFFF is no longer permanently excluded by the strict-less-than
admit condition.

Mirrors the post-stamp manifest validation step from desktop-rollout.yml
into desktop-release.yml's finalize-rollout job so a stamp-rollout.mjs
regression can't silently ship a broken manifest.

Adds boundary tests: bucket=0 stays blocked at exact release time,
max-bucket admits at and past rollout end, unparseable releaseDate
admits, bucketFromStagingUserId is strictly < 1 at the upper bound.
2026-04-28 16:58:54 +07:00
Mohamed Boudra 25eb26db0f feat(desktop): add time-based staged rollout for stable updates
Linear ramp from 0% at publish to 100% at releaseDate + rolloutHours
(default 24h, configurable per release). Beta channel and rolloutHours=0
short-circuit to admit everyone. Per-machine bucket derives from a
persistent UUID at <userData>/.updaterId and feeds electron-updater's
isUserWithinRollout hook.

Adds desktop-rollout.yml workflow for in-place rolloutHours edits on
already-published releases (hotfix to 0 or extend the ramp), serialized
against finalize-rollout in desktop-release.yml via a shared concurrency
group keyed on the tag. Replaces the hand-rolled mac manifest merge
with a js-yaml round-trip that preserves unknown fields.
2026-04-28 16:48:18 +07:00
Mohamed Boudra 932a97e447 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-28 14:37:00 +07:00
Mohamed Boudra 3415a77497 docs: update changelog for fixes since v0.1.63-beta.5 2026-04-28 14:32:28 +07:00
Xh74d82hl 814c0660ab fix(loop): pass provider/model to daemon in loop/run (#594)
Fixes #593
2026-04-28 14:54:19 +08:00
Mohamed Boudra 01728e7c22 fix(server): apply provider model overrides to agent default resolution
Built-in provider clients bypassed mergeModels in listModels(), so
configured profileModels/additionalModels were honored by the catalog
but ignored when AgentManager picked a default for new agents.

Wrap the inner client whenever overrides exist (not only when the
provider id differs) and route listModels through mergeModels.

Fixes #579
2026-04-28 13:41:04 +07:00
Edvard Chen b58e62136e docs: add star history to README (#504) 2026-04-28 14:33:04 +08:00
Mohamed Boudra 7ccf97eb2f fix(server): detect Pi availability via SDK model registry (#586)
The hardcoded OPENAI/ANTHROPIC/OPENROUTER env-var check missed
DEEPSEEK_API_KEY and ~14 other providers the pi-ai SDK supports,
hiding the Pi provider entirely when only those keys were set.
Delegate to ModelRegistry.getAvailable() so every supported provider
is recognized automatically. Diagnostic now lists configured providers
dynamically instead of three hardcoded keys.
2026-04-28 13:31:24 +07:00
Mohamed Boudra f4fded1472 fix(app): size file preview gutter to fit 3-digit line numbers (#592)
The gutter width formula was calibrated for the diff pane's 12px font,
so 3-digit numbers wrapped to two lines in the file preview's 14px
gutter. Parameterize on font size and add numberOfLines={1} as a guard.
2026-04-28 13:28:02 +07:00
Mohamed Boudra b23e60a8f4 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-28 13:23:09 +07:00
Mohamed Boudra ea72c72998 test(server): codify subfolder of archived git repo stays as directory workspace
When a user archives a git repo and later opens a subfolder of it that is
not its own git project, the resulting workspace is a directory — not a
revived git workspace pointing at the archived parent. To get git features
back the user unarchives the parent (S6/S11).
2026-04-28 13:22:33 +07:00
github-actions[bot] cfe140deb8 fix: update lockfile signatures and Nix hash 2026-04-28 05:39:37 +00:00
Mohamed Boudra e13634380c chore(release): cut 0.1.63-beta.5 2026-04-28 12:38:33 +07:00
Mohamed Boudra 120c374361 docs: update changelog for 0.1.63-beta.5 2026-04-28 12:36:48 +07:00
Mohamed Boudra ccf248c332 fix(app): contain triple-click selection to one message bubble
Triple-clicking a message extended the document Range across the
sibling composer and into the textarea, copying both on Cmd+C. Mark
the messages region non-selectable on web and re-enable selection on
each bubble's outer container so triple-click stays scoped.
2026-04-28 12:13:31 +07:00
Mohamed Boudra 05d279cc65 fix(server): surface real errors and child-process output in diagnostics
Provider diagnostics were stringifying Error objects through JSON.stringify,
which yields {} and hides the cause. Spawned children logged stderr to the
daemon log but never attached it to thrown errors, so the diagnostic showed
"Server: Unavailable ({})" with no clue why.

Generalize toDiagnosticErrorMessage to extract message, code, signal, stderr,
stdout, and recursive cause from any Error (including execFile-style errors),
and never return {} or empty. Buffer stderr/stdout in OpenCode startServer
and include them in startup-failure rejections. Stop swallowing errors in
resolveBinaryVersion. Drop the duplicate stringifyUnknownError helpers in
opencode-agent and pi-direct-agent and route every callsite through the
single canonical helper.
2026-04-28 11:58:19 +07:00
Mohamed Boudra 221b76bf52 fix(server): keep archived workspaces out of cwd resolution (#564)
Filter archivedAt records out of the prefix-fallback resolver and stop
canonicalizing into archived parents — opening a child of an archived
git ancestor now creates a fresh workspace at the input path instead
of resurfacing the archived parent.
2026-04-28 11:32:35 +07:00
Mohamed Boudra b090b4d237 fix(server): align tests with SSH alias resolver and Windows rm flake
- session.test.ts: drop stale 4th workspaceGitService arg from
  createPullRequest assertion (signature changed in ee5a23d9).
- workspace-git-service.test.ts: bump local flushPromises microtask
  count to cover the extra await resolveGitHubRemoteForTarget step
  added by the GitHub remote resolver.
- provider-windows-launch.test.ts: retry rmSync in afterEach to
  ride out Windows file locks from the spawned claude.cmd.
2026-04-27 20:43:01 +07:00
Myriad-DreaminandMohamed Boudra c78c30ef54 Support opening workspace branches on GitHub (#583)
* Add GitHub branch open target to workspace button (#96)

* refactor: simplify workspace open targets to flat shape

Collapse the editor/url discriminated union into a single OpenTarget
{ id, label, icon, onOpen } so the button renders targets uniformly
and the mutation is shape-agnostic. Drop the heavily-mocked button
test in favor of the URL builder tests, restore the decodeURIComponent
guard for valid-syntax-but-invalid-UTF-8 pathnames, and note the SSH
host alias limitation.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-27 21:31:11 +08:00
hissin 34626e3b8f fix(server): skip home directory in workspace prefix matching (#563)
Skip the home directory in workspace prefix matching so child paths under the home workspace get their own workspace records.

Closes #562
2026-04-27 20:28:46 +08:00
Jakub Skałecki ee5a23d962 Support GitHub SSH host aliases for PR actions
Resolves the case where a remote like `git@github-work:owner/repo.git`
points at github.com via `~/.ssh/config` but Paseo treats it as
non-GitHub.

Adds a centralized GitHub remote resolver that parses standard remotes
directly and resolves SSH aliases via `ssh -G` (with hostname
validation to keep the alias out of ssh's option parser). The resolver
is shared by workspace GitHub feature detection, PR status, PR
creation, and workspace git metadata.
2026-04-27 20:28:10 +08:00
github-actions[bot] ec4f569f6e fix: update lockfile signatures and Nix hash 2026-04-27 10:26:29 +00:00
Mohamed Boudra 304e6d701a chore(release): cut 0.1.63-beta.4 2026-04-27 17:25:25 +07:00
Mohamed Boudra 0245fa59f2 docs: update changelog for 0.1.63-beta.4 2026-04-27 17:24:25 +07:00
Mohamed Boudra e250024b03 chore: remove scroll jank investigation logging 2026-04-27 17:13:52 +07:00
Mohamed Boudra 40ce317ac2 Reshape desktop bootstrap into independent services
HostRuntime self-boots via boot() and emits state through existing
subscribeAll plumbing. DaemonStartService runs in parallel: on success
it upserts the connection, which HostRuntime's syncHosts already wires
into a controller; on failure it emits an error.

The bootstrap no longer awaits daemon-start before letting saved-host
controllers connect. index.tsx resolves the redirect from
useEarliestOnlineHostServerId + useDaemonStartLastError subscriptions,
not from a procedural promise. The splash error is derived state:
splashError = !anyOnlineHostServerId ? daemonStartError : null. Retry
calls only DaemonStartService.start().

Deletes initializeHostRuntime, setupDesktopManagedConnection,
addConnectionFromListenAndWaitForOnline, bootstrapDesktop on the store,
waitForAnyConnectionOnline (now unused in production), and the
phase enum. Net -623 lines.
2026-04-27 16:36:07 +07:00
Mohamed Boudra 88f382cad3 Fix Cmd+Q on macOS leaving the desktop app alive
The before-quit handler preventDefaulted on every quit and re-fired
app.quit() from .finally. That re-entered Electron through the
window-all-closed gate, which on macOS is a no-op listener that
vetoes the quit — so the window closed but the process stayed up.

Use app.exit(0) to bypass the close → window-all-closed → will-quit
chain entirely. Also drop the daemon-stop NSAlert: with no parent
window it ran app-modal and stalled the child-process pipes for
'paseo daemon stop', so daemon shutdown waited on the user dismissing
the dialog. Replace it with an in-app overlay rendered in the renderer
via a paseo:event:quitting IPC event. Stop opening DevTools on dev boot.
2026-04-27 15:22:16 +07:00
Mohamed Boudra 2ba523ce05 Match shimmer overlay flexShrink to real label row
The shimmerText style forced flexShrink: 1 on the primary label, while
the real label has flexShrink: 0. With long secondary labels, the
shimmer truncated the primary label and shifted the secondary
truncation point, so the shimmer no longer aligned with the actual
text. Drop the override so each shimmer label inherits its base
flexShrink.
2026-04-27 14:40:09 +07:00
Mohamed Boudra 7827adeaf0 Seed paseo.json into new worktrees when missing
Lets first-time users edit project settings via the UI and have those
scripts apply to their first worktree without first having to commit
paseo.json. Committed configs are unaffected — git worktree add already
checks them out, so the seed is a no-op there.
2026-04-27 14:06:23 +07:00
Mohamed Boudra 7212cc2596 Make provider diagnostic modal scrollable on short screens
Drop the flex:1 Models section in favor of a maxHeight, and let the
outer modal scroll so the whole sheet is reachable when the viewport
can't fit it.
2026-04-27 13:51:53 +07:00
Mohamed Boudra 3b66b25f67 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-27 12:49:19 +07:00
Mohamed Boudra e9ba84ec65 Add open file button to ExpandableBadgeWebShimmerOverlay 2026-04-27 12:45:29 +07:00
Yurui ZhouandClaude Opus 4.7 df49fbe0ac feat(speech): make OpenAI realtime transcription URL configurable (#570)
Allow `OPENAI_REALTIME_URL` env var to override the hardcoded
`wss://api.openai.com/v1/realtime?intent=transcription` endpoint, with
the current value as default. Pairs with the OpenAI SDK's existing
`OPENAI_BASE_URL` support that already makes HTTP audio endpoints
reroutable. Unblocks pointing the realtime session at any OpenAI
Realtime API protocol-compatible backend (Azure OpenAI, Alibaba
Bailian / DashScope, self-hosted compat layers) without further
changes.

Closes #569

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:26:21 +08:00
Mohamed Boudra 15df680a64 [codex] Centralize subprocess env boundaries (#585)
* Centralize subprocess env boundaries

* Remove env boundary static audit test

* Tighten env boundary process launches

* Fix Windows native process launches

* Inline provider env pass-throughs
2026-04-27 13:24:16 +08:00
Mohamed Boudra 90032570ec Add macOS packaged desktop smoke gate (#578)
* Fix packaged node entrypoint env preservation

* Add packaged desktop smoke gate
2026-04-27 09:08:56 +08:00
Mohamed Boudra 1e5722c996 test(app): align projects/vim e2e specs with current UI
- Navigate Projects via the settings sidebar entry; the top-level
  Projects button never existed and the page lives under
  /settings/projects.
- Match the row's accessibility label (Edit <project>) and the
  textbox label (Worktree setup commands) the screen actually uses.
- Replace the .xterm-rows DOM selector with .xterm-screen for the
  vim layout assertion. With the WebGL renderer, .xterm-rows > div
  is empty, so the test has been red since it was added in 2b372765.
2026-04-26 23:51:02 +07:00
Mohamed Boudra 175685a912 test(app): align startup splash and host-page tests with desktop settings IPC
- Add testID="startup-splash" to the simplified splash screen so e2e helpers
  can detect desktop daemon bootstrap without depending on removed copy.
- Update startup-dsl helper to assert the testID instead of the deprecated
  "Starting local server..." text.
- Replace the obsolete localStorage write for manageBuiltInDaemon in the
  local-daemon sidebar test with a get_desktop_settings IPC mock so the
  desktop bridge surfaces the seeded host without triggering bootstrap.
2026-04-26 23:43:14 +07:00
Mohamed Boudra 29689b15f0 test(app): match inject-mcp-card switch role
Commit 5b47b659 swapped the 'On'/'Off' segmented buttons for a Switch
component (accessibilityRole=switch with label 'Inject Paseo tools').
Update the assertion to match the new role.
2026-04-26 23:32:55 +07:00
Mohamed Boudra 0daf3c209b test(cli): pass --provider explicitly in schedule create test
Commit d3f9655e removed the implicit 'claude' default for --provider on
schedule create. Test 1 still relied on the default, which fails fast
with MISSING_PROVIDER. Pass --provider claude so the assertion path that
expects target=new-agent:claude continues to hold.
2026-04-26 23:07:01 +07:00
Mohamed Boudra 08fbddae4a test(cli): align disabled-provider expectation with daemon message
Daemon emits 'Provider <id> is disabled' for disabled providers (see
session.ts emitProviderDisabledResponse). The test was asserting the
older 'is not available' phrasing, which never matched the actual
output.
2026-04-26 22:46:32 +07:00
Mohamed Boudra d7659d3f0c test(server): normalize expected cwd in worktree grouping test
classifyDirectoryForProjectMembership normalizes input.cwd via
path.resolve, which on Windows turns "/tmp/repo-feature" into
"D:\\tmp\\repo-feature". Mirror existing pattern (deriveWorkspaceId
tests at lines 91/107) and wrap the expected cwd with
normalizeWorkspaceId so the test passes on both POSIX and Windows.
2026-04-26 22:29:33 +07:00
Mohamed Boudra e8843a2875 test(app,cli): align mocks and CLI args with intentional API changes
- Add FolderPlus to lucide-react-native mock in left-sidebar.test
  (sidebar started using FolderPlus in 291a1246, mock was stale).
- Pass --provider in cli run daemon-not-running test so it reaches
  the daemon-connect step instead of erroring out at MISSING_PROVIDER
  validation introduced in 64b9b612.
2026-04-26 22:25:14 +07:00
Mohamed Boudra 8a025a6c78 test(server): default mainRepoRoot to null in primitive test helper
Same root cause as 16cf9edf — checkout-git emits mainRepoRoot directly,
so the helper must initialize it to null or downstream isWorktree
derivation flips to true on undefined !== null.
2026-04-26 22:18:32 +07:00
Mohamed Boudra 16cf9edfb1 test(server): default mainRepoRoot to null in createCheckoutStatus helper
de428582 made workspace-git-service propagate checkoutStatus.mainRepoRoot
directly. The test helper never set the field, so the runtime snapshot
emitted undefined where tests expected null.
2026-04-26 22:12:17 +07:00
Mohamed Boudra eb9492f9b9 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-26 22:04:24 +07:00
Mohamed Boudra 5e7a51364d docs(skill): rewrite paseo skill MCP-first
Replace CLI-only reference with tool-first reference covering worktree,
agent, and schedule tools. Add CLI parity section, desktop CLI fallback
paths, and ops/debugging notes.
2026-04-26 18:38:24 +07:00
Mohamed Boudra d672bc19de Fix packaged node entrypoint env preservation 2026-04-26 18:34:29 +07:00
github-actions[bot] c5c03ff956 fix: update lockfile signatures and Nix hash 2026-04-26 10:12:27 +00:00
Mohamed Boudra f79af3ffbf chore(release): cut 0.1.63-beta.3 2026-04-26 17:11:13 +07:00
Mohamed Boudra f27598af98 fix(desktop): bundle @getpaseo/cli in packaged app
The CLI module was dropped from desktop deps in ba9ed8b9 (Knip
false-positive — runtime resolves it via createRequire, which static
analysis can't see). electron-builder then shipped 0.1.63-beta.{1,2}
without app.asar/node_modules/@getpaseo/cli, breaking every daemon
status check from the desktop wrapper. Splash startup never saw the
daemon as ready and retries collided with the live pid lock.

Re-declare the dep, and switch private-workspace internal @getpaseo/*
deps to "*" so npm always resolves the workspace sibling and never
falls back to the registry. Publishable workspaces (cli, server) keep
the root-version pin so their npm tarballs reference real ranges.

Update sync-workspace-versions.mjs to preserve that shape on release
bumps, and add a packaging assertion in desktop-packaging.test.ts that
fails fast if a runtime-required workspace dep is removed again.
2026-04-26 17:10:21 +07:00
github-actions[bot] b7ee28659f fix: update lockfile signatures and Nix hash 2026-04-26 09:18:54 +00:00
Mohamed Boudra c69b42ac10 chore(release): cut 0.1.63-beta.2 2026-04-26 16:17:44 +07:00
Mohamed Boudra 151a2c513a docs: update 0.1.63-beta.2 changelog entry 2026-04-26 16:16:14 +07:00
Mohamed Boudra 01f89c7c36 refactor: strip useUnistyles in favor of withUnistyles + StyleSheet
Replaces broad useUnistyles() subscriptions in composer, workspace, and
related components with withUnistyles wrappers and StyleSheet.create so
re-renders stay scoped to the tokens actually consumed. Lifts the design
scales (spacing, iconSize, fontSize, etc.) to top-level exports in
theme.ts so component literals share a single source of truth with the
unistyles theme.
2026-04-26 16:12:09 +07:00
Mohamed Boudra 941b4fb760 fix: keep mainRepoRoot optional for backward-compat with old daemons
Newer clients were rejecting checkout_status_response and
project-checkout-lite payloads from older daemons that don't yet send
mainRepoRoot. Make the field optional with a null default so the schema
stays forward-compatible.
2026-04-26 13:44:23 +07:00
Mohamed Boudra a6b89bfdf7 perf: unblock open_project from gh pr status fetch
Placement only ever needed local git data; fetching GitHub PR status
was incidental coupling through getSnapshot, blocking the response
~5s with no feedback in the desktop app. Split it: getCheckout for
placement (local only), getSnapshot for the watch path. classify is
now a pure function over a checkout. The observer emits the full
gitRuntime/githubRuntime via workspace_update when the background
snapshot lands.
2026-04-26 13:39:08 +07:00
Mohamed Boudra 291a124663 refactor: split sidebar callout from generic Alert primitive
Rename CalloutCard to SidebarCallout (it was always sidebar-specific) and
add a shadcn-style Alert primitive with info/success/warning/error variants
for inline use in screens. Replace the misused SidebarCallout in project
settings with Alert and surface the underlying RPC error message instead
of generic copy. Tune destructive color per theme to a calm tinted red.
2026-04-26 13:16:24 +07:00
Mohamed Boudra 88ea88162c feat: prompt to configure worktree.setup when missing
Replaces the daemon version mismatch sidebar callout with a Set up
worktree scripts callout that watches the active git workspace and
points the user at the project's settings when paseo.json is missing
worktree.setup. Dismissals are sticky per project; read failures are
silent.
2026-04-26 13:06:35 +07:00
Mohamed Boudra 35de4b26e8 chore: gitignore vitest browser failure screenshots 2026-04-26 13:05:10 +07:00
Mohamed Boudra feeb6c2aa6 fix: answer terminal protocol queries on the daemon, never the browser
Browser xterm.js auto-replies to DA1/DA2/DA3 traveled back over the
websocket and arrived at the PTY after the foreground app had exited,
so the response bytes (\x1b[?62;4;9;22c) landed on the shell prompt as
visible garbage. Move all replies to the daemon's headless xterm where
they reach the PTY synchronously, and suppress every query class on
the browser side. The image addon also registers a {final:"c"} handler
inside the post-mount raf, so re-register suppression after addons
load to stay last in xterm's LIFO dispatch.

Replaces the previous printf-injection test with a real foreground
helper that queries DA1 and asserts the reply lands on stdin, plus a
browser table covering DA1/DA2/DA3 + DSR/CPR/DECRQM.
2026-04-26 12:47:50 +07:00
Mohamed Boudra 7f2592041b Merge branch 'research/paseo-json-rpc' 2026-04-26 11:56:13 +07:00
Mohamed Boudra 712cb8fc86 fix: surface Claude Grep results in search detail body
The Claude SDK delivers Grep tool_result content as a plain string, which
claude-agent.ts wraps as { output: <string> } for unrecognized tools. The
canonical ToolGrepOutputSchema requires structured fields, so parsing fell
through to an unknown detail and the UI rendered only the redundant query.
2026-04-26 11:56:01 +07:00
Mohamed Boudra 219050a7ab feat: project settings page with paseo.json editor
Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
2026-04-26 11:37:47 +07:00
Mohamed Boudra 18903bcecb refactor: simplify startup splash screen to logo with shimmer
Replace text-heavy splash ("Welcome to Paseo", "Starting local
server...", "Connecting...") with just the logo centered with a
shimmer sweep effect. Error state preserved as-is.
2026-04-26 11:30:07 +07:00
Mohamed Boudra a2c68cc689 feat: restore last workspace on cold start
Persist the user's last active workspace and navigate directly to it
on cold start instead of always landing on the open-project screen.

- Unified bootstrap into one cross-platform wait step after
  platform-specific connection setup
- Store-level wait with workspace preference: preferred host online
  resolves immediately, grace period if a different host connects
  first, bounded 5s timeout for unreachable hosts
- index.tsx is fully declarative — reads resolved target from context,
  no useEffect/promises/refs/timers
2026-04-26 11:28:46 +07:00
Mohamed Boudra 843d9234bd refactor: tighten tool call badge hover affordances
Move the open-file button inline after the summary text so the click
target sits next to what it acts on, and replace the tool icon with the
chevron on hover instead of showing a separate chevron at the row's end.
Hovering the icon slot keeps the tool icon visible for predictability.
2026-04-26 11:11:42 +07:00
Mohamed Boudra 5b47b6594f refactor: replace settings buttons with switches and simplify provider sheet
Swap daemon management and keep-running-after-quit buttons for Switch
toggles. Remove the restart/start daemon control. Simplify the provider
diagnostic sheet to reuse shared settings styles and SettingsSection.
Drop the unused "sm" size variant from the Switch component. Remove
trailing periods from hint text for consistency.
2026-04-26 08:15:28 +07:00
Mohamed Boudra 521944f19b feat: add open-file button to tool call badges
Show a file-symlink icon on hover that opens the file referenced by read,
edit, write, and simple shell commands (cat, head, tail, etc). Extracts
the file path from tool call details via a new utility with tests.
2026-04-26 08:15:16 +07:00
Mohamed Boudra 1ec448d2b9 fix: prevent bootstrap phase regression when an existing host comes online first
When a saved host reconnects before bootstrapDesktop finishes, the phase
was regressed from "online" back to "connecting". Track the race settlement
and skip the setPhase("connecting") call if anyOnline already won. Also
extract getEarliestOnlineHostServerId into HostRuntimeStore and use it to
initialize the bootstrap phase, avoiding a flash of the starting screen.
2026-04-26 08:15:06 +07:00
Mohamed Boudra b7310b9e90 feat(desktop): simplify quit lifecycle with sync PID check and shutdown dialog
Replace the async daemon status resolution during quit with a synchronous
PID file check, avoiding races when the event loop is shutting down. Show
a blocking dialog so users know the daemon is stopping. Add --kill-timeout
to the CLI stop command for the desktop app to control SIGKILL wait time.
2026-04-26 08:14:55 +07:00
Mohamed Boudra f9fcd6e032 Merge branch 'feature/desktop-daemon-settings' 2026-04-25 14:08:46 +07:00
Mohamed Boudra cfc27f2576 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-25 14:07:13 +07:00
Mohamed Boudra 4dd3b0006b Accept string form for worktree setup/teardown
Lifecycle config now takes either a multiline shell script string or an
array of commands, making it friendlier to edit in a textarea. Schema
parsing moved to Zod so coercion stays in one place.
2026-04-25 14:06:56 +07:00
github-actions[bot] a8ffbc8003 fix: update lockfile signatures and Nix hash 2026-04-25 07:04:36 +00:00
Mohamed Boudra 4b3603604a feat: enable/disable providers from settings
Adds a per-provider toggle in Settings → Providers, persisted via daemon
config, so users can turn off providers they don't have installed (or
don't want to use) without removing them from the registry.

Disabled providers stay visible everywhere — settings, `paseo provider
ls`, MCP `list_providers`, model/mode lists — marked as disabled rather
than silently filtered. Availability probes are skipped for them, and
attempts to launch an agent or fetch models/modes from a disabled
provider return a clear "Provider '<id>' is disabled" error.

Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider
summary) are backward-compatible: optional with a default of true so
older clients continue to parse new daemon messages.

CLI: `paseo provider ls` gains an ENABLED column; JSON output includes
the new field.
2026-04-25 14:00:38 +07:00
Mohamed Boudra ae3e88e7e1 feat(desktop): split daemon and release settings to main process
Move daemon management and release channel settings out of the renderer's
AsyncStorage and into a desktop-owned settings store persisted by the main
process. Adds a "Keep daemon running after quit" option, a daemon lifecycle
section in settings, and a one-time migration of legacy renderer-owned
desktop settings.
2026-04-25 13:59:07 +07:00
Mohamed Boudra de42858213 fix: reopen worktrees under the right project 2026-04-24 21:43:23 +07:00
Mohamed Boudra db8934c53e fix: keep unavailable provider agents in history 2026-04-24 21:43:09 +07:00
Mohamed Boudra 4b7199f53b feat: improve provider settings and model selection 2026-04-24 21:42:47 +07:00
Mohamed Boudra d3f9655e33 fix: require providers for new CLI agents 2026-04-24 21:40:59 +07:00
Mohamed Boudra 2b37276577 Fix initial terminal resize for nvim 2026-04-24 15:57:13 +07:00
Mohamed Boudra 83689ef0c5 fix(app): restore assistant link color 2026-04-24 14:03:49 +07:00
Mohamed Boudra 9a4528129f fix: suppress terminal query response leaks 2026-04-24 13:58:53 +07:00
Mohamed Boudra 231ebdcfb1 fix: preserve zsh prompt in packaged desktop (#544) 2026-04-24 13:54:20 +07:00
781 changed files with 60256 additions and 18730 deletions
+48 -9
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -31,7 +31,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -47,7 +47,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -74,7 +74,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
@@ -104,7 +104,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -122,9 +122,12 @@ jobs:
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
@@ -135,6 +138,35 @@ jobs:
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
desktop-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- 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: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
app-tests:
runs-on: ubuntu-latest
steps:
@@ -142,7 +174,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -164,7 +196,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -207,7 +239,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -220,13 +252,18 @@ jobs:
run: npm run test --workspace=@getpaseo/relay
cli-tests:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
runs-on: ubuntu-latest
name: cli-tests (shard ${{ matrix.shard }}/3)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Install dependencies
@@ -244,3 +281,5 @@ jobs:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
PASEO_DICTATION_ENABLED: "0"
PASEO_VOICE_MODE_ENABLED: "0"
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
PASEO_CLI_TEST_SHARD_TOTAL: "3"
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
scope: "@boudra"
- name: Install dependencies
run: npm install --workspace=@getpaseo/app --include-workspace-root
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+226 -143
View File
@@ -24,6 +24,24 @@ on:
- macos
- linux
- windows
checkout_ref:
description: "Optional branch/ref to checkout while using tag for release metadata."
required: false
default: ""
type: string
publish:
description: "Publish built artifacts to GitHub Releases."
required: false
default: "true"
type: choice
options:
- "true"
- "false"
rollout_hours:
description: "Linear rollout duration in hours. Use 0 for instant rollout."
required: false
default: "24"
type: string
concurrency:
group: desktop-release-${{ github.ref }}
@@ -31,12 +49,14 @@ concurrency:
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
DESKTOP_WORKSPACE: "@getpaseo/desktop"
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all' && github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
@@ -44,7 +64,7 @@ jobs:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -91,7 +111,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -127,9 +147,6 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
env:
@@ -140,123 +157,40 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.IS_SMOKE_TAG != 'true'
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync(manifestName, output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
publish-linux:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
@@ -269,7 +203,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -305,25 +239,47 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Install Linux smoke display
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
build_args=(-- --publish never --linux --x64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: linux-manifest
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-linux.yml
retention-days: 1
publish-windows:
needs: [create-release]
@@ -337,7 +293,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release metadata
shell: bash
@@ -373,30 +329,157 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build workspace dependencies
run: npm run build:workspace-deps --workspace=@getpaseo/app
- name: Build web app for desktop
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npx expo export --platform web
working-directory: packages/app
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
build_args=(-- --publish never --win --x64 --arm64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: windows-manifest
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}.yml
retention-days: 1
finalize-rollout:
needs: [publish-macos, publish-linux, publish-windows]
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
concurrency:
group: desktop-rollout-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
package-lock.json
scripts
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download mac manifest artifacts
if: env.IS_SMOKE_TAG != 'true' && needs.publish-macos.result == 'success'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
path: release-manifests
- name: Download Linux manifest artifact
if: env.IS_SMOKE_TAG != 'true' && needs.publish-linux.result == 'success'
uses: actions/download-artifact@v4
with:
name: linux-manifest
path: release-manifests/linux-manifest
- name: Download Windows manifest artifact
if: env.IS_SMOKE_TAG != 'true' && needs.publish-windows.result == 'success'
uses: actions/download-artifact@v4
with:
name: windows-manifest
path: release-manifests/windows-manifest
- name: Assemble and upload stamped manifests
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
manifests_dir="$PWD/final"
mkdir -p "$manifests_dir"
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
manifest_name="${RELEASE_CHANNEL}-mac.yml"
node ../scripts/merge-mac-manifest.mjs \
"mac-manifest-arm64/${manifest_name}" \
"mac-manifest-x64/${manifest_name}" \
"$manifests_dir/${manifest_name}"
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
if [[ "${{ needs.publish-linux.result }}" == "success" ]]; then
cp "linux-manifest/${RELEASE_CHANNEL}-linux.yml" "$manifests_dir/"
fi
if [[ "${{ needs.publish-windows.result }}" == "success" ]]; then
cp "windows-manifest/${RELEASE_CHANNEL}.yml" "$manifests_dir/"
fi
shopt -s nullglob
files=( "$manifests_dir"/*.yml )
if (( ${#files[@]} == 0 )); then
echo "::error::No manifest artifacts were available to publish"
exit 1
fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
const yaml = require("js-yaml");
const fs = require("fs");
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
}
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
if (m.rolloutHours !== expectedHours) {
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
}
if (m.releaseDate !== expectedDate) {
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
}
if (typeof m.version !== "string" || m.version.length === 0) {
throw new Error(`${f}: missing or invalid version`);
}
}
' "${files[@]}"
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
+152
View File
@@ -0,0 +1,152 @@
name: Desktop Rollout
on:
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to re-stamp (e.g. v0.1.42)."
required: true
type: string
rollout_hours:
description: "Total rollout duration since the original release date, in hours. Use 0 to admit everyone immediately."
required: true
type: string
concurrency:
group: desktop-rollout-${{ inputs.tag }}
cancel-in-progress: false
env:
SOURCE_TAG: ${{ inputs.tag }}
jobs:
stamp:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
package-lock.json
scripts
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download release manifests
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir release-manifests
cd release-manifests
gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml"
shopt -s nullglob
files=( ./*.yml )
if (( ${#files[@]} == 0 )); then
echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG"
exit 1
fi
echo "Downloaded ${#files[@]} manifest(s):"
printf ' %s\n' "${files[@]}"
- name: Capture before state
id: before
shell: bash
run: |
set -euo pipefail
cd release-manifests
summary=$(node -e '
const yaml = require("js-yaml");
const fs = require("fs");
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
console.log(` ${f}: rolloutHours=${m.rolloutHours ?? "<unset>"} releaseDate=${m.releaseDate ?? "<unset>"}`);
}
' ./*.yml)
echo "$summary"
{
echo "summary<<EOF"
echo "$summary"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Re-stamp rolloutHours
env:
NEW_HOURS: ${{ inputs.rollout_hours }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
node ../scripts/stamp-rollout.mjs --rollout-hours "$NEW_HOURS" ./*.yml
- name: Validate rewritten manifests
env:
EXPECTED: ${{ inputs.rollout_hours }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
node -e '
const yaml = require("js-yaml");
const fs = require("fs");
const expected = Number(process.env.EXPECTED);
if (!Number.isFinite(expected) || expected < 0) {
throw new Error(`EXPECTED must be a non-negative number, got ${process.env.EXPECTED}`);
}
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
if (m.rolloutHours !== expected) {
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expected}`);
}
if (typeof m.version !== "string" || m.version.length === 0) {
throw new Error(`${f}: missing or invalid version`);
}
}
' ./*.yml
- name: Upload to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
gh release upload "$RELEASE_TAG" ./*.yml --clobber --repo "${{ github.repository }}"
- name: Write summary
env:
BEFORE: ${{ steps.before.outputs.summary }}
shell: bash
run: |
{
echo "## Rollout updated"
echo ""
echo "**Tag:** \`$RELEASE_TAG\`"
echo "**Channel:** \`$RELEASE_CHANNEL\`"
echo "**New rolloutHours:** \`${{ inputs.rollout_hours }}\`"
echo ""
echo "### Before"
echo '```'
echo "$BEFORE"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
+4
View File
@@ -1,5 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"options": {
"typeAware": false
},
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
@@ -53,6 +56,7 @@
"typescript/no-explicit-any": "error",
"typescript/prefer-as-const": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/consistent-type-definitions": ["error", "interface"],
"import/no-unassigned-import": [
+116 -3
View File
@@ -1,16 +1,129 @@
# Changelog
## 0.1.63-beta.1 - 2026-04-24
## 0.1.67 - 2026-05-03
### Fixed
- Archiving a worktree or workspace feels instant instead of waiting on the daemon, with automatic rollback if it fails.
- The built-in daemon toggle in desktop settings now actually takes effect.
- Desktop settings no longer reset on app launch after a legacy migration.
- Desktop daemon startup failures now surface on the splash screen and respond to retry, instead of leaving the app silently stuck.
- Internal LLM calls (branch names, commit messages, PR text) no longer leave behind ephemeral agent sessions in your provider history.
## 0.1.66 - 2026-05-03
### Fixed
- Streaming markdown preserves trailing newlines so paragraph spacing stays correct while the agent is still typing.
- Agent initialization failures surface within 30 seconds instead of 5 minutes.
- Terminals reply to ANSI cursor-position queries, so tools that ask for cursor location no longer hang.
## 0.1.65 - 2026-05-03
### Added
- **In-app browser** — open a real web browser in any workspace to test your app. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
- Inline review comments in the git diff pane. Tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
- Sub-agent activity is now shown for Codex, OpenCode, and Claude. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
- Pull and push your branch in one step from the git actions menu in the changes pane.
- Resume existing agent sessions with `paseo import --provider <name> <id>`. ([#632](https://github.com/getpaseo/paseo/pull/632))
- Password authentication and SSL support for daemon connections. ([#635](https://github.com/getpaseo/paseo/pull/635))
- Connect to a daemon via relay using a pairing offer URL from the CLI. ([#639](https://github.com/getpaseo/paseo/pull/639))
- **Windows:** Native ARM64 builds are now available.
- Bundled Paseo skills now refresh automatically on desktop app launch.
### Improved
- Codex streaming feels more responsive — message boundaries are preserved and output arrives sooner.
- Terminal sessions run in a dedicated worker process for better stability.
- New worktree branch names are derived from your prompt and attachments instead of a generic placeholder.
- Review comment UI is cleaner and easier to scan.
- The daemon's `/api/status` endpoint is now protected by password auth when one is configured.
### Fixed
- **Apple Silicon Mac:** The desktop update pipeline now publishes manifests atomically, closing a race that could install the Intel build on Apple Silicon Macs and cause 100%+ renderer CPU usage. Affected users will self-heal — electron-updater's Rosetta detection migrates back to arm64 on the next update poll. ([#555](https://github.com/getpaseo/paseo/issues/555))
- **Linux:** `.deb` and `.rpm` packages now show as `Paseo` in the dock and process list instead of `Paseo.bin`. `--no-sandbox` is now scoped to AppImage only, matching VS Code's sandbox handling. ([#602](https://github.com/getpaseo/paseo/issues/602))
- **Windows:** Git diff commands no longer break on paths with special characters. ([#629](https://github.com/getpaseo/paseo/pull/629))
- Cursor CLI and other ACP custom providers launch reliably. ([#628](https://github.com/getpaseo/paseo/pull/628))
- Daemon stays up when WebSocket clients disconnect mid-stream, and crashes now write a fatal log entry instead of disappearing silently. ([#613](https://github.com/getpaseo/paseo/pull/613) by [@yuruiz](https://github.com/yuruiz))
- Long agent timelines reconnect cleanly over the relay instead of looping through disconnects while catching up. ([#657](https://github.com/getpaseo/paseo/pull/657) by [@fireblue](https://github.com/fireblue))
- Agent timelines refresh with smaller catch-up requests when you reopen an agent.
- Terminal snapshots flush reliably before clients reconnect.
- Workspace reconnects avoid unnecessary refresh work when the focused workspace is already current.
- Voice dictation keeps recording when the agent tab loses focus.
- OpenCode mode picker now lists agents available in every mode. ([#606](https://github.com/getpaseo/paseo/pull/606) by [@thisisryanswift](https://github.com/thisisryanswift))
- Codex plan approval panels no longer duplicate.
- Imported agents display the correct title immediately.
- OpenCode surfaces invalid mode/model errors instead of hanging.
- Archived worktrees stay hidden without flashing back into the list. ([#640](https://github.com/getpaseo/paseo/pull/640))
- Web dropdown menus no longer resize unexpectedly.
- The visible changes pane keeps in sync with the working tree diff.
- Tool detail rows on the timeline are selectable again.
- `paseo.json` parse errors in setup, teardown, and terminal actions now surface a clear error instead of failing silently.
- Diff gutter line numbers were shifted one row out of alignment in some cases on web.
- Streamed agent output reconciles cleanly when the timeline hydrates mid-turn. ([#663](https://github.com/getpaseo/paseo/pull/663))
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
- Isolated bottom sheet modals close and re-open without getting stuck.
## 0.1.64 - 2026-04-28
### Added
- OpenCode now has a Full Access mode that auto-approves tool calls. ([#595](https://github.com/getpaseo/paseo/pull/595) by [@tmih06](https://github.com/tmih06))
- OpenCode supports executable slash commands. ([#597](https://github.com/getpaseo/paseo/pull/597) by [@tmih06](https://github.com/tmih06))
### Improved
- `@`-mention stays responsive on very large projects. ([#600](https://github.com/getpaseo/paseo/pull/600) by [@yuruiz](https://github.com/yuruiz))
### Fixed
- Workspaces still load when `paseo.json` has a parse error.
## 0.1.63 - 2026-04-28
### Added
- Project settings page with a built-in `paseo.json` editor.
- Cold start restores your last open workspace.
- Tool call badges have a button to open the referenced file directly.
- Open the current branch on GitHub from a workspace's open menu. ([#583](https://github.com/getpaseo/paseo/pull/583) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin))
- Enable or disable providers from Settings without editing config files.
- Paseo prompts you to configure a worktree setup script when one is missing.
- Choose whether the daemon shuts down when you close the desktop app.
### Improved
- Provider settings and model selection have been redesigned.
- Voice mode transcription endpoint is configurable for OpenAI-compatible providers. ([#570](https://github.com/getpaseo/paseo/pull/570) by [@yuruiz](https://github.com/yuruiz))
- Adding a project no longer waits for GitHub PR status to load.
- Startup splash screen is cleaner — just the logo with a subtle shimmer.
- `paseo.json` setup and teardown accept a single command string, not just an array.
- Archiving a worktree is instant instead of waiting for the backend to confirm.
- Terminal sessions recover cleanly after rendering hiccups.
- Agent timelines and git diff lists no longer jump around while loading or streaming.
### Fixed
- `paseo loop run` and `paseo run` now respect the `--provider` and `--model` flags. ([#594](https://github.com/getpaseo/paseo/pull/594) by [@VincenzoRocchi](https://github.com/VincenzoRocchi))
- Pi provider shows up when only DeepSeek or other non-OpenAI/Anthropic/OpenRouter API keys are set.
- Custom models from `additionalModels` and `profileModels` are honored when picking a default for new agents.
- File preview line numbers stay on one line past line 99.
- Cmd+Q on macOS quits the desktop app instead of leaving it running in the background.
- Terminal sessions recover cleanly after rendering hiccups, including the initial resize for nvim.
- Terminal protocol query responses no longer leak into the browser.
- Assistant link color matches the theme again.
- File links with line numbers (like `foo.ts:42`) open correctly from assistant messages.
- Claude's Grep results show up in the search detail body.
- Reopening a worktree lands under the right project.
- Agents from disabled or unavailable providers stay visible in history.
- New CLI agents now require a provider instead of failing silently.
- Git diff headers no longer truncate.
- Provider diagnostic modal scrolls on short screens.
- Provider diagnostics show the real error and underlying child-process output instead of a generic message.
- Archived workspaces no longer interfere with working-directory resolution.
- Triple-click on a message no longer extends the selection into adjacent bubbles.
- The packaged desktop app preserves your zsh prompt.
## 0.1.62 - 2026-04-23
@@ -142,7 +255,7 @@
### Added
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
- Choose provider and model when creating scheduled agents.
- Max reasoning effort option for Opus 4.6 models.
+20 -9
View File
@@ -19,14 +19,14 @@ This is an npm workspace monorepo:
| 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 |
| [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
@@ -41,7 +41,7 @@ npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
@@ -55,6 +55,9 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **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`
@@ -100,6 +103,14 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
```
components/
browser-pane.electron.tsx ← Electron <webview> implementation
browser-pane.web.tsx ← plain web fallback
browser-pane.tsx ← native fallback
```
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
+6 -6
View File
@@ -27,10 +27,10 @@ If you want to propose a direction change, start a conversation.
Please read these first:
- [README.md](README.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
- [docs/TESTING.md](docs/TESTING.md)
- [docs/architecture.md](docs/architecture.md)
- [docs/development.md](docs/development.md)
- [docs/coding-standards.md](docs/coding-standards.md)
- [docs/testing.md](docs/testing.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
@@ -105,7 +105,7 @@ npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
@@ -132,7 +132,7 @@ If you touch protocol or shared client/server behavior, read the compatibility n
Paseo has explicit standards. Follow them.
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
## PR checklist
+10
View File
@@ -144,6 +144,16 @@ npm run build:daemon
npm run typecheck
```
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0
@@ -1,158 +0,0 @@
# Attachment-Based Review Context Plan
## Goal
Use structured attachments as the source of truth for review context during agent creation.
This covers two related behaviors:
1. Provider-facing context for the agent prompt
2. Worktree checkout behavior when creating a worktree from a review item
The design should stay compatible with future forge support such as GitLab.
## Core Direction
- Do not parse the prompt to discover issue/PR intent.
- Do not introduce deeply nested transport objects for this flow.
- Make the attachment itself the discriminated union.
- Keep forge-specific checkout logic below the attachment layer.
- Let each agent provider translate attachments into its own prompt/input format.
## Initial Attachment Types
Two initial structured attachment types:
- `github_pr`
- `github_issue`
With separate MIME types:
- `application/github-pr`
- `application/github-issue`
Proposed wire shape:
```ts
type AgentAttachment =
| {
type: "github_pr";
mimeType: "application/github-pr";
number: number;
title: string;
url: string;
body?: string | null;
baseRefName?: string | null;
headRefName?: string | null;
}
| {
type: "github_issue";
mimeType: "application/github-issue";
number: number;
title: string;
url: string;
body?: string | null;
};
```
## Backward Compatibility
- Add new optional `attachments` fields; keep existing `images` fields.
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
- Malformed attachment entries should also be ignored safely.
- Old clients continue working because `attachments` is optional.
- New clients can send `attachments` without breaking older flows that still rely on `images`.
## Request-Level Changes
Add optional `attachments` to:
- `create_agent_request`
- `send_agent_message_request`
Existing `initialPrompt` remains plain user text.
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
## App Responsibilities
When the user selects a GitHub item:
- If it is a PR, create a `github_pr` attachment
- If it is an issue, create a `github_issue` attachment
- Include the attachment in the create-agent request
The UI can still render friendly labels and previews from the same data.
The app should stop treating PR/issue context as prompt-only metadata.
## Worktree Creation Behavior
During agent creation, if worktree creation is requested:
- Inspect normalized attachments
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
The important rule:
- attachment type identifies the review object
- server-side git logic decides how to check it out
This keeps the attachment contract simple while allowing fork-safe implementation details.
## Checkout Resolution
The attachment itself should not encode the full checkout strategy.
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
Examples of possible implementation strategies:
- `gh pr checkout`
- `git fetch origin refs/pull/<number>/head:<local-branch>`
- another GitHub-aware resolver if needed later
This is intentionally a server implementation detail, not part of the attachment schema.
## Provider Responsibilities
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
Examples:
- Claude: render attachment into text blocks
- Codex: inject as blocks or text
- OpenCode: send as file/resource-like input where appropriate
- ACP: convert to resource/text forms as supported
The session layer should not hardcode one translation strategy for all providers.
## Suggested Implementation Order
1. Add tolerant attachment schema and normalization in shared/server message handling.
2. Thread `attachments` through app -> daemon client -> session layer.
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
6. Optionally migrate image transport into the same attachment mechanism later.
## Files Likely Involved
- `packages/server/src/shared/messages.ts`
- `packages/server/src/client/daemon-client.ts`
- `packages/server/src/server/session.ts`
- `packages/server/src/server/worktree-session.ts`
- `packages/server/src/server/agent/agent-sdk-types.ts`
- `packages/server/src/server/agent/providers/claude-agent.ts`
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/acp-agent.ts`
- `packages/app/src/screens/new-workspace-screen.tsx`
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
- `packages/app/src/contexts/session-context.tsx`
## Notes
- The current image path can remain in place initially.
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.
View File
@@ -137,6 +137,21 @@ Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
### Compatibility rules
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
Example: adding a new enum value
```ts
// 1. Add CLIENT_CAPS.newThing = "new_thing"
// 2. Let new clients advertise it in WS hello
// 3. Keep the shared producer schema strict
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
## Agent lifecycle
```
+247
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, 8–12px 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` |
+2 -2
View File
@@ -17,7 +17,7 @@ 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 layers exist? (See [architecture.md](./architecture.md))
- What types and data shapes are already defined?
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.
@@ -38,7 +38,7 @@ If you can't define verification, you don't understand the feature well enough y
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
### Layers
@@ -62,6 +62,18 @@ Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
```json
{
"worktree": {
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"teardown": "npm run db:drop || true"
}
}
```
Every `scripts` entry with `"type": "service"` receives these environment variables:
| Variable | Value |
+45
View File
@@ -0,0 +1,45 @@
# Paseo Glossary
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 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: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: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/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.
@@ -261,4 +261,4 @@ xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [development.md](development.md)).
-68
View File
@@ -1,68 +0,0 @@
# Plan Approval Normalization
## Goal
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
## Compatibility Constraints
- Older clients must remain compatible with newer daemons.
- All new wire fields must be optional.
- Existing plan permissions without action metadata must still render and work.
- Existing question permissions must keep their current behavior.
## Design
### Shared abstraction
Add optional permission action definitions to the shared permission request/response types.
- Permission requests may include `actions`.
- Permission responses may include `selectedActionId`.
- `kind: "plan"` remains the normalized concept for plan approval.
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
### Claude
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
- Always expose `Reject`.
- Always expose `Implement`.
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
- Resolve the selected action entirely inside `respondToPermission()`.
### Codex
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
- Emit a plan permission with `Reject` and `Implement` actions.
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
- On `Reject`, resolve without starting a follow-up turn.
- Keep the implementation prompt and state transitions inside the Codex provider.
### Manager and state sync
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
- Refresh current mode
- Refresh pending permissions
- Refresh runtime info
- Refresh features
- Persist refreshed state
### UI
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
- If `actions` are absent, fall back to legacy buttons.
- Plan cards should use `Implement` as the default primary label.
- Do not add provider-specific rendering branches.
## Verification
1. Shared schema/type tests for optional `actions` and `selectedActionId`
2. App tests for generic plan-action rendering
3. Claude tests for third action when resuming from a more permissive mode
4. Codex tests for synthetic plan approval and automatic implementation follow-up
5. Manager tests for post-permission state refresh
6. `npm run typecheck`
View File
+101 -1
View File
@@ -59,6 +59,94 @@ Use the beta path when you need to:
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
### Default behavior
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
```bash
# 1. Cut and publish (default 24h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
gh workflow run desktop-rollout.yml \
-f tag=v0.1.64 \
-f rollout_hours=0
```
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
### Adjusting an already-published release
To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated `desktop-rollout.yml` workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites `rolloutHours`; `releaseDate` is preserved, so the rollout clock keeps ticking from the original publish time.
**Hotfix (instant admit) on an already-shipped release:**
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=0
```
`rollout_hours=0` admits 100% of stable users on their next update check (within ~30 min for active clients).
**Slow a rollout down** (e.g. extend total duration to 72h since the original release):
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=72
```
`rollout_hours` is **total duration since the original release date**, not "extend by N more hours from now." If `v0.1.42` was published 2h ago and you set `rollout_hours=72`, the ramp finishes 70h from now.
The dispatch is idempotent and shares the `desktop-rollout-<tag>` concurrency group with `desktop-release.yml`'s `finalize-rollout` job, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
### Custom ramp on a manually-dispatched build
`desktop-release.yml` accepts `rollout_hours` only on `workflow_dispatch`, which is the path used to **rebuild an existing tag** (retry a failed release, force a rebuild on a different ref). When you go that route, you can stamp a non-default ramp directly:
```bash
gh workflow run desktop-release.yml \
-f tag=v0.1.43 \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### Limitations
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **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.
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
@@ -140,6 +228,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -169,7 +259,17 @@ Rules:
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
- **Credit the commit author, not the PR opener.** A maintainer often opens a PR that lands work authored by someone else (cherry-pick, rebase of a contributor's branch, manual extraction from a stacked PR). The squash commit preserves the original commit's author, but `gh pr view N --json author` returns the PR opener — using that field will silently mis-credit the work to the maintainer (and then the "skip @boudra" rule drops the attribution entirely). Always resolve attribution from commit authors.
Use this command to get the GitHub logins for each PR:
```bash
gh pr view N --json commits --jq '[.commits[].authors[].login] | unique | .[]'
```
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
View File
+68
View File
@@ -4,6 +4,74 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Forbidden
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.
```tsx
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.surface0,
padding: theme.spacing[4],
},
}));
<View style={styles.container} />;
```
If you are reading a theme value just to feed it back into a `style` prop, you almost certainly want this and not the hook.
### 2. Hard-coded constants for genuinely static values
If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static.
### 3. `withUnistyles(Component)` for third-party props
When a third-party component takes a non-`style` prop that must be theme-reactive (e.g. `BlurView.tint`, `Image.tintColor`, navigator option props, bottom-sheet `backgroundStyle`), wrap that single component with `withUnistyles`. Only the wrapper re-renders, not the surrounding tree.
```tsx
const ThemedBlur = withUnistyles(BlurView);
<ThemedBlur tint={theme.colors.surface0} />;
```
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
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 / `AgentStreamSection` / `AgentComposerSection`
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, tabs row, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
+1 -1
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-0Izp63gVeBO9Efo4Uf8/EB3TkQ9FXJ4Td2Fe5UHm9eE=";
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).
+701 -503
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.63-beta.1",
"version": "0.1.67",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -63,7 +63,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
@@ -94,11 +94,14 @@
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
"lefthook": "^2.1.6",
"oxfmt": "0.46.0",
"oxlint": "1.61.0",
"oxlint-tsgolint": "^0.22.1",
"patch-package": "^8.0.1",
"playwright": "^1.56.1",
"typescript": "^5.9.3",
@@ -5,6 +5,7 @@ on:
tags:
- "v*"
- "!v*-rc.*"
- "!v*-beta.*"
workflow_dispatch: {}
jobs:
@@ -42,11 +43,10 @@ jobs:
name: Submit iOS for App Store review
needs: [submit_ios]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
working_directory: ./packages/app
run: bundle install
- name: Submit for review
working_directory: ./packages/app
run: bundle exec fastlane ios submit_review
+3
View File
@@ -37,6 +37,9 @@ yarn-error.*
# typescript
*.tsbuildinfo
# vitest browser failure screenshots
.vitest-screenshots/
app-example
# generated native folders
@@ -0,0 +1,61 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
page,
}) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("codex-plan-approval-");
const client = await connectTerminalClient();
try {
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Codex plan approval e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "Emit synthetic plan approval.",
});
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await waitForPermissionPrompt(page, 120_000);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(1);
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
await allowPermission(page);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, {
timeout: 30_000,
});
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -31,7 +31,7 @@ test.beforeAll(async () => {
if (!result.workspace) {
throw new Error(result.error ?? "Failed to seed workspace");
}
workspaceId = String(result.workspace.id);
workspaceId = result.workspace.id;
});
test.afterAll(async () => {
+96 -94
View File
@@ -1,8 +1,13 @@
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";
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
// 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 }>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -10,102 +15,99 @@ const test = base.extend({
}
await provide(`http://localhost:${metroPort}`);
},
});
const consoleEntries = new WeakMap<Page, string[]>();
test.beforeEach(async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
);
}
// Hard guardrail: never allow tests to hit the developer's default daemon.
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
const entries: string[] = [];
consoleEntries.set(page, entries);
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
const nowIso = new Date().toISOString();
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
}
const testDaemon = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === nonce) {
return;
}
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
);
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
// Hard guardrail: never allow tests to hit the developer's default daemon.
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
const entries: string[] = [];
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
const nowIso = new Date().toISOString();
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
}
const testDaemon = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
);
await provide();
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
}
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
);
});
test.afterEach(async ({ page }, testInfo) => {
const entries = consoleEntries.get(page);
if (!entries || entries.length === 0) {
return;
}
if (testInfo.status === testInfo.expectedStatus) {
return;
}
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
{ auto: true },
],
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);
await handle.cleanup();
},
});
export { test, expect, type Page };
+1
View File
@@ -585,6 +585,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_NODE_ENV: "development",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
+17
View File
@@ -0,0 +1,17 @@
import { expect, type Page } from "@playwright/test";
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 });
}
-17
View File
@@ -658,23 +658,6 @@ export const createAgentInRepo = async (
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 });
+18
View File
@@ -0,0 +1,18 @@
import { expect, type Page } from "@playwright/test";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
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 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();
}
+17
View File
@@ -0,0 +1,17 @@
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();
}
+52
View File
@@ -0,0 +1,52 @@
import { expect, type Page } from "@playwright/test";
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();
}
+21
View File
@@ -0,0 +1,21 @@
import { expect, type Page } from "@playwright/test";
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 });
}
+1 -2
View File
@@ -140,7 +140,6 @@ class StartupAssertions {
async expectsReconnectWelcome(): Promise<this> {
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
@@ -164,7 +163,7 @@ class StartupAssertions {
}
async expectsDesktopDaemonStartup(): Promise<this> {
await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({
await expect(this.page.getByTestId("startup-splash")).toBeVisible({
timeout: 15_000,
});
return this;
@@ -24,6 +24,10 @@ export interface TerminalPerfDaemonClient {
cwd: string;
title?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal(
+4 -6
View File
@@ -204,8 +204,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
probe.writeCount += 1;
const preview = text
.replaceAll("\u001b", "\\x1b")
@@ -424,7 +423,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
const count = Math.min(from.length, to.length);
const values: number[] = [];
for (let index = 0; index < count; index += 1) {
values.push(to[index]!.at - from[index]!.at);
values.push(to[index].at - from[index].at);
}
return values;
}
@@ -459,7 +458,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
const keydownToInputFrame = this.keydowns
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
.filter((at): at is number => at !== null)
.map((at, index) => at - this.keydowns[index]!.at);
.map((at, index) => at - this.keydowns[index].at);
const inputFrameToOutputFrame = this.inputFrames
.map((input) => {
const output = firstAtOrAfter(this.outputFrames, input.at);
@@ -663,8 +662,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
const event: XtermWriteEvent = {
at: performance.now(),
committedAt: null,
+105
View File
@@ -0,0 +1,105 @@
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);
}
},
};
}
+3 -3
View File
@@ -265,7 +265,7 @@ export async function createWorkspaceThroughDaemon(
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
}
return {
id: String(result.workspace.id),
id: result.workspace.id,
name: result.workspace.name,
};
}
@@ -291,7 +291,7 @@ export async function findWorktreeWorkspaceForProject(
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
}
return {
id: String(workspace.id),
id: workspace.id,
name: workspace.name,
projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory,
@@ -308,7 +308,7 @@ export async function fetchWorkspaceById(
projectRootPath: string;
}> {
const payload = await client.fetchWorkspaces();
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
const workspace = payload.entries.find((entry) => entry.id === workspaceId) ?? null;
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
+26 -8
View File
@@ -9,10 +9,35 @@ interface TempRepo {
cleanup: () => Promise<void>;
}
async function configureRemote(input: {
repoPath: string;
withRemote: boolean;
originUrl: string | undefined;
}): Promise<void> {
const { repoPath, withRemote, originUrl } = input;
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
return;
}
if (originUrl) {
// Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine.
execSync(`git remote add origin ${JSON.stringify(originUrl)}`, {
cwd: repoPath,
stdio: "ignore",
});
}
}
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
options?: {
withRemote?: boolean;
originUrl?: string;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
branches?: string[];
@@ -74,14 +99,7 @@ export const createTempGitRepo = async (
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
}
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
}
await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl });
return {
path: repoPath,
+1 -1
View File
@@ -32,7 +32,7 @@ test.beforeAll(async () => {
seedClient = await connectTerminalClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
workspaceId = String(result.workspace.id);
workspaceId = result.workspace.id;
});
test.afterAll(async () => {
+151
View File
@@ -0,0 +1,151 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
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";
const updatedSetup = ["npm install", "npm run build"];
interface ProjectsSettingsProject {
name: string;
path: string;
}
interface ProjectsSettingsFixtures {
editableProject: ProjectsSettingsProject;
gitlabRemoteProject: ProjectsSettingsProject;
}
const initialPaseoConfig = {
worktree: {
setup: ["echo initial setup"],
teardown: "echo cleanup",
customWorktreeField: "preserved",
},
scripts: {
dev: {
command: "npm run dev",
type: "server",
port: 3000,
customScriptField: "preserved",
},
},
customTopLevelField: "preserved",
};
const test = base.extend<ProjectsSettingsFixtures>({
editableProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-", {
paseoConfig: initialPaseoConfig,
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
});
await client.close();
await repo.cleanup();
},
gitlabRemoteProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-gitlab-", {
paseoConfig: initialPaseoConfig,
originUrl: "https://gitlab.com/acme/app.git",
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
});
await client.close();
await repo.cleanup();
},
});
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(
async () => {
const contents = await readProjectConfigFile(project);
return JSON.parse(contents) as unknown;
},
{
timeout: 30_000,
},
)
.toMatchObject({
worktree: {
setup: updatedSetup,
teardown: initialPaseoConfig.worktree.teardown,
customWorktreeField: initialPaseoConfig.worktree.customWorktreeField,
},
scripts: {
dev: {
command: initialPaseoConfig.scripts.dev.command,
type: initialPaseoConfig.scripts.dev.type,
port: initialPaseoConfig.scripts.dev.port,
customScriptField: initialPaseoConfig.scripts.dev.customScriptField,
},
},
customTopLevelField: initialPaseoConfig.customTopLevelField,
});
const savedConfig = await readProjectConfigFile(project);
expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`);
}
async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<string> {
return readFile(path.join(project.path, "paseo.json"), "utf8");
}
test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
test("user edits worktree setup on a non-GitHub remote project", async ({
page,
gitlabRemoteProject,
}) => {
expect(gitlabRemoteProject.name).toBe("acme/app");
await openProjects(page);
await openProjectSettings(page, gitlabRemoteProject.name);
await editWorktreeSetup(page, updatedSetup);
await saveProjectConfig(page);
await expectProjectConfigSaved(gitlabRemoteProject);
});
});
+15 -17
View File
@@ -1,6 +1,7 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
import { expectSettingsHeader, openSettingsHost } from "./helpers/settings";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
@@ -18,13 +19,8 @@ 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 expectSettingsHeader(page, TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
@@ -38,7 +34,7 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
await openSettingsHost(page, serverId);
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
await expectHostLabelHeader(page);
@@ -53,8 +49,7 @@ test.describe("Settings host page", () => {
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
await expect(injectMcpCard).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).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();
@@ -68,7 +63,7 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
await openSettingsHost(page, serverId);
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
@@ -86,7 +81,7 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
await openSettingsHost(page, serverId);
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
// Pair-device now lives behind a row that only the local host sees
@@ -130,13 +125,10 @@ test.describe("Settings host page", () => {
const serverId = getSeededServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
// seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses
// the desktop bootstrap flow so only the sidebar's status query runs.
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned
// from get_desktop_settings) bypasses the desktop bootstrap flow so only
// the sidebar's status query runs against the seeded test daemon.
await page.addInitScript((localServerId) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }),
);
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
@@ -153,6 +145,12 @@ test.describe("Settings host page", () => {
error: null,
};
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
getPendingOpenProject: async () => null,
@@ -59,6 +59,35 @@ test.describe("Settings sidebar navigation", () => {
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("direct connection advanced URI round-trips SSL and password into the form", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-add-host").click();
await page.getByRole("button", { name: "Direct connection" }).click();
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 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 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);
+1 -1
View File
@@ -52,7 +52,7 @@ async function openProjectViaDaemon(
throw new Error(result.error ?? `Failed to open project ${cwd}`);
}
return {
id: String(result.workspace.id),
id: result.workspace.id,
name: result.workspace.name,
};
}
@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { getTabTestIds } from "./helpers/launcher";
import {
installTerminalRenderProbe,
readTerminalRenderProbe,
@@ -10,6 +11,53 @@ import {
} from "./helpers/terminal-probes";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
interface TerminalLayoutMetrics {
visibleSurfaceCount: number;
surfaceHeight: number;
rowCount: number;
rows: number | null;
cols: number | null;
renderedRowsHeight: number;
tabIds: string[];
}
async function readTerminalLayoutMetrics(page: Page): Promise<TerminalLayoutMetrics> {
const tabIds = await getTabTestIds(page);
return page.evaluate((currentTabIds) => {
const visibleSurfaces = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]'),
).filter((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const surface = visibleSurfaces[0] ?? null;
const surfaceRect = surface?.getBoundingClientRect() ?? null;
// xterm.js exposes `.xterm-screen` regardless of renderer (DOM, canvas, WebGL),
// so use it as the canonical "rendered surface" rect rather than `.xterm-rows`
// which is only populated by the DOM renderer.
const xtermScreen = surface?.querySelector<HTMLElement>(".xterm-screen") ?? null;
const xtermScreenRect = xtermScreen?.getBoundingClientRect() ?? null;
const term = (
window as Window & {
__paseoTerminal?: {
rows?: number;
cols?: number;
};
}
).__paseoTerminal;
return {
visibleSurfaceCount: visibleSurfaces.length,
surfaceHeight: surfaceRect?.height ?? 0,
rowCount: typeof term?.rows === "number" ? term.rows : 0,
rows: typeof term?.rows === "number" ? term.rows : null,
cols: typeof term?.cols === "number" ? term.cols : null,
renderedRowsHeight: xtermScreenRect?.height ?? 0,
tabIds: currentTabIds,
};
}, tabIds);
}
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
let lastBufferText = "";
let lastProbe = await readTerminalRenderProbe(page);
@@ -53,6 +101,8 @@ async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout:
}
test.describe("Terminal alternate-screen transitions", () => {
test.describe.configure({ timeout: 120_000 });
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
@@ -138,4 +188,62 @@ test.describe("Terminal alternate-screen transitions", () => {
).toEqual([]);
});
});
test("opening vim in a new terminal fills the terminal surface", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1280, height: 900 });
await installTerminalRenderProbe(page);
await withTerminalInApp(page, harness, { name: "vim-layout" }, async () => {
await harness.setupPrompt(page);
const terminalSurface = harness.terminalSurface(page).first();
await expect(terminalSurface).toBeVisible({ timeout: 15_000 });
await terminalSurface.click();
const beforeVim = await readTerminalLayoutMetrics(page);
await startTerminalFrameSampling(page, 2_000);
await terminalSurface.pressSequentially("vim -Nu NONE -n\n", { delay: 0 });
await page.waitForTimeout(1_500);
const probe = await readTerminalRenderProbe(page);
const samples: TerminalLayoutMetrics[] = [];
for (let index = 0; index < 12; index += 1) {
samples.push(await readTerminalLayoutMetrics(page));
await page.waitForTimeout(50);
}
await testInfo.attach("reused-terminal-layout-metrics", {
body: JSON.stringify(
{
beforeVim,
probe: summarizeTerminalRenderProbe(probe),
samples,
},
null,
2,
),
contentType: "application/json",
});
const firstSample = samples[0];
const finalSample = samples.at(-1);
expect(firstSample, "expected an initial layout sample").toBeTruthy();
expect(finalSample, "expected a final layout sample").toBeTruthy();
expect(
finalSample?.visibleSurfaceCount ?? 0,
"opening vim should leave exactly one visible terminal surface",
).toBe(1);
expect(
finalSample?.renderedRowsHeight ?? 0,
"vim should render rows that fill most of the terminal surface",
).toBeGreaterThan((finalSample?.surfaceHeight ?? 0) * 0.75);
expect(
finalSample?.rowCount ?? 0,
"vim should leave a substantial number of rendered rows",
).toBeGreaterThan(Math.max(10, Math.floor((beforeVim.rowCount || 0) * 0.75)));
});
});
});
@@ -68,6 +68,22 @@ terminalPerfDescribe("Terminal keystroke stress", () => {
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
const appObserveNodeBurstReport = await measureAppObservingNodeBurstEcho({
page,
harness,
terminalName: "app-observe-node-burst",
});
await attachJson(testInfo, "app-observe-node-burst", appObserveNodeBurstReport);
console.log(
"[terminal-stress-app-observe-node-burst]",
JSON.stringify(appObserveNodeBurstReport),
);
expect(appObserveNodeBurstReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(
INPUT_TEXT.length,
);
expect(appObserveNodeBurstReport.xtermWriteCount).toBeGreaterThan(0);
const appSmallChunksReport = await measureAppBurstEcho({
page,
harness,
@@ -172,6 +188,34 @@ async function measureAppBurstEcho(input: {
}
}
async function measureAppObservingNodeBurstEcho(input: {
page: Page;
harness: TerminalE2EHarness;
terminalName: string;
}) {
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
try {
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
await input.harness.setupPrompt(input.page);
await resetTerminalKeystrokeStressProbe(input.page);
for (const char of INPUT_TEXT) {
input.harness.client.sendTerminalInput(appTerminal.id, {
type: "input",
data: char,
});
}
await waitForAppStressEcho(input.page, INPUT_TEXT);
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
} finally {
await input.harness.killTerminal(appTerminal.id);
}
}
async function emitRapidAgentStreamUpdates(
harness: TerminalE2EHarness,
input: { agentId: string; count: number },
@@ -234,7 +278,7 @@ async function measureDaemonBurstEcho(
timeoutMs: STRESS_TIMEOUT_MS,
});
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index] - sentAt);
return {
inputTextLength: inputText.length,
inputFrameCount: sendTimes.length,
+2 -2
View File
@@ -45,7 +45,7 @@ test.describe("Workspace cwd correctness", () => {
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
const workspaceId = workspaceResult.workspace.id;
// Use sidebar navigation to avoid Expo Router hydration issues
await openHomeWithProject(page, repo.path);
@@ -95,7 +95,7 @@ test.describe("Workspace cwd correctness", () => {
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
const workspaceId = workspaceResult.workspace.id;
// Use sidebar navigation to avoid Expo Router hydration issues
// with direct URL navigation to the 2nd+ workspace.
+19 -167
View File
@@ -1,39 +1,9 @@
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 { test } from "./fixtures";
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
@@ -42,154 +12,36 @@ test.describe("Workspace lifecycle", () => {
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates an agent chat via New Chat", async ({ page }) => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
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 = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
await client.close();
await repo.cleanup();
}
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
await workspace.navigateTo();
await createAgentChatFromLauncher(page);
});
test("creates a terminal with correct CWD", async ({ page }) => {
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
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 = String(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();
}
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
});
});
test.describe("Worktree workspace", () => {
test("creates an agent chat via New Chat", async ({ page }) => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
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 = String(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();
}
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
await workspace.navigateTo();
await createAgentChatFromLauncher(page);
});
test("creates a terminal with correct CWD", async ({ page }) => {
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
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 = String(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();
}
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
});
});
});
@@ -1,5 +1,6 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
@@ -27,9 +28,213 @@ import {
waitForSidebarHydration,
} from "./helpers/workspace-ui";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function expectNoLoadingWorkspacePane(
page: Page,
input: { label: string; durationMs?: number },
): Promise<void> {
const durationMs = input.durationMs ?? 2000;
const startedAt = Date.now();
const samples: string[] = [];
while (Date.now() - startedAt < durationMs) {
const url = page.url();
const text = await page
.locator("body")
.innerText({ timeout: 250 })
.catch((error) => `[body unavailable: ${error instanceof Error ? error.message : error}]`);
samples.push(`${Date.now() - startedAt}ms ${url}\n${text.slice(0, 1000)}`);
if (LOADING_WORKSPACE_TEXT_PATTERN.test(text)) {
throw new Error(
`${input.label}: loading workspace pane appeared during reconnect window.\n\n${samples.join(
"\n\n---\n\n",
)}`,
);
}
await page.waitForTimeout(100);
}
}
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
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: 1008, reason: "Dropped by workspace reconnect regression test." })
.catch(() => undefined),
),
);
},
restore(): void {
acceptingConnections = true;
},
};
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
const workspaceClient = await connectNewWorkspaceDaemonClient();
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceIds = new Set<string>();
const agentIds: string[] = [];
const repo = await createTempGitRepo("workspace-reconnect-");
try {
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
workspaceIds.add(workspace.workspaceId);
const agent = await createIdleAgent(archiveClient, {
cwd: repo.path,
title: `workspace-reconnect-${Date.now()}`,
});
agentIds.push(agent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
await expectWorkspaceTabVisible(page, agent.id);
await daemonGate.drop();
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
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 expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: 30_000,
});
await monitorReconnect;
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
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) {
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
await repo.cleanup();
}
});
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
});
await page.goto(
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
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("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
@@ -42,7 +42,7 @@ test.describe("Workspace setup runtime authority", () => {
cwd: repo.path,
worktreeSlug: `setup-chat-${Date.now()}`,
});
const workspaceId = String(workspace.id);
const workspaceId = workspace.id;
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
@@ -78,7 +78,7 @@ test.describe("Workspace setup runtime authority", () => {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
const workspaceId = result.workspace.id;
// Navigate to the worktree workspace via sidebar click (direct URL
// navigation for freshly created worktree workspaces can race with
@@ -1,7 +1,6 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { waitForTerminalContent } from "./helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
@@ -279,7 +278,7 @@ test.describe("Workspace setup streaming", () => {
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.keyboard.press("Escape");
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 });
@@ -300,9 +299,7 @@ test.describe("Workspace setup streaming", () => {
await terminalSurface.click();
// Verify the terminal output contains "listening on" via xterm buffer API.
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
await expect(terminalTab).toContainText("web");
} finally {
await client.close();
await repo.cleanup();
@@ -340,7 +337,7 @@ test.describe("Workspace setup streaming", () => {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
const workspaceId = result.workspace.id;
await completed;
+27 -4
View File
@@ -16,11 +16,34 @@ platform :ios do
app_identifier: APP_IDENTIFIER,
)
wait_for_build_processing_to_be_complete(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
build_number: build_number.to_s,
require "spaceship"
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
filepath: ENV.fetch("ASC_KEY_P8"),
)
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
deadline = Time.now + (30 * 60)
loop do
builds = Spaceship::ConnectAPI::Build.all(
app_id: app.id,
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'}")
break if state == "VALID"
if state == "INVALID" || state == "FAILED"
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
end
if Time.now > deadline
UI.user_error!("Timed out waiting for build #{build_number} to finish processing on App Store Connect.")
end
sleep(30)
end
deliver(
api_key: api_key,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.63-beta.1",
"version": "0.1.67",
"private": true,
"main": "index.ts",
"scripts": {
@@ -31,8 +31,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.63-beta.1",
"@getpaseo/highlight": "0.1.63-beta.1",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Scale the amplitude of the thinking-tone PCM16 base64 in-place.
// Usage: node scripts/lower-thinking-tone.mjs <gain>
// Example: node scripts/lower-thinking-tone.mjs 0.3 (≈ -10 dB)
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const target = resolve(here, "../src/utils/thinking-tone.native-pcm.ts");
const gainArg = process.argv[2];
if (!gainArg) {
console.error("Usage: node scripts/lower-thinking-tone.mjs <gain>");
process.exit(1);
}
const gain = Number(gainArg);
if (!Number.isFinite(gain) || gain < 0) {
console.error(`Invalid gain: ${gainArg}`);
process.exit(1);
}
const source = readFileSync(target, "utf8");
const match = source.match(/"([A-Za-z0-9+/=]+)"/);
if (!match) {
console.error("Could not find base64 string in target file.");
process.exit(1);
}
const originalBase64 = match[1];
const buf = Buffer.from(originalBase64, "base64");
if (buf.byteLength % 2 !== 0) {
console.error(`PCM16 buffer has odd length ${buf.byteLength}`);
process.exit(1);
}
let peakBefore = 0;
let peakAfter = 0;
const out = Buffer.alloc(buf.byteLength);
for (let i = 0; i < buf.byteLength; i += 2) {
const sample = buf.readInt16LE(i);
peakBefore = Math.max(peakBefore, Math.abs(sample));
const scaled = Math.max(-32768, Math.min(32767, Math.round(sample * gain)));
peakAfter = Math.max(peakAfter, Math.abs(scaled));
out.writeInt16LE(scaled, i);
}
const newBase64 = out.toString("base64");
const updated = source.replace(originalBase64, newBase64);
writeFileSync(target, updated);
console.log(`Scaled thinking tone by ${gain}.`);
console.log(` samples: ${buf.byteLength / 2}`);
console.log(` peak abs: ${peakBefore} -> ${peakAfter}`);
console.log(` base64 len ${originalBase64.length} -> ${newBase64.length}`);
console.log(` wrote ${target}`);
+153 -152
View File
@@ -19,16 +19,18 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { View } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
@@ -48,17 +50,20 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useOpenProject } from "@/hooks/use-open-project";
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
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";
@@ -70,6 +75,7 @@ import {
useHostRuntimeClient,
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
@@ -99,15 +105,17 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
polyfillCrypto();
export interface HostRuntimeBootstrapState {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
splashError: string | null;
retry: () => void;
hasGivenUpWaitingForHost: boolean;
storeReady: boolean;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
splashError: null,
retry: () => {},
hasGivenUpWaitingForHost: false,
storeReady: false,
});
function PushNotificationRouter() {
@@ -179,19 +187,18 @@ function PushNotificationRouter() {
});
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
@@ -266,115 +273,105 @@ function HostSessionManager() {
);
}
export function useEarliestOnlineHostServerId(): string | null {
const store = getHostRuntimeStore();
const subscribe = useCallback(
(listener: () => void) => {
const unsubscribeAll = store.subscribeAll(listener);
const unsubscribeHostList = store.subscribeHostList(listener);
return () => {
unsubscribeAll();
unsubscribeHostList();
};
},
[store],
);
return useSyncExternalStore(
subscribe,
() => store.getEarliestOnlineHostServerId(),
() => store.getEarliestOnlineHostServerId(),
);
}
function useDaemonStartLastError(): string | null {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.getLastError(),
() => service.getLastError(),
);
}
function useDaemonStartIsRunning(): boolean {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.isRunning(),
() => service.isRunning(),
);
}
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
return false;
}
const settings = await loadDesktopSettings();
return settings.daemon.manageBuiltInDaemon;
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
useEffect(() => {
const store = getHostRuntimeStore();
const daemonStartService = getDaemonStartService({ store });
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
})();
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
if (!cancelled) {
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
}
} else {
setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
if (
anyOnlineHostServerId ||
daemonStartError ||
daemonStartIsRunning ||
hasGivenUpWaitingForHost
) {
return;
}
const handle = setTimeout(() => {
setHasGivenUpWaitingForHost(true);
}, STARTUP_GIVE_UP_TIMEOUT_MS);
return () => {
cancelled = true;
cancelAnyOnline?.();
clearTimeout(handle);
};
}, [retryToken]);
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
startDaemonIfGateAllows({
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
const isCurrentlyStoreReady =
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady],
);
return (
@@ -385,7 +382,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
return useContext(HostRuntimeBootstrapContext).storeReady;
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
@@ -412,7 +409,6 @@ function AppContainer({
selectedAgentId,
chromeEnabled: chromeEnabledOverride,
}: AppContainerProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
@@ -426,7 +422,7 @@ function AppContainer({
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
@@ -469,13 +465,8 @@ function AppContainer({
useActiveWorktreeNewAction();
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
const content = (
<View style={containerStyle}>
<View style={layoutStyles.surfaceFill}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
@@ -484,8 +475,9 @@ function AppContainer({
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<DaemonVersionMismatchCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
@@ -494,6 +486,7 @@ function AppContainer({
/>
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
</View>
);
@@ -611,9 +604,6 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const { theme } = useUnistyles();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
useEffect(() => {
@@ -626,21 +616,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}
}, [settingsLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || isNative) {
return;
}
void updateDesktopWindowControls({
backgroundColor: theme.colors.surface0,
foregroundColor: theme.colors.foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
return (
<VoiceProvider>
<DesktopWindowControlsSync enabled={!settingsLoading} />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
@@ -649,6 +627,24 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
);
}
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => {
if (!enabled || isNative) return;
void updateDesktopWindowControls({
backgroundColor: surface0,
foregroundColor: foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [enabled, surface0, foreground]);
return null;
}
function OfferLinkListener({
upsertDaemonFromOfferUrl,
}: {
@@ -789,7 +785,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname
@@ -845,6 +841,8 @@ function RootStack() {
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
{/*
@@ -926,20 +924,23 @@ function RootProviders({ children }: { children: ReactNode }) {
}
export default function RootLayout() {
const { theme } = useUnistyles();
const gestureRootStyle = useMemo(
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
return (
<GestureHandlerRootView style={gestureRootStyle}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
</GestureHandlerRootView>
);
}
const layoutStyles = StyleSheet.create((theme) => ({
surfaceFill: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
}));
@@ -0,0 +1,226 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveStartupRedirectRoute,
startHostRuntimeBootstrap,
WELCOME_ROUTE,
} from "./host-runtime-bootstrap";
function createFakeStore() {
return { boot: vi.fn() };
}
function createFakeDaemonStartService() {
return {
start: vi.fn(async () => ({ ok: true as const })),
};
}
describe("startHostRuntimeBootstrap", () => {
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
const events: string[] = [];
const store = {
boot: vi.fn(() => {
events.push("boot");
}),
};
const daemonStartService = {
start: vi.fn(async () => {
events.push("daemon-start");
return { ok: true as const };
}),
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
expect(events).toEqual(["boot", "daemon-start"]);
});
it("skips daemon-start when shouldStartDaemon is false", () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: false,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("skips daemon-start when the startup gate resolves false", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => false,
});
await Promise.resolve();
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
const onGateError = vi.fn();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => {
throw new Error("settings file unreadable");
},
onGateError,
});
await vi.waitFor(() => {
expect(onGateError).toHaveBeenCalledTimes(1);
});
expect(daemonStartService.start).not.toHaveBeenCalled();
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
});
it("does not await the daemon-start promise", () => {
const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined;
const daemonStartService = {
start: vi.fn(
() =>
new Promise<{ ok: true }>((resolve) => {
resolveStart = resolve;
}),
),
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
resolveStart?.({ ok: true });
});
});
describe("resolveStartupRedirectRoute", () => {
const baseInput = {
pathname: "/",
anyOnlineHostServerId: null,
workspaceSelection: null,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
};
it("returns null when the pathname is not the index route", () => {
expect(
resolveStartupRedirectRoute({
...baseInput,
pathname: "/h/server-1",
anyOnlineHostServerId: "server-1",
}),
).toBeNull();
});
it("waits while the persisted workspace selection has not finished loading", () => {
expect(
resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-1",
isWorkspaceSelectionLoaded: false,
}),
).toBeNull();
});
it("waits while no host is online and the give-up timer has not fired", () => {
expect(resolveStartupRedirectRoute(baseInput)).toBeNull();
});
describe("scenario: saved-host-online", () => {
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).toBe("/h/server-1/workspace/workspace-a");
});
it("redirects to the host root when the persisted workspace targets a different server", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-2",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-2");
});
it("redirects to the host root when no persisted workspace exists", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-2",
});
expect(route).toBe("/h/server-2");
});
});
describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => {
it("redirects to the host that came online", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "srv_desktop",
});
expect(route).toBe("/h/srv_desktop");
});
});
describe("scenario: both-succeed", () => {
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).toBe("/h/server-saved/workspace/workspace-a");
});
});
describe("scenario: both-fail (no host comes online, give-up timer fires)", () => {
it("redirects to the welcome route", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
hasGivenUpWaitingForHost: true,
});
expect(route).toBe(WELCOME_ROUTE);
});
it("still redirects to the host when one comes online before the timer expires", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-saved",
hasGivenUpWaitingForHost: true,
});
expect(route).toBe("/h/server-saved");
});
});
});
@@ -0,0 +1,95 @@
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, buildHostWorkspaceRoute } from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
boot: () => void;
}
export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
}
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
startDaemonIfGateAllows({
daemonStartService: input.daemonStartService,
shouldStartDaemon: input.shouldStartDaemon,
onGateError: input.onGateError,
});
}
export function startDaemonIfGateAllows(input: {
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}): void {
const gate = input.shouldStartDaemon;
if (typeof gate === "boolean") {
if (gate) {
void input.daemonStartService.start();
}
return;
}
void Promise.resolve()
.then(() => gate())
.then((shouldStartDaemon) => {
if (shouldStartDaemon) {
void input.daemonStartService.start();
}
return null;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
});
}
export const WELCOME_ROUTE: Href = "/welcome";
export interface ResolveStartupRedirectInput {
pathname: string;
anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
}
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
if (input.pathname !== "/" && input.pathname !== "") {
return null;
}
if (!input.isWorkspaceSelectionLoaded) {
return null;
}
if (input.anyOnlineHostServerId) {
if (
input.workspaceSelection &&
input.workspaceSelection.serverId === input.anyOnlineHostServerId
) {
return buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
);
}
return buildHostRootRoute(input.anyOnlineHostServerId);
}
if (input.hasGivenUpWaitingForHost) {
return WELCOME_ROUTE;
}
return null;
}
+148
View File
@@ -0,0 +1,148 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
const { redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
return {
redirectMock: vi.fn(),
state: hoistedState,
};
});
vi.mock("expo-router", () => ({
Redirect: ({ href }: { href: string }) => {
redirectMock(href);
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
},
usePathname: () => state.pathname,
}));
vi.mock("@/app/_layout", () => ({
useHostRuntimeBootstrapState: () => state.bootstrapState,
useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
shouldUseDesktopDaemon: () => false,
}));
vi.mock("@/screens/startup-splash-screen", () => ({
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
}));
describe("Index route startup navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.resetModules();
state.pathname = "/";
state.bootstrapState = {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
redirectMock.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
async function renderIndex() {
const { default: Index } = await import("./index");
await act(async () => {
root.render(<Index />);
});
}
it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
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(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("navigates to the host root when the persisted workspace targets a different server", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("navigates to the host root when no persisted workspace exists", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = null;
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("falls back to welcome when the give-up timer fires with no host online", async () => {
state.bootstrapState = {
...state.bootstrapState,
hasGivenUpWaitingForHost: true,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/welcome");
});
});
+22 -46
View File
@@ -1,59 +1,35 @@
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import React from "react";
import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
import {
getLastNavigationWorkspaceRouteSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded,
} from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => null,
);
}
const isDesktop = shouldUseDesktopDaemon();
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
useEffect(() => {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
const redirectRoute = resolveStartupRedirectRoute({
pathname,
anyOnlineHostServerId,
workspaceSelection: isWorkspaceSelectionLoaded
? getLastNavigationWorkspaceRouteSelection()
: null,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
}
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
@@ -0,0 +1,12 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -0,0 +1,7 @@
import SettingsScreen from "@/screens/settings-screen";
const PROJECTS_VIEW = { kind: "projects" as const };
export default function SettingsProjectsIndexRoute() {
return <SettingsScreen view={PROJECTS_VIEW} />;
}
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { AgentAttachment } from "@server/shared/messages";
import { useClearReviewDraft } from "@/review/store";
interface WorkspaceAttachmentBindingInput {
normalAttachments: UserComposerAttachment[];
workspaceAttachments: readonly WorkspaceComposerAttachment[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
}
interface RemoveWorkspaceAttachmentInput {
selectedAttachments: readonly ComposerAttachment[];
index: number;
}
interface OpenWorkspaceAttachmentInput {
attachment: ComposerAttachment;
}
interface CompleteSubmitInput {
result: "noop" | "queued" | "submitted" | "failed";
outgoingAttachments: readonly ComposerAttachment[];
}
interface ComposerWorkspaceAttachmentBinding {
selectedAttachments: ComposerAttachment[];
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
completeSubmit: (input: CompleteSubmitInput) => void;
resetSuppression: () => void;
}
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review" || attachment?.kind === "browser_element";
}
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "browser_element",
);
}
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
if (attachment.kind === "browser_element") {
return {
type: "text",
mimeType: "text/plain",
title: `Browser element · ${attachment.attachment.tag}`,
text: attachment.attachment.formatted,
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return (
<WorkspaceAttachmentPill
key={`workspace:${getAttachmentKey(args.attachment)}`}
{...args}
attachment={args.attachment}
/>
);
}
function useWorkspaceAttachmentBinding({
normalAttachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
const clearReviewDraft = useClearReviewDraft();
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
const workspaceAttachmentKeys = useMemo(
() => workspaceAttachments.map(getAttachmentKey),
[workspaceAttachments],
);
const activeWorkspaceAttachments = useMemo(
() =>
workspaceAttachments.filter(
(attachment, index) => !suppressedKeys.includes(workspaceAttachmentKeys[index] ?? ""),
),
[suppressedKeys, workspaceAttachmentKeys, workspaceAttachments],
);
const selectedAttachments = useMemo<ComposerAttachment[]>(
() =>
activeWorkspaceAttachments.length > 0
? [...normalAttachments, ...activeWorkspaceAttachments]
: normalAttachments,
[activeWorkspaceAttachments, normalAttachments],
);
useEffect(() => {
setSuppressedKeys((current) => {
const next = current.filter((suppressedKey) =>
workspaceAttachmentKeys.includes(suppressedKey),
);
return next.length === current.length ? current : next;
});
}, [workspaceAttachmentKeys]);
const buildOutgoingAttachments = useCallback(
(attachments: UserComposerAttachment[]): ComposerAttachment[] =>
activeWorkspaceAttachments.length > 0
? [...attachments, ...activeWorkspaceAttachments]
: attachments,
[activeWorkspaceAttachments],
);
const suppressWorkspaceAttachment = useCallback((attachment: WorkspaceComposerAttachment) => {
const key = getAttachmentKey(attachment);
setSuppressedKeys((current) => (current.includes(key) ? current : [...current, key]));
}, []);
const clearSentAttachments = useCallback(
(attachments: readonly ComposerAttachment[]) => {
for (const attachment of attachments) {
if (attachment.kind === "review") {
clearReviewDraft({ key: attachment.reviewDraftKey });
}
}
},
[clearReviewDraft],
);
const removeAttachment = useCallback(
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index];
if (isWorkspaceAttachment(selected)) {
if (selected.kind === "browser_element") {
const selectedKey = getAttachmentKey(selected);
const { attachmentsByScope } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
return true;
}
suppressWorkspaceAttachment(selected);
return true;
}
return false;
},
[setWorkspaceAttachments, suppressWorkspaceAttachment],
);
const openAttachment = useCallback(
({ attachment }: OpenWorkspaceAttachmentInput) => {
if (!isWorkspaceAttachment(attachment) || attachment.kind !== "review") {
return false;
}
onOpenWorkspaceAttachment?.(attachment);
return true;
},
[onOpenWorkspaceAttachment],
);
const resetSuppression = useCallback(() => {
setSuppressedKeys([]);
}, []);
const completeSubmit = useCallback(
({ result, outgoingAttachments }: CompleteSubmitInput) => {
if (result === "submitted") {
clearSentAttachments(outgoingAttachments);
}
if (result === "queued" || result === "submitted") {
resetSuppression();
}
},
[clearSentAttachments, resetSuppression],
);
return {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
};
}
interface RenderWorkspaceAttachmentPillArgs {
attachment: WorkspaceComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
}
interface WorkspaceAttachmentPillProps extends Omit<
RenderWorkspaceAttachmentPillArgs,
"attachment"
> {
attachment: WorkspaceComposerAttachment;
}
function WorkspaceAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
}: WorkspaceAttachmentPillProps) {
let label: string;
if (attachment.kind === "browser_element") {
label = `Element · ${attachment.attachment.tag}`;
} else {
label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
}
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-review-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={
attachment.kind === "browser_element"
? "Open browser element attachment"
: "Open review attachment"
}
removeAccessibilityLabel={
attachment.kind === "browser_element"
? "Remove browser element attachment"
: "Remove review attachment"
}
disabled={disabled}
>
<View style={styles.pillBody}>
<View style={styles.pillIcon}>
{attachment.kind === "browser_element" ? (
<ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
)}
</View>
<Text style={styles.pillText} numberOfLines={1}>
{label}
</Text>
</View>
</AttachmentPill>
);
}
export const composerWorkspaceAttachment = {
is: isWorkspaceAttachment,
renderPill,
toSubmitAttachment,
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
const styles = StyleSheet.create((theme: Theme) => ({
pillBody: {
minHeight: 48,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
pillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
pillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
})) as unknown as Record<string, object>;
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -14,6 +14,21 @@ const fileSystemMock = vi.hoisted(() => ({
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
}));
vi.mock("expo-file-system", () => ({
File: class {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
write(content: Uint8Array) {
fileSystemMock.fileWrite(this.uri, content);
}
},
}));
vi.mock("expo-file-system/legacy", () => ({
@@ -37,9 +52,10 @@ describe("local file attachment store", () => {
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
fileSystemMock.fileWrite.mockClear();
});
it("writes raw base64 sources directly to the managed file path", async () => {
it("writes raw byte sources directly to the managed file path", async () => {
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
@@ -50,14 +66,14 @@ describe("local file attachment store", () => {
id: "preview_8_test",
mimeType: "image/png",
fileName: "result.png",
source: { kind: "base64", base64: "AAECAw==" },
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
});
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
"AAECAw==",
{ encoding: "base64" },
new Uint8Array([0, 1, 2, 3]),
);
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
@@ -1,3 +1,4 @@
import { File } from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
import {
type AttachmentStore,
@@ -6,7 +7,6 @@ import {
type SaveAttachmentInput,
} from "@/attachments/types";
import {
blobToBase64,
fileUriToPath,
generateAttachmentId,
getFileExtensionFromName,
@@ -45,6 +45,15 @@ async function ensureDirectory(uri: string): Promise<void> {
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
const response = await fetch(dataUrl);
return new Uint8Array(await response.arrayBuffer());
}
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
async function writeFromSource(input: {
source: SaveAttachmentInput["source"];
targetUri: string;
@@ -59,30 +68,16 @@ async function writeFromSource(input: {
return;
}
let bytes: Uint8Array;
if (input.source.kind === "data_url") {
const parsed = parseDataUrl(input.source.dataUrl);
const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType);
const base64 = parsed.base64;
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
if (mimeType !== input.mimeType) {
return;
}
return;
bytes = await dataUrlToBytes(input.source.dataUrl);
} else if (input.source.kind === "blob") {
bytes = await blobToBytes(input.source.blob);
} else {
bytes = input.source.bytes;
}
if (input.source.kind === "base64") {
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
encoding: FileSystem.EncodingType.Base64,
});
return;
}
const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
new File(input.targetUri).write(bytes);
}
function attachmentUri(metadata: AttachmentMetadata): string {
+9 -8
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
@@ -54,19 +54,20 @@ describe("attachment service", () => {
__setAttachmentStoreForTests(null);
});
it("persists raw base64 without requiring a data URL wrapper", async () => {
it("persists raw bytes without requiring a base64 wrapper", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const bytes = new Uint8Array([0, 1, 2, 3]);
const attachment = await persistAttachmentFromBase64({
id: "att_base64",
base64: "AAECAw==",
const attachment = await persistAttachmentFromBytes({
id: "att_bytes",
bytes,
mimeType: "image/png",
fileName: "image.png",
});
expect(attachment).toEqual({
id: "att_base64",
id: "att_bytes",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_1",
@@ -76,10 +77,10 @@ describe("attachment service", () => {
});
expect(store.savedSources).toEqual([
{
id: "att_base64",
id: "att_bytes",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "base64", base64: "AAECAw==" },
source: { kind: "bytes", bytes },
},
]);
});
+3 -3
View File
@@ -31,8 +31,8 @@ export async function persistAttachmentFromDataUrl(input: {
});
}
export async function persistAttachmentFromBase64(input: {
base64: string;
export async function persistAttachmentFromBytes(input: {
bytes: Uint8Array;
mimeType?: string;
fileName?: string | null;
id?: string;
@@ -42,7 +42,7 @@ export async function persistAttachmentFromBase64(input: {
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "base64", base64: input.base64 },
source: { kind: "bytes", bytes: input.bytes },
});
}
+47 -3
View File
@@ -1,4 +1,4 @@
import type { GitHubSearchItem } from "@server/shared/messages";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -17,13 +17,57 @@ export interface AttachmentMetadata {
createdAt: number;
}
export interface BrowserElementAttachment {
url: string;
selector: string;
tag: string;
text: string;
outerHTML: string;
computedStyles: Record<string, string>;
boundingRect: {
x: number;
y: number;
width: number;
height: number;
};
reactSource: {
fileName: string | null;
lineNumber: number | null;
columnNumber: number | null;
componentName: string | null;
} | null;
parentChain: string[];
children: string[];
formatted: string;
}
export type ComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem };
| { kind: "github_pr"; item: GitHubSearchItem }
| {
kind: "browser_element";
attachment: BrowserElementAttachment;
}
| {
kind: "review";
attachment: Extract<AgentAttachment, { type: "review" }>;
reviewDraftKey: string;
commentCount: number;
};
export type UserComposerAttachment = Exclude<
ComposerAttachment,
{ kind: "review" } | { kind: "browser_element" }
>;
export type WorkspaceComposerAttachment = Extract<
ComposerAttachment,
{ kind: "review" } | { kind: "browser_element" }
>;
export type AttachmentDataSource =
| { kind: "base64"; base64: string }
| { kind: "bytes"; bytes: Uint8Array }
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string };
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store";
type Listener = () => void;
class FakeRequest<T = unknown> {
result!: T;
error: Error | null = null;
private listeners = new Map<string, Listener[]>();
addEventListener(event: string, listener: Listener): void {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
}
emit(event: string): void {
for (const listener of this.listeners.get(event) ?? []) {
listener();
}
}
}
class FakeObjectStore {
constructor(private readonly onPut: (record: unknown) => void) {}
put(record: unknown): FakeRequest {
this.onPut(record);
const request = new FakeRequest();
queueMicrotask(() => request.emit("success"));
return request;
}
}
class FakeTransaction {
error: Error | null = null;
constructor(private readonly store: FakeObjectStore) {}
objectStore(): FakeObjectStore {
return this.store;
}
addEventListener(): void {}
}
class FakeDatabase {
readonly objectStoreNames = {
contains: () => true,
};
constructor(private readonly store: FakeObjectStore) {}
createObjectStore(): void {}
transaction(): FakeTransaction {
return new FakeTransaction(this.store);
}
close(): void {}
}
describe("indexeddb attachment store", () => {
let storedRecord: unknown;
beforeEach(() => {
storedRecord = null;
Object.defineProperty(globalThis, "indexedDB", {
configurable: true,
value: {
open: () => {
const store = new FakeObjectStore((record) => {
storedRecord = record;
});
const request = new FakeRequest<FakeDatabase>();
request.result = new FakeDatabase(store);
queueMicrotask(() => request.emit("success"));
return request;
},
} as unknown as IDBFactory,
});
});
afterEach(() => {
Reflect.deleteProperty(globalThis, "indexedDB");
});
it("stores raw byte sources as a Blob", async () => {
const store = createIndexedDbAttachmentStore();
const bytes = new Uint8Array([0, 1, 2, 3]);
const attachment = await store.save({
id: "att_bytes",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "bytes", bytes },
});
expect(storedRecord).toEqual({
id: "att_bytes",
blob: new Blob([bytes], { type: "image/png" }),
createdAt: expect.any(Number),
fileName: "image.png",
});
expect(attachment).toMatchObject({
id: "att_bytes",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_bytes",
fileName: "image.png",
byteSize: 4,
});
});
});
@@ -61,7 +61,7 @@ function runTx<T>(
const request = run(store);
request.addEventListener("success", () => {
resolve(request.result as T);
resolve(request.result);
});
request.addEventListener("error", () => {
@@ -74,17 +74,18 @@ function runTx<T>(
});
}
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
const binary = atob(input.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: input.mimeType });
}
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
const source = input.source;
if (source.kind === "bytes") {
const mimeType = normalizeMimeType(input.mimeType);
const buffer = new ArrayBuffer(source.bytes.byteLength);
new Uint8Array(buffer).set(source.bytes);
return {
blob: new Blob([buffer], { type: mimeType }),
mimeType,
};
}
if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob =
@@ -105,14 +106,6 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
};
}
if (source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return {
blob: base64ToBlob({ base64: source.base64, mimeType }),
mimeType,
};
}
const response = await fetch(source.uri);
const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "./types";
import {
buildWorkspaceAttachmentScopeKey,
resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore,
} from "./workspace-attachments-store";
function reviewAttachment(body: string): WorkspaceComposerAttachment {
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment: {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,1 +40,1 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
},
};
}
describe("workspace attachments store", () => {
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
expect(
buildWorkspaceAttachmentScopeKey({
serverId: " local ",
workspaceId: " workspace-1 ",
cwd: "/repo",
}),
).toBe("workspace-attachments:server=local:workspace=workspace-1");
expect(
buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: null,
cwd: "/repo/",
}),
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
});
it("publishes and clears attachments for a workspace scope", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: "workspace-1",
cwd: "/repo",
});
const attachment = reviewAttachment("Please simplify this.");
useWorkspaceAttachmentsStore
.getState()
.setWorkspaceAttachments({ scopeKey, attachments: [attachment] });
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toEqual([
attachment,
]);
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
});
});
@@ -0,0 +1,115 @@
import { useMemo } from "react";
import { create } from "zustand";
import type { WorkspaceComposerAttachment } from "./types";
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
export interface WorkspaceAttachmentScopeInput {
serverId: string;
workspaceId?: string | null;
cwd: string;
}
interface WorkspaceAttachmentsStoreState {
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
}
interface WorkspaceAttachmentsStoreActions {
setWorkspaceAttachments: (input: {
scopeKey: string;
attachments: readonly WorkspaceComposerAttachment[];
}) => void;
clearWorkspaceAttachments: (input: { scopeKey: string }) => void;
}
type WorkspaceAttachmentsStore = WorkspaceAttachmentsStoreState & WorkspaceAttachmentsStoreActions;
function encodeScopePart(value: string): string {
return encodeURIComponent(value.trim());
}
function normalizeCwd(cwd: string): string {
const trimmed = cwd.trim();
if (trimmed === "/") {
return trimmed;
}
return trimmed.replace(/\/+$/, "");
}
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const workspaceId = input.workspaceId?.trim();
const workspacePart = workspaceId
? `workspace=${encodeScopePart(workspaceId)}`
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;
return ["workspace-attachments", `server=${encodeScopePart(input.serverId)}`, workspacePart].join(
":",
);
}
function areWorkspaceAttachmentsEqual(
left: readonly WorkspaceComposerAttachment[],
right: readonly WorkspaceComposerAttachment[],
): boolean {
if (left === right) {
return true;
}
if (left.length !== right.length) {
return false;
}
return left.every((attachment, index) => attachment === right[index]);
}
export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()((set) => ({
attachmentsByScope: {},
setWorkspaceAttachments: ({ scopeKey, attachments }) => {
set((state) => {
const current = state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS;
if (areWorkspaceAttachmentsEqual(current, attachments)) {
return state;
}
if (attachments.length === 0) {
if (!state.attachmentsByScope[scopeKey]) {
return state;
}
const next = { ...state.attachmentsByScope };
delete next[scopeKey];
return { attachmentsByScope: next };
}
return {
attachmentsByScope: {
...state.attachmentsByScope,
[scopeKey]: attachments,
},
};
});
},
clearWorkspaceAttachments: ({ scopeKey }) => {
set((state) => {
if (!state.attachmentsByScope[scopeKey]) {
return state;
}
const next = { ...state.attachmentsByScope };
delete next[scopeKey];
return { attachmentsByScope: next };
});
},
}));
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const { serverId, workspaceId, cwd } = input;
return useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[serverId, workspaceId, cwd],
);
}
export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceComposerAttachment[] {
return useWorkspaceAttachmentsStore(
(state) => state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS,
);
}
export function resetWorkspaceAttachmentsStore(): void {
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
}
@@ -196,7 +196,7 @@ export function AdaptiveModalSheet({
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
onClose,
@@ -228,6 +228,7 @@ export function AdaptiveModalSheet({
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
+336 -55
View File
@@ -1,17 +1,35 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link2 } from "lucide-react-native";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
interface DirectConnectionDraft {
host: string;
port: string;
useTls: boolean;
password: string;
}
interface PreparedDirectConnection {
uri: string;
endpoint: string;
useTls: boolean;
password?: string;
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -30,6 +48,66 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
},
portRow: {
flexDirection: "row",
gap: theme.spacing[3],
},
hostField: {
flex: 1,
minWidth: 0,
},
portField: {
width: 112,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
passwordInput: {
flex: 1,
minWidth: 0,
},
iconButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1],
},
advancedText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
@@ -45,8 +123,51 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function isHostPortOnly(raw: string): boolean {
return !raw.includes("://") && !raw.includes("/");
function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error("Host is required");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
return {
uri: serializeConnectionUri(parsed),
endpoint,
useTls: parsed.useTls,
...(parsed.password ? { password: parsed.password } : {}),
};
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri);
return {
host: parsed.host,
port: String(parsed.port),
useTls: parsed.useTls,
password: parsed.password ?? "",
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
@@ -56,7 +177,7 @@ function normalizeTransportMessage(message: string | null | undefined): string |
return trimmed;
}
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
const unique = Array.from(
new Set(
details
@@ -103,7 +224,9 @@ function buildConnectionFailureCopy(
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (rawLower.includes("timed out")) {
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
} else if (
rawLower.includes("econnrefused") ||
@@ -121,9 +244,7 @@ function buildConnectionFailureCopy(
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
}
@@ -149,21 +270,41 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const endpointRawRef = useRef("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState("6767");
const [useTls, setUseTls] = useState(false);
const [password, setPassword] = useState("");
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const clearInput = useCallback(() => {
endpointRawRef.current = "";
hostInputRef.current?.clear();
setHost("");
setPort("6767");
setUseTls(false);
setPassword("");
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
() => [styles.checkbox, useTls ? styles.checkboxChecked : null],
[useTls],
);
const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []);
const useTlsAccessibilityState = useMemo(
() => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls],
);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -182,21 +323,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRawRef.current.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
}
if (!isHostPortOnly(raw)) {
setErrorMessage("Enter host:port only (no ws://, no /ws)");
return;
}
let endpoint: string;
let connection: PreparedDirectConnection;
try {
endpoint = normalizeHostPort(raw);
connection = prepareDirectConnection({ host, port, useTls, password });
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid host:port";
const message = error instanceof Error ? error.message : "Invalid connection";
setErrorMessage(message);
return;
}
@@ -208,20 +339,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
endpoint,
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
});
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
const profile = await upsertDirectConnection({
serverId,
endpoint,
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error);
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
@@ -232,17 +367,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
Alert.alert("Connection failed", combined);
}
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
const handleChangeEndpoint = useCallback((next: string) => {
endpointRawRef.current = next;
}, []);
}, [
daemons,
handleClose,
host,
isMobile,
isSaving,
onSaved,
password,
port,
upsertDirectConnection,
useTls,
]);
const handleSubmitEditing = useCallback(() => {
void handleSave();
@@ -252,6 +393,43 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
void handleSave();
}, [handleSave]);
const handleToggleUseTls = useCallback(() => {
if (isSaving) return;
setUseTls((current) => !current);
}, [isSaving]);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((current) => !current);
}, []);
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
} catch {
setAdvancedUri("");
}
setErrorMessage("");
setIsAdvancedOpen(true);
return;
}
try {
const next = draftFromConnectionUri(advancedUri);
setHost(next.host);
setPort(next.port);
setUseTls(next.useTls);
setPassword(next.password);
setErrorMessage("");
} catch {
setErrorMessage("");
}
setIsAdvancedOpen(false);
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
title="Direct connection"
@@ -261,24 +439,127 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="Host"
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="next"
/>
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>Port</Text>
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel="Port"
value={port}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
</View>
</View>
<Pressable
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
accessibilityRole="checkbox"
accessibilityLabel="Use SSL"
accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle"
>
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.palette.white} />
</View>
) : null}
</View>
<Text style={styles.label}>Use SSL</Text>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={handleChangeEndpoint}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Text style={styles.label}>Password</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel="Password"
value={password}
onChangeText={setPassword}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
<View style={styles.field}>
<Pressable
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>Advanced</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI"
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
@@ -234,7 +234,7 @@ function buildFallbackAllProviderModels(
map.set(
provider,
modelOptions.map((option) => ({
provider: provider as AgentProvider,
provider: provider,
id: option.id,
label: option.label,
})),
@@ -0,0 +1,419 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import { AgentStreamView } from "./agent-stream-view";
const assistantMessageCalls = vi.hoisted(
() => [] as Array<{ message: string; spacing: string | undefined }>,
);
const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>);
const mockSessionState = vi.hoisted(() => ({
sessions: {
server: {
client: null,
agentStreamHead: new Map<string, StreamItem[]>(),
workspaces: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
},
},
setAgentTimelineOlderFetchInFlight: () => {},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (
factory: (theme: {
borderRadius: Record<string, number>;
borderWidth: Record<number, number>;
colors: Record<string, string>;
fontSize: Record<string, number>;
fontWeight: Record<string, string>;
shadow: Record<string, object>;
spacing: Record<number, number>;
}) => unknown,
) =>
factory({
borderRadius: {
full: 9999,
md: 6,
},
borderWidth: {
1: 1,
},
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#000",
surface1: "#111",
surface2: "#222",
border: "#333",
borderAccent: "#444",
},
fontSize: {
sm: 14,
base: 16,
xs: 12,
},
fontWeight: {
normal: "normal",
},
shadow: {
sm: {},
},
spacing: {
1: 4,
2: 8,
3: 12,
4: 16,
12: 48,
},
}),
},
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
withUnistyles: (Component: unknown) => Component,
}));
vi.mock("react-native-reanimated", async () => {
const ReactModule = await import("react");
return {
default: {
View: ({ children, ...props }: { children?: React.ReactNode }) =>
ReactModule.createElement("div", props, children),
},
Easing: { linear: vi.fn() },
FadeIn: { duration: vi.fn(() => undefined) },
FadeOut: { duration: vi.fn(() => undefined) },
cancelAnimation: vi.fn(),
useAnimatedStyle: (factory: () => unknown) => factory(),
useSharedValue: (value: unknown) => ({ value }),
withRepeat: (value: unknown) => value,
withTiming: (value: unknown) => value,
};
});
vi.mock("lucide-react-native", async () => {
const ReactModule = await import("react");
const Icon = () => ReactModule.createElement("span");
return {
Check: Icon,
ChevronDown: Icon,
X: Icon,
};
});
vi.mock("./message", async () => {
const ReactModule = await import("react");
return {
ActivityLog: () => null,
AssistantMessage: (props: { message: string; spacing?: string }) => {
assistantMessageCalls.push({ message: props.message, spacing: props.spacing });
return ReactModule.createElement("div", {
"data-message": props.message,
"data-spacing": props.spacing ?? "",
});
},
CompactionMarker: () => null,
MessageOuterSpacingProvider: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement(ReactModule.Fragment, null, children),
SpeakMessage: () => null,
TodoListCard: () => null,
ToolCall: () => null,
TurnCopyButton: (props: { getContent: () => string }) => {
turnCopyButtonCalls.push(props);
return ReactModule.createElement("button", {
"data-testid": "turn-copy-button",
type: "button",
});
},
UserMessage: () => null,
};
});
vi.mock("./tool-call-sheet", async () => {
const ReactModule = await import("react");
return {
ToolCallSheetProvider: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement(ReactModule.Fragment, null, children),
useToolCallSheet: () => ({ open: vi.fn() }),
};
});
vi.mock("./tool-call-details", () => ({ ToolCallDetailsContent: () => null }));
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
vi.mock("./question-form-card", () => ({ QuestionFormCard: () => null }));
vi.mock("./plan-card", () => ({ PlanCard: () => null }));
vi.mock("@/hooks/use-file-explorer-actions", () => ({
useFileExplorerActions: () => ({ requestDirectoryListing: vi.fn() }),
}));
vi.mock("@/stores/panel-store", () => ({
usePanelStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
openFileExplorerForCheckout: vi.fn(),
setExplorerTabForCheckout: vi.fn(),
}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: Object.assign(
(selector: (state: typeof mockSessionState) => unknown) => selector(mockSessionState),
{
getState: () => mockSessionState,
},
),
}));
vi.mock("expo-router", () => ({ useRouter: () => ({ navigate: vi.fn() }) }));
function assistantBlock(params: {
id: string;
text: string;
blockIndex: number;
}): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id: params.id,
blockGroupId: "group-1",
blockIndex: params.blockIndex,
text: params.text,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
};
}
function runningToolCall(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "npm test",
result: null,
status: "executing",
},
},
};
}
describe("AgentStreamView", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn();
assistantMessageCalls.length = 0;
turnCopyButtonCalls.length = 0;
mockSessionState.sessions.server.agentStreamHead = new Map();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
if (originalScrollTo) {
HTMLElement.prototype.scrollTo = originalScrollTo;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
}
vi.restoreAllMocks();
});
it("compacts assistant block spacing across the history/live-head boundary", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
text: "First paragraph",
blockIndex: 0,
});
const headBlock = assistantBlock({
id: "group-1:head",
text: "Second paragraph",
blockIndex: 1,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "idle",
cwd: "/tmp/project",
} as never;
const streamItems = [tailBlock];
const pendingPermissions = new Map();
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems,
pendingPermissions,
}),
);
});
const tailCalls = assistantMessageCalls.filter((call) => call.message === "First paragraph");
const headCalls = assistantMessageCalls.filter((call) => call.message === "Second paragraph");
expect(tailCalls.length).toBeGreaterThan(0);
expect(headCalls.length).toBeGreaterThan(0);
expect(tailCalls.map((call) => call.spacing)).toEqual(
Array.from({ length: tailCalls.length }, () => "compactBottom"),
);
expect(headCalls.map((call) => call.spacing)).toEqual(
Array.from({ length: headCalls.length }, () => "compactTop"),
);
});
it("renders running dots in the assistant turn footer when live text is streaming", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull();
});
it("only renders running dots on the live assistant row", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
text: "History paragraph",
blockIndex: 0,
});
const headBlock = assistantBlock({
id: "group-2:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [tailBlock],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1);
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
});
it("keeps the auxiliary running dots when there is no live assistant row", () => {
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).not.toBeNull();
});
it("replaces the running footer with the copy button when the assistant turn idles", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Complete paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "idle",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull();
expect(turnCopyButtonCalls.length).toBeGreaterThan(0);
expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual(
Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"),
);
});
});
+184 -99
View File
@@ -1,4 +1,4 @@
import {
import React, {
forwardRef,
memo,
useCallback,
@@ -18,7 +18,7 @@ import {
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -56,6 +56,8 @@ import type {
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
@@ -84,6 +86,7 @@ import {
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -125,6 +128,12 @@ const getAssistantBlockSpacing = (params: {
}
return "default";
};
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
}
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -138,6 +147,7 @@ export interface AgentStreamViewProps {
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
@@ -151,12 +161,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
onOpenWorkspaceFile,
},
ref,
) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -192,6 +202,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId: workspaceId ?? undefined,
workspaceRoot,
});
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
serverId: resolvedServerId,
agentId,
toast,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
@@ -271,6 +286,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
],
);
const handleToolCallOpenFile = useCallback(
(filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath });
},
[handleInlinePathPress],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
tail: streamItems,
@@ -279,6 +301,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
const inlineWorkingIndicatorItemId = useMemo(() => {
if (agent.status !== "running") {
return null;
}
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
if (item.kind !== "assistant_message") {
return false;
}
return (
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) === undefined
);
});
return footerItem?.id ?? null;
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
useImperativeHandle(
ref,
() => ({
@@ -296,9 +337,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
const looseGap = theme.spacing[4]; // 16px
const tightGap = SPACING[1];
const assistantBlockGap = SPACING[3];
const looseGap = SPACING[4];
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
@@ -375,6 +416,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<UserMessage
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
@@ -389,7 +431,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item: Extract<StreamItem, { kind: "assistant_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
seams: StreamItemBoundarySeams,
) => {
const aboveItem =
getStreamNeighborItem({
@@ -398,14 +440,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
index,
relation: "above",
}) ??
seamAboveItem ??
seams.aboveItem ??
undefined;
const belowItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) ??
seams.belowItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
@@ -485,6 +530,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
}
@@ -499,10 +545,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
@@ -510,14 +557,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null,
seams: StreamItemBoundarySeams = {},
) => {
switch (item.kind) {
case "user_message":
return renderUserMessageItem(item, index, items, seamAboveItem);
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null);
case "assistant_message":
return renderAssistantMessageItem(item, index, items, seamAboveItem);
return renderAssistantMessageItem(item, index, items, seams);
case "thought":
return renderThoughtItem(item, index, items);
@@ -553,9 +600,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item: StreamItem,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null = null,
seams: StreamItemBoundarySeams = {},
) => {
const content = renderStreamItemContent(item, index, items, seamAboveItem);
const content = renderStreamItemContent(item, index, items, seams);
if (!content) {
return null;
}
@@ -571,21 +618,31 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const isRunningAssistantTurnFooter =
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
let footer: ReactNode = null;
if (isRunningAssistantTurnFooter) {
footer = <InlineWorkingIndicatorSlot />;
} else if (isEndOfAssistantTurn) {
footer = (
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
);
}
return (
<StreamItemWrapper gapBelow={gapBelow}>
{content}
{isEndOfAssistantTurn ? (
<TurnCopyButtonSlot
strategy={streamRenderStrategy}
items={items}
startIndex={index}
/>
) : null}
{footer}
</StreamItemWrapper>
);
},
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
[
getGapBetween,
renderStreamItemContent,
agent.status,
streamRenderStrategy,
inlineWorkingIndicatorItemId,
],
);
const pendingPermissionItems = useMemo(
@@ -593,7 +650,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showWorkingIndicator = agent.status === "running";
const showAuxiliaryWorkingIndicator =
agent.status === "running" && inlineWorkingIndicatorItemId === null;
const pendingPermissionsNode = useMemo(
() =>
pendingPermissionItems.length > 0 ? (
@@ -607,12 +665,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workingIndicatorNode = useMemo(
() =>
showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
showAuxiliaryWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
<WorkingIndicator />
</View>
) : null,
[showWorkingIndicator],
[showAuxiliaryWorkingIndicator],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
@@ -654,6 +712,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const _liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
@@ -669,9 +728,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (historyIndex === undefined) {
return null;
}
return renderStreamItem(item, historyIndex, historyItems);
const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
return renderStreamItem(item, historyIndex, historyItems, {
belowItem: seamBelowItem,
});
},
[historyIndexById, historyItems, renderStreamItem],
[firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
);
const renderHistoryVirtualizedRow = useCallback<
@@ -683,7 +745,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
renderStreamItem(item, index, items, {
aboveItem: index === 0 ? lastHistoryItem : null,
}),
[lastHistoryItem, renderStreamItem],
);
const liveAuxiliaryHeaderStyle = useMemo(() => {
@@ -741,6 +805,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
@@ -775,7 +842,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator() {
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
const progress = useSharedValue(0);
useEffect(() => {
@@ -827,8 +894,13 @@ function WorkingIndicator() {
[dotThreeStyle],
);
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return (
<View style={stylesheet.workingIndicatorBubble}>
<View style={containerStyle}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
@@ -838,6 +910,14 @@ function WorkingIndicator() {
);
}
function InlineWorkingIndicatorSlot() {
return (
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
<WorkingIndicator variant="inline" />
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
@@ -882,16 +962,33 @@ function ToolCallSlot({
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCheckIcon = withUnistyles(Check);
const ThemedXIcon = withUnistyles(X);
const primaryColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const mutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const pressableStyle = ({
pressed,
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
hovered ? permissionStyles.optionButtonHovered : null,
pressed ? permissionStyles.optionButtonPressed : null,
];
interface PermissionActionButtonProps {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
textColor: string;
iconColor: string;
isDanger: boolean;
Icon: typeof Check;
isPrimary: boolean;
Icon: typeof ThemedCheckIcon;
testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void;
}
@@ -899,37 +996,21 @@ function PermissionActionButton({
action,
isRespondingAction,
isResponding,
textColor,
iconColor,
isDanger,
isPrimary,
Icon,
testID,
theme,
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent, isDanger],
);
const optionTextStyle = useMemo(
() => [permissionStyles.optionText, { color: textColor }],
[textColor],
);
const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText;
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Icon size={14} uniProps={colorMapping} />
<Text style={optionTextStyle}>{action.label}</Text>
</View>
)}
@@ -944,7 +1025,6 @@ function PermissionRequestCard({
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const { request } = permission;
@@ -1060,10 +1140,6 @@ function PermissionRequestCard({
[handleResponse],
);
const questionTextStyle = useMemo(
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const optionsContainerStyle = useMemo(
() => [
permissionStyles.optionsContainer,
@@ -1071,24 +1147,6 @@ function PermissionRequestCard({
],
[isMobile],
);
const cardContainerStyle = useMemo(
() => [
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[theme.colors.surface1, theme.colors.border],
);
const cardTitleStyle = useMemo(
() => [permissionStyles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cardDescriptionStyle = useMemo(
() => [permissionStyles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
if (request.kind === "question") {
return (
@@ -1102,18 +1160,15 @@ function PermissionRequestCard({
const footer = (
<>
<Text testID="permission-request-question" style={questionTextStyle}>
<Text testID="permission-request-question" style={permissionStyles.question}>
How would you like to proceed?
</Text>
<View style={optionsContainerStyle}>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon;
let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement")
@@ -1126,12 +1181,9 @@ function PermissionRequestCard({
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
textColor={textColor}
iconColor={iconColor}
isDanger={isDanger}
isPrimary={isPrimary}
Icon={Icon}
testID={testID}
theme={theme}
onPress={handleActionPress}
/>
);
@@ -1147,19 +1199,25 @@ function PermissionRequestCard({
description={description}
text={planMarkdown}
footer={footer}
testID="permission-plan-card"
disableOuterSpacing
/>
);
}
return (
<View style={cardContainerStyle}>
<Text style={cardTitleStyle}>{title}</Text>
<View style={permissionStyles.container}>
<Text style={permissionStyles.title}>{title}</Text>
{description ? <Text style={cardDescriptionStyle}>{description}</Text> : null}
{description ? <Text style={permissionStyles.description}>{description}</Text> : null}
{planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
<PlanCard
title="Proposed plan"
text={planMarkdown}
testID="permission-plan-card"
disableOuterSpacing
/>
) : null}
{!isPlanRequest ? (
@@ -1224,6 +1282,17 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
inlineTurnFooter: {
alignSelf: "flex-start",
marginTop: theme.spacing[2],
padding: theme.spacing[2],
paddingTop: 0,
},
inlineWorkingIndicatorFrame: {
height: 18,
alignItems: "center",
justifyContent: "center",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
@@ -1302,14 +1371,18 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
color: theme.colors.foreground,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[2],
@@ -1321,6 +1394,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted,
},
optionsContainer: {
gap: theme.spacing[2],
@@ -1337,6 +1411,11 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
alignItems: "center",
borderWidth: theme.borderWidth[1],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
optionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
optionButtonPressed: {
opacity: 0.9,
@@ -1349,9 +1428,15 @@ const permissionStyles = StyleSheet.create((theme) => ({
optionText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
optionTextPrimary: {
color: theme.colors.foreground,
},
}));
const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary];
interface StreamItemWrapperProps {
gapBelow: number;
children: ReactNode;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMemo } from "react";
interface BrowserPaneProps {
browserId: string;
serverId: string;
workspaceId: string;
cwd: string | null;
isInteractive?: boolean;
onFocusPane?: () => void;
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
</View>
);
}
const styles = StyleSheet.create(() => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
},
title: {
fontSize: 16,
fontWeight: "600",
},
subtitle: {
fontSize: 12,
},
}));
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface BrowserPaneProps {
browserId: string;
serverId: string;
workspaceId: string;
cwd: string | null;
isInteractive?: boolean;
onFocusPane?: () => void;
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>
Open this workspace in Electron to use the built-in browser.
</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
</View>
);
}
const styles = StyleSheet.create(() => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
},
title: {
fontSize: 16,
fontWeight: "600",
},
subtitle: {
fontSize: 12,
},
}));
+6 -3
View File
@@ -2,11 +2,14 @@ import type { Theme } from "@/styles/theme";
/**
* Compute the pixel width for a line-number gutter based on the highest
* line number that will be displayed. Minimum width accommodates 2 digits.
* line number that will be displayed and the gutter font size. Minimum
* width accommodates 2 digits. The 0.62 factor approximates monospace
* digit width as a fraction of font size.
*/
export function lineNumberGutterWidth(maxLineNumber: number): number {
export function lineNumberGutterWidth(maxLineNumber: number, fontSize: number): number {
const digits = Math.max(2, String(maxLineNumber).length);
return digits * 8 + 12;
const digitWidth = Math.ceil(fontSize * 0.62);
return digits * digitWidth + 12;
}
export function getCodeInsets(theme: Theme) {
@@ -63,8 +63,8 @@ describe("combined model selector helpers", () => {
}),
]);
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
expect(matchesSearch(rows[0], "claude")).toBe(true);
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
});
it("keeps the selected trigger label model-only", () => {
@@ -622,7 +622,7 @@ export function CombinedModelSelector({
const singleProviderView = useMemo<SelectorView | null>(() => {
const providers = Array.from(allProviderModels.keys());
if (providers.length !== 1) return null;
const providerId = providers[0]!;
const providerId = providers[0];
const label = resolveProviderLabel(providerDefinitions, providerId);
return { kind: "provider", providerId, providerLabel: label };
}, [allProviderModels, providerDefinitions]);
@@ -655,7 +655,7 @@ export function CombinedModelSelector({
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId);
onSelect(provider, modelId);
setIsOpen(false);
setSearchQuery("");
},
@@ -1,4 +1,5 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
@@ -17,6 +18,14 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
if (composerWorkspaceAttachment.is(attachment)) {
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
if (workspaceAttachment) {
reviewAttachments.push(workspaceAttachment);
}
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
if (reviewAttachment) {
reviewAttachments.push(reviewAttachment);
+424 -44
View File
@@ -4,10 +4,16 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
} from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
import { addReviewDraftComment, getReviewDraftComments, resetReviewDraftStore } from "@/review";
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
@@ -22,12 +28,12 @@ const {
deleteAttachmentsMock,
encodeImagesMock,
openExternalUrlMock,
markScrollInvestigationRenderMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
agentDirectoryStatusMock,
appSendBehavior,
} = vi.hoisted(() => {
const hoistedTheme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
@@ -36,6 +42,8 @@ const {
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { normal: "400", medium: "500" },
lineHeight: { diff: 18 },
opacity: { 50: 0.5 },
shadow: { md: {} },
colors: {
surface0: "#000",
@@ -100,7 +108,12 @@ const {
cancelAgent: vi.fn(async () => {}),
};
const hoistedSetQueuedMessagesMock = vi.fn();
const hoistedSetQueuedMessagesMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = hoistedMockSessionState.sessions[serverId];
session.queuedMessages = updater(session.queuedMessages);
},
);
const hoistedMockSessionState: {
sessions: Record<
string,
@@ -161,8 +174,8 @@ const {
);
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
const hoistedMarkScrollInvestigationRenderMock = vi.fn();
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
const hoistedAppSendBehavior = { current: "interrupt" as "interrupt" | "queue" };
return {
theme: hoistedTheme,
@@ -175,12 +188,12 @@ const {
deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}),
markScrollInvestigationRenderMock: hoistedMarkScrollInvestigationRenderMock,
mockSessionState: hoistedMockSessionState,
setAgentStreamTailMock: hoistedSetAgentStreamTailMock,
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
setQueuedMessagesMock: hoistedSetQueuedMessagesMock,
agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock,
appSendBehavior: hoistedAppSendBehavior,
};
});
@@ -188,6 +201,7 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
}));
@@ -212,7 +226,9 @@ vi.mock("lucide-react-native", () => {
Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"),
CircleDot: createIcon("CircleDot"),
MousePointer2: createIcon("MousePointer2"),
GitPullRequest: createIcon("GitPullRequest"),
MessageSquareCode: createIcon("MessageSquareCode"),
X: createIcon("X"),
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
@@ -299,7 +315,7 @@ vi.mock("@/utils/open-external-url", () => ({
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
useAppSettings: () => ({ settings: { sendBehavior: appSendBehavior.current } }),
}));
vi.mock("@/hooks/use-agent-autocomplete", () => ({
@@ -338,11 +354,6 @@ vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/scroll-jank", () => ({
markScrollInvestigationRender: markScrollInvestigationRenderMock,
markScrollInvestigationEvent: vi.fn(),
}));
vi.mock("@/components/agent-status-bar", () => ({
AgentStatusBar: () => null,
DraftAgentStatusBar: () => null,
@@ -562,9 +573,118 @@ let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
let latestAttachments: ComposerAttachment[] = [];
let workspaceBindingRenderCount = 0;
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
type BrowserElementComposerAttachment = Extract<ComposerAttachment, { kind: "browser_element" }>;
function reviewAttachment(body: string): ReviewAttachment {
return {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,2 +40,2 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
};
}
function reviewComposerAttachment(body: string): ReviewComposerAttachment {
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment: reviewAttachment(body),
};
}
function browserElementComposerAttachment(): BrowserElementComposerAttachment {
return {
kind: "browser_element",
attachment: {
url: "https://example.com/page",
selector: "button.primary",
tag: "button",
text: "Save",
outerHTML: '<button class="primary">Save</button>',
computedStyles: { display: "flex" },
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
reactSource: {
fileName: "src/save-button.tsx",
lineNumber: 12,
columnNumber: 3,
componentName: "SaveButton",
},
parentChain: ["form.settings"],
children: [],
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
},
};
}
function cloneReviewComposerAttachment(
attachment: ReviewComposerAttachment,
): ReviewComposerAttachment {
return {
...attachment,
attachment: {
...attachment.attachment,
comments: attachment.attachment.comments.map((comment) => ({
...comment,
context: {
...comment.context,
targetLine: { ...comment.context.targetLine },
lines: comment.context.lines.map((line) => ({ ...line })),
},
})),
},
};
}
function seedReviewDraft(key: string) {
addReviewDraftComment({
key,
comment: {
id: `${key}:comment`,
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body: "Please simplify this.",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
},
});
}
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
@@ -577,6 +697,7 @@ beforeEach(() => {
attachEvent: vi.fn(),
detachEvent: vi.fn(),
});
vi.stubGlobal("localStorage", dom.window.localStorage);
container = document.createElement("div");
document.body.appendChild(container);
@@ -591,13 +712,14 @@ beforeEach(() => {
deleteAttachmentsMock.mockClear();
encodeImagesMock.mockClear();
openExternalUrlMock.mockClear();
markScrollInvestigationRenderMock.mockClear();
setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear();
keyboardActionHandlerMock.mockClear();
agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready");
appSendBehavior.current = "interrupt";
workspaceBindingRenderCount = 0;
mockSessionState.sessions.server.serverInfo = {
serverId: "server",
hostname: "test",
@@ -615,6 +737,10 @@ beforeEach(() => {
mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map();
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "idle", lastUsage: null }],
]);
resetReviewDraftStore();
});
afterEach(() => {
@@ -642,20 +768,30 @@ function imageAttachment(id: string): AttachmentMetadata {
function ComposerHarness({
initialText = "",
initialAttachments = [],
workspaceAttachment = null,
isSubmitLoading = false,
submitBehavior,
}: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
const workspaceAttachments = React.useMemo(
() => (workspaceAttachment ? [workspaceAttachment] : []),
[workspaceAttachment],
);
latestAttachments = attachments;
const handleChangeAttachments = React.useCallback(
(updater: ComposerAttachment[] | ((current: ComposerAttachment[]) => ComposerAttachment[])) => {
(
updater:
| UserComposerAttachment[]
| ((current: UserComposerAttachment[]) => UserComposerAttachment[]),
) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
@@ -674,6 +810,7 @@ function ComposerHarness({
value={text}
onChangeText={setText}
attachments={attachments}
workspaceAttachments={workspaceAttachments}
onChangeAttachments={handleChangeAttachments}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
@@ -687,7 +824,8 @@ function ComposerHarness({
function renderComposer(
input: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
@@ -697,6 +835,20 @@ function renderComposer(
});
}
function WorkspaceAttachmentBindingHarness({
workspaceAttachment,
}: {
workspaceAttachment: ReviewComposerAttachment;
}) {
workspaceBindingRenderCount += 1;
const { selectedAttachments } = composerWorkspaceAttachment.useBinding({
normalAttachments: [],
workspaceAttachments: [workspaceAttachment],
});
return <div data-testid="workspace-binding-count">{selectedAttachments.length}</div>;
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
@@ -737,12 +889,6 @@ function dispatchAgentInterrupt() {
});
}
function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent",
).length;
}
describe("Composer keyboard shortcuts", () => {
it("interrupts a running agent without clearing a filled draft", async () => {
mockSessionState.sessions.server.agents = new Map([
@@ -917,6 +1063,261 @@ describe("Composer attachments", () => {
);
});
it("serializes workspace review attachments through the structured attachment path", async () => {
const review = reviewComposerAttachment("Please simplify this.");
expect(splitComposerAttachmentsForSubmit([review])).toEqual({
images: [],
attachments: [review.attachment],
});
});
it("serializes browser element workspace attachments as generic text attachments", async () => {
const browserElement = browserElementComposerAttachment();
expect(splitComposerAttachmentsForSubmit([browserElement])).toEqual({
images: [],
attachments: [
{
type: "text",
mimeType: "text/plain",
title: "Browser element · button",
text: browserElement.attachment.formatted,
},
],
});
});
it("does not enqueue redundant binding renders for equivalent workspace attachments", async () => {
const review = reviewComposerAttachment("Stable workspace review.");
act(() => {
root?.render(<WorkspaceAttachmentBindingHarness workspaceAttachment={review} />);
});
await flushAsyncWork();
expect(workspaceBindingRenderCount).toBe(1);
expect(queryByTestId("workspace-binding-count")?.textContent).toBe("1");
act(() => {
root?.render(
<WorkspaceAttachmentBindingHarness
workspaceAttachment={cloneReviewComposerAttachment(review)}
/>,
);
});
await flushAsyncWork();
expect(workspaceBindingRenderCount).toBe(2);
expect(queryByTestId("workspace-binding-count")?.textContent).toBe("1");
});
it("renders and submits a workspace review attachment pill", async () => {
const review = reviewComposerAttachment("Please simplify this.");
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("Review");
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("1 comment");
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
});
it("clears the included workspace review draft after a successful submit", async () => {
const review = reviewComposerAttachment("Clear submitted review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("restores only normal attachments when a submit with a workspace review fails", async () => {
const image = imageAttachment("img-failure");
const review = reviewComposerAttachment("This should be sent but not persisted.");
seedReviewDraft(review.reviewDraftKey);
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "review this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
expect(getReviewDraftComments(review.reviewDraftKey)).toHaveLength(1);
});
it("clears workspace review suppression after a send lifecycle", async () => {
const review = reviewComposerAttachment("Keep this available for the next message.");
renderComposer({
initialText: "send without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"send without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("keeps workspace review suppressed after a failed send", async () => {
const review = reviewComposerAttachment("Do not send this on retry.");
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "retry without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
1,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
2,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
});
it("captures workspace review attachments at queue time", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const initialReview = reviewComposerAttachment("Initial queued review.");
const editedReview = reviewComposerAttachment("Edited after queue.");
renderComposer({
initialText: "queue this",
workspaceAttachment: initialReview,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
renderComposer({
initialText: "",
workspaceAttachment: editedReview,
});
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([initialReview]);
});
it("clears the included workspace review draft after queueing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Clear queued review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "queue this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("clears workspace review suppression after queueing a message", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Queue without this review first.");
renderComposer({
initialText: "queue without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([]);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("does not restore queued workspace review attachments into live draft attachments when editing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const image = imageAttachment("img-queued-edit");
const review = reviewComposerAttachment("Queued snapshot.");
renderComposer({
initialText: "queue this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(
(
mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>
)[0]?.attachments,
).toEqual([{ kind: "image", metadata: image }, review]);
click(document.querySelector('[aria-label="Edit queued message"]')!);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("submits empty wire arrays when there are no composer attachments", async () => {
renderComposer({ initialText: "plain message" });
@@ -986,27 +1387,6 @@ describe("Composer attachments", () => {
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("does not re-render MessageInput when opening the attachment lightbox", () => {
const image = imageAttachment("img-lightbox-render");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
const renderCountBeforeLightbox = countMessageInputRenders();
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox);
});
it("still re-renders MessageInput when submit loading semantics change", () => {
renderComposer({ initialText: "pending submit", isSubmitLoading: false });
const renderCountBeforeLoading = countMessageInputRenders();
renderComposer({ initialText: "pending submit", isSubmitLoading: true });
expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1);
expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true);
});
it("enables dictation from server capabilities before the agent directory finishes loading", () => {
agentDirectoryStatusMock.mockReturnValue("initial_loading");
+145 -81
View File
@@ -7,7 +7,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import {
@@ -40,7 +40,7 @@ import {
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
@@ -62,7 +62,6 @@ import {
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { markScrollInvestigationRender } from "@/utils/scroll-jank";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
@@ -71,7 +70,13 @@ import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@server/shared/messages";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
@@ -87,13 +92,13 @@ interface QueuedMessage {
}
type AttachmentListUpdater =
| ComposerAttachment[]
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
function noop() {}
function resolveComposerButtonIconSize(theme: Theme): number {
return isWeb ? theme.iconSize.md : theme.iconSize.lg;
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
}
function resolveIsComposerLocked(
@@ -146,7 +151,7 @@ async function pickAndPersistImages(
);
}
function removeAttachmentAtIndex(prev: ComposerAttachment[], index: number): ComposerAttachment[] {
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
const removed = prev[index];
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
@@ -155,20 +160,17 @@ function removeAttachmentAtIndex(prev: ComposerAttachment[], index: number): Com
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled =
!isConnected || isCancellingAgent ? (styles.buttonDisabled as object) : undefined;
return [styles.cancelButton as object, disabled].filter((value): value is object =>
Boolean(value),
);
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
function buildRealtimeVoiceButtonStyle(
hovered: boolean | undefined,
voiceButtonDisabled: boolean,
): object[] {
const hoveredStyle = hovered ? (styles.iconButtonHovered as object) : undefined;
const disabledStyle = voiceButtonDisabled ? (styles.buttonDisabled as object) : undefined;
return [styles.realtimeVoiceButton as object, hoveredStyle, disabledStyle].filter(
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
return [styles.realtimeVoiceButton, hoveredStyle, disabledStyle].filter(
(value): value is object => Boolean(value),
);
}
@@ -243,26 +245,28 @@ function findGithubItemByOption(
}
function isAttachmentSelectedForGithubItem(
attachments: readonly ComposerAttachment[],
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
): boolean {
return attachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return composerWorkspaceAttachment
.userAttachmentsOnly(current)
.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
function buildGithubAttachment(item: GitHubSearchItem): ComposerAttachment {
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
function toggleGithubAttachment(
current: ComposerAttachment[],
current: UserComposerAttachment[],
item: GitHubSearchItem,
): ComposerAttachment[] {
const matches = (attachment: ComposerAttachment) =>
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
@@ -362,6 +366,15 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
index,
disabled,
onOpen,
onRemove,
});
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -447,6 +460,7 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream({ ...args, userMessage });
const imagesData = await encodeImages(wirePayload.images);
@@ -460,11 +474,16 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
function openComposerAttachment(
attachment: ComposerAttachment,
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
): void {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
if (composerWorkspaceAttachment.is(attachment)) {
openWorkspaceAttachment({ attachment });
return;
}
void openExternalUrl(attachment.item.url);
}
@@ -585,7 +604,6 @@ interface QueuedMessageRowProps {
}
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
const { theme } = useUnistyles();
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
@@ -598,11 +616,19 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
{item.text}
</Text>
<View style={styles.queueActions}>
<Pressable onPress={handleEdit} style={styles.queueActionButton}>
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
<Pressable
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel="Edit queued message"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
</Pressable>
<Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}>
<ArrowUp size={theme.iconSize.sm} color="white" />
<Pressable
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now"
>
<ArrowUp size={ICON_SIZE.sm} color="white" />
</Pressable>
</View>
</View>
@@ -654,7 +680,7 @@ function ImageAttachmentPill({
}
interface GithubAttachmentPillProps {
attachment: Exclude<ComposerAttachment, { kind: "image" }>;
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
@@ -668,7 +694,6 @@ function GithubAttachmentPill({
onOpen,
onRemove,
}: GithubAttachmentPillProps) {
const { theme } = useUnistyles();
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const handleOpen = useCallback(() => {
@@ -689,9 +714,9 @@ function GithubAttachmentPill({
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
@@ -719,18 +744,17 @@ function GithubPickerOption({
item,
onToggle,
}: GithubPickerOptionProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onToggle(item);
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
),
[item.kind, theme.iconSize.sm, theme.colors.foregroundMuted],
[item.kind],
);
return (
<ComboboxItem
@@ -763,7 +787,9 @@ interface ComposerProps {
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: ComposerAttachment[];
attachments: UserComposerAttachment[];
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
@@ -824,9 +850,7 @@ function ComposerCancelButton({
) : (
<Square size={buttonIconSize} color="white" fill="white" />
);
const shortcutNode = agentInterruptKeys ? (
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
) : null;
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
@@ -872,7 +896,6 @@ interface ComposerVoiceModeButtonProps {
realtimeVoiceButtonStyle: (
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
theme: Theme;
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
}
@@ -911,21 +934,18 @@ function ComposerVoiceModeButton({
isConnected,
isVoiceSwitching,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null;
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const renderTriggerContent = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
if (isVoiceSwitching) {
return <ActivityIndicator size="small" color="white" />;
}
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return <AudioLines size={buttonIconSize} color={color} />;
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
},
[buttonIconSize, isVoiceSwitching, theme.colors.foreground, theme.colors.foregroundMuted],
[buttonIconSize, isVoiceSwitching],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -963,6 +983,8 @@ export function Composer({
value,
onChangeText,
attachments,
workspaceAttachments = [],
onOpenWorkspaceAttachment,
onChangeAttachments,
cwd,
clearDraft,
@@ -977,9 +999,7 @@ export function Composer({
statusControls,
inputWrapperStyle,
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = resolveComposerButtonIconSize(theme);
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1013,7 +1033,19 @@ export function Composer({
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
const userInput = value;
const setUserInput = onChangeText;
const selectedAttachments = attachments;
const {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
} = composerWorkspaceAttachment.useBinding({
normalAttachments: attachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
@@ -1170,8 +1202,18 @@ export function Composer({
setUserInput("");
setSelectedAttachments([]);
resetSuppression();
clearSentAttachments(queuedAttachments);
},
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
[
agentId,
clearSentAttachments,
resetSuppression,
serverId,
setQueuedMessages,
setSelectedAttachments,
setUserInput,
],
);
const sendMessageWithContent = useCallback(
@@ -1180,7 +1222,7 @@ export function Composer({
outgoingAttachments: ComposerAttachment[],
forceSend?: boolean,
) => {
await submitAgentInput({
const result = await submitAgentInput({
message: outgoingMessage,
attachments: outgoingAttachments,
hasExternalContent,
@@ -1200,7 +1242,7 @@ export function Composer({
clearDraft,
setUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(nextAttachments);
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
},
setSendError,
setIsProcessing,
@@ -1208,10 +1250,15 @@ export function Composer({
console.error("[AgentInput] Failed to send message:", error);
},
});
completeSubmit({
result,
outgoingAttachments,
});
},
[
allowEmptySubmit,
clearDraft,
completeSubmit,
hasExternalContent,
isAgentRunning,
queueMessage,
@@ -1227,9 +1274,13 @@ export function Composer({
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
void sendMessageWithContent(
payload.text,
buildOutgoingAttachments(attachments),
payload.forceSend,
);
},
[blurOnSubmit, sendMessageWithContent],
[attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent],
);
const handlePickImage = useCallback(async () => {
@@ -1240,14 +1291,24 @@ export function Composer({
const handleRemoveAttachment = useCallback(
(index: number) => {
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
});
if (didRemoveWorkspaceAttachment) {
return;
}
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
},
[setSelectedAttachments],
[removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback((attachment: ComposerAttachment) => {
openComposerAttachment(attachment, setLightboxMetadata);
}, []);
const handleOpenAttachment = useCallback(
(attachment: ComposerAttachment) => {
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
},
[openAttachment],
);
useEffect(() => {
if (!isAgentRunning || !isConnected) {
@@ -1335,7 +1396,7 @@ export function Composer({
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedAttachments(item.attachments);
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
},
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
);
@@ -1361,9 +1422,9 @@ export function Composer({
const handleQueue = useCallback(
(payload: MessagePayload) => {
queueMessage(payload.text, payload.attachments);
queueMessage(payload.text, buildOutgoingAttachments(attachments));
},
[queueMessage],
[attachments, buildOutgoingAttachments, queueMessage],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
@@ -1416,7 +1477,6 @@ export function Composer({
],
);
const isVoiceSwitchingValue = voice?.isVoiceSwitching ?? false;
const rightContent = useMemo(
() => (
<ComposerRightControlsSlot
@@ -1428,9 +1488,8 @@ export function Composer({
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitchingValue}
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
theme={theme}
voiceToggleKeys={voiceToggleKeys}
cancelButton={cancelButton}
/>
@@ -1445,9 +1504,8 @@ export function Composer({
isConnected,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitchingValue,
isVoiceSwitching,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
],
);
@@ -1491,7 +1549,7 @@ export function Composer({
{
id: "image",
label: "Add image",
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickImage();
},
@@ -1499,13 +1557,13 @@ export function Composer({
{
id: "github",
label: "Add issue or PR",
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md],
[handlePickImage],
);
const handleToggleGithubItem = useCallback(
@@ -1809,10 +1867,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
tooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
buttonDisabled: {
opacity: 0.5,
},
@@ -1860,3 +1914,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
})) as unknown as Record<string, object>;
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton];
const ThemedPencil = withUnistyles(Pencil);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -138,7 +138,6 @@ const styles = StyleSheet.create((theme) => ({
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
tooltipText: {
color: theme.colors.foreground,
@@ -1,209 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const hostsState = vi.hoisted(() => ({
value: [{ serverId: "server-1", label: "Local daemon" }],
}));
const sessionState = vi.hoisted(() => ({
value: {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
},
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => hostsState.value,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: typeof sessionState.value) => unknown) =>
selector(sessionState.value),
}));
vi.mock("@/utils/app-version", () => ({
resolveAppVersion: () => "1.2.3",
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source";
function Harness() {
return (
<SidebarCalloutProvider>
<DaemonVersionMismatchCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
);
}
async function renderHarness(root: Root): Promise<void> {
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
}
describe("DaemonVersionMismatchCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
hostsState.value = [{ serverId: "server-1", label: "Local daemon" }];
sessionState.value = {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
};
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("registers a daemon mismatch sidebar callout", async () => {
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).not.toBeNull();
expect(container?.textContent).toContain("Daemon version mismatch");
expect(container?.textContent).toContain("Local daemon is running v1.2.2");
expect(container?.textContent).toContain("This app is v1.2.3");
expect(container?.textContent).toContain(
"For the best experience, keep the daemon and client on the same version.",
);
});
it("does not register a callout when versions match", async () => {
sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3";
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
it("opens host settings from the callout action", async () => {
await renderHarness(root!);
const action = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-action-0"]',
) as HTMLElement | null;
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1");
});
it("persists dismissal for the current app-daemon version pair", async () => {
await renderHarness(root!);
const dismiss = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]',
) as HTMLElement | null;
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]),
);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
});
@@ -1,79 +0,0 @@
import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { resolveAppVersion } from "@/utils/app-version";
import { buildSettingsHostRoute } from "@/utils/host-routes";
interface DaemonVersionMismatch {
serverId: string;
label: string;
appVersion: string;
daemonVersion: string;
}
function useDaemonVersionMismatches(): DaemonVersionMismatch[] {
const hosts = useHosts();
const sessions = useSessionStore((state) => state.sessions);
const appVersion = resolveAppVersion();
return useMemo(() => {
if (!appVersion) {
return [];
}
return hosts
.map((entry) => ({
serverId: entry.serverId,
label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon",
appVersion,
daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null,
}))
.filter((entry): entry is DaemonVersionMismatch =>
isVersionMismatch(entry.appVersion, entry.daemonVersion),
);
}, [appVersion, hosts, sessions]);
}
function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) {
const callouts = useSidebarCallouts();
const router = useRouter();
const { appVersion, daemonVersion, label, serverId } = mismatch;
const openSettings = useStableEvent(() => {
router.navigate(buildSettingsHostRoute(serverId));
});
useEffect(() => {
return callouts.show({
id: `daemon-version-mismatch:${serverId}`,
dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`,
priority: 300,
title: "Daemon version mismatch",
description: `${label} is running ${formatVersionWithPrefix(
daemonVersion,
)}. This app is ${formatVersionWithPrefix(
appVersion,
)}. For the best experience, keep the daemon and client on the same version.`,
variant: "error",
actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }],
testID: `daemon-version-mismatch-callout-${serverId}`,
});
}, [appVersion, callouts, daemonVersion, label, openSettings, serverId]);
return null;
}
export function DaemonVersionMismatchCalloutSource() {
const mismatches = useDaemonVersionMismatches();
return (
<>
{mismatches.map((mismatch) => (
<DaemonVersionMismatchRegistration key={mismatch.serverId} mismatch={mismatch} />
))}
</>
);
}
@@ -483,6 +483,7 @@ function SidebarContent({
workspaceId={workspaceId}
cwd={workspaceRoot}
hideHeaderRow={!isMobile}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (

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