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
349 changed files with 11244 additions and 4291 deletions
+17
View File
@@ -124,6 +124,7 @@ jobs:
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
@@ -154,6 +155,15 @@ jobs:
- 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
@@ -242,7 +252,12 @@ 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
@@ -266,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"
+3 -21
View File
@@ -52,7 +52,6 @@ env:
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_WORKSPACE: "@getpaseo/desktop"
DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs:
@@ -148,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:
@@ -167,7 +163,7 @@ jobs:
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 --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
@@ -243,9 +239,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: Install Linux smoke display
run: sudo apt-get update && sudo apt-get install -y xvfb
@@ -260,7 +253,7 @@ jobs:
build_args=(-- --publish never --linux --x64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
@@ -336,17 +329,6 @@ 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:
@@ -358,7 +340,7 @@ jobs:
build_args=(-- --publish never --win --x64 --arm64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
+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": [
+28 -8
View File
@@ -1,18 +1,35 @@
# Changelog
## 0.1.65-beta.4 - 2026-05-02
## 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 and click any element on the page to send it to the agent as context. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
- Inline review comments in the git diff pane, including tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
- Sub-agent activity now streams into the timeline as nested tool calls — Codex sub-agents, OpenCode sub-agents, and Claude sub-agents all show their work inline. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
- **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))
- Connect directly via TCP URI with SSL and optional password auth. ([#635](https://github.com/getpaseo/paseo/pull/635))
- 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 for Snapdragon X / Copilot+ PCs.
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
- **Windows:** Native ARM64 builds are now available.
- Bundled Paseo skills now refresh automatically on desktop app launch.
### Improved
@@ -31,6 +48,9 @@
- 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.
@@ -43,7 +63,7 @@
- `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))
- Browser pane keyboard handling no longer steals shortcuts from the rest of the app.
- 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
+2
View File
@@ -228,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
+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-m9SEG8ecj6W+XV3LDJKE6Psl5+EOHoFoAxzcJ5IkqC0=";
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).
+118 -13
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -23,12 +23,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",
@@ -10375,6 +10377,90 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint-tsgolint/darwin-arm64": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.22.1.tgz",
"integrity": "sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxlint-tsgolint/darwin-x64": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.22.1.tgz",
"integrity": "sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxlint-tsgolint/linux-arm64": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.22.1.tgz",
"integrity": "sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxlint-tsgolint/linux-x64": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.22.1.tgz",
"integrity": "sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxlint-tsgolint/win32-arm64": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.22.1.tgz",
"integrity": "sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxlint-tsgolint/win32-x64": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.22.1.tgz",
"integrity": "sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz",
@@ -31173,6 +31259,24 @@
}
}
},
"node_modules/oxlint-tsgolint": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.22.1.tgz",
"integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==",
"dev": true,
"license": "MIT",
"bin": {
"tsgolint": "bin/tsgolint.js"
},
"optionalDependencies": {
"@oxlint-tsgolint/darwin-arm64": "0.22.1",
"@oxlint-tsgolint/darwin-x64": "0.22.1",
"@oxlint-tsgolint/linux-arm64": "0.22.1",
"@oxlint-tsgolint/linux-x64": "0.22.1",
"@oxlint-tsgolint/win32-arm64": "0.22.1",
"@oxlint-tsgolint/win32-x64": "0.22.1"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -38444,7 +38548,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38570,10 +38674,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.65-beta.4",
"@getpaseo/server": "0.1.67",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38615,7 +38719,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38664,7 +38768,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -38700,7 +38804,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -38726,7 +38830,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38741,12 +38845,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.65-beta.4",
"@getpaseo/relay": "0.1.65-beta.4",
"@getpaseo/highlight": "0.1.67",
"@getpaseo/relay": "0.1.67",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -38761,6 +38865,7 @@
"dotenv": "^17.2.3",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
@@ -39198,7 +39303,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.65-beta.4",
"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 && PASEO_WEB_PLATFORM=electron 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,12 +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",
@@ -43,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
+1 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
@@ -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 };
+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
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();
+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 () => {
@@ -14,6 +14,7 @@ interface ProjectsSettingsProject {
interface ProjectsSettingsFixtures {
editableProject: ProjectsSettingsProject;
gitlabRemoteProject: ProjectsSettingsProject;
}
const initialPaseoConfig = {
@@ -46,6 +47,22 @@ const test = base.extend<ProjectsSettingsFixtures>({
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();
},
@@ -119,4 +136,16 @@ test.describe("Projects settings", () => {
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);
});
});
+5 -9
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);
@@ -67,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);
@@ -85,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
+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,
};
}
@@ -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
@@ -337,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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.65-beta.4",
"version": "0.1.67",
"private": true,
"main": "index.ts",
"scripts": {
@@ -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}`);
+60 -44
View File
@@ -26,7 +26,7 @@ import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-g
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 { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
@@ -50,17 +50,18 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
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 { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean {
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 }) {
useEffect(() => {
const store = getHostRuntimeStore();
@@ -319,7 +328,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: shouldUseDesktopDaemon(),
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
@@ -346,12 +356,18 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => {
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
startDaemonIfGateAllows({
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
const storeReady =
const isCurrentlyStoreReady =
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
@@ -393,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);
@@ -407,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();
@@ -450,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} />
@@ -594,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(() => {
@@ -609,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 />
@@ -632,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,
}: {
@@ -772,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
@@ -911,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,
},
}));
@@ -55,6 +55,42 @@ describe("startHostRuntimeBootstrap", () => {
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;
+35 -3
View File
@@ -11,17 +11,49 @@ export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
}
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: boolean;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
if (input.shouldStartDaemon) {
void input.daemonStartService.start();
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";
@@ -22,9 +22,9 @@ class FakeRequest<T = unknown> {
class FakeObjectStore {
constructor(private readonly onPut: (record: unknown) => void) {}
put(record: unknown): FakeRequest<unknown> {
put(record: unknown): FakeRequest {
this.onPut(record);
const request = new FakeRequest<unknown>();
const request = new FakeRequest();
queueMicrotask(() => request.emit("success"));
return request;
}
@@ -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", () => {
@@ -234,7 +234,7 @@ function buildFallbackAllProviderModels(
map.set(
provider,
modelOptions.map((option) => ({
provider: provider as AgentProvider,
provider: provider,
id: option.id,
label: option.label,
})),
@@ -22,6 +22,7 @@ import {
isElectronRuntime,
type DesktopBrowserShortcutEvent,
} from "@/desktop/host";
import { isDev } from "@/constants/platform";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
type ElectronWebview = HTMLElement & {
@@ -460,7 +461,7 @@ export function BrowserPane({
};
const handleFaviconUpdated = (event: Event) => {
const favicons = Array.isArray((event as Event & { favicons?: unknown[] }).favicons)
? (((event as Event & { favicons?: string[] }).favicons as string[] | undefined) ?? [])
? ((event as Event & { favicons?: string[] }).favicons ?? [])
: [];
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
};
@@ -971,17 +972,19 @@ export function BrowserPane({
/>
</View>
<View style={styles.chromeRight}>
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</Pressable>
{isDev ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</Pressable>
) : null}
</View>
</View>
{browser?.lastError ? (
@@ -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("");
},
+5 -8
View File
@@ -160,20 +160,17 @@ function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index:
}
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),
);
}
@@ -132,7 +132,7 @@ function TreeRowItem({
);
const handleCopy = useCallback(() => {
void onCopyPath(entry.path);
onCopyPath(entry.path);
}, [onCopyPath, entry.path]);
const handleDownload = useCallback(() => {
+38 -34
View File
@@ -159,7 +159,9 @@ interface MarkdownWithStableRendererProps {
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
const markdownStyleMapping = (theme: Theme) => ({ style: createMarkdownStyles(theme) }) as never;
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
style: createMarkdownStyles(theme),
});
const ThemedMicVocal = withUnistyles(MicVocal);
const ThemedTodoCheckIcon = withUnistyles(Check);
@@ -428,6 +430,8 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
return `Issue #${attachment.number}`;
case "text":
return attachment.title ?? "Text attachment";
default:
return "";
}
}
@@ -634,7 +638,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
useEffect(() => {
if (cachedMetadata) {
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
return;
return () => {};
}
setLoadState({ status: "loading" });
@@ -924,11 +928,11 @@ function getInlineCodeAutoLinkUrl(
return null;
}
const matches = markdownParser.linkify.match(trimmed) as Array<{
const matches: Array<{
index: number;
lastIndex: number;
url: string;
}> | null;
}> | null = markdownParser.linkify.match(trimmed);
if (!matches || matches.length !== 1) {
return null;
}
@@ -950,7 +954,7 @@ function nodeHasParentType(parent: unknown, type: string): boolean {
typeof parent === "object" &&
parent !== null &&
"type" in parent &&
(parent as { type?: string }).type === type
(parent as Record<"type", unknown>)["type"] === type
);
}
@@ -1638,16 +1642,13 @@ export const AssistantMessage = memo(function AssistantMessage({
style={styles.link}
onPress={handleLinkPress}
>
{Children.map(children, (child) =>
isValidElement(child)
? cloneElement(child, {
style: [
(child.props as { style?: StyleProp<TextStyle> }).style,
{ color: styles.link.color as string | undefined },
],
} as Partial<{ style: StyleProp<TextStyle> }>)
: child,
)}
{Children.map(children, (child) => {
if (!isValidElement(child)) return child;
const childProps = child.props as { style?: StyleProp<TextStyle> };
return cloneElement(child, {
style: [childProps.style, { color: styles.link.color }],
} as Partial<{ style: StyleProp<TextStyle> }>);
})}
</MarkdownLink>
),
image: (
@@ -1740,8 +1741,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
},
headerLabel: {
fontFamily: Fonts.sans,
fontSize: 12,
fontWeight: "500",
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
text: {
@@ -1769,7 +1770,7 @@ export const SpeakMessage = memo(function SpeakMessage({
return (
<View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}>
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
</View>
<Text style={speakMessageStylesheet.text}>{message}</Text>
@@ -1810,9 +1811,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
successBg: {
backgroundColor: "rgba(20, 83, 45, 0.3)",
},
errorBg: {
backgroundColor: "rgba(127, 29, 29, 0.3)",
},
errorBg: {},
artifactBg: {
backgroundColor: "rgba(30, 58, 138, 0.4)",
},
@@ -1827,6 +1826,8 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
},
iconContainer: {
flexShrink: 0,
height: 20,
justifyContent: "center",
},
textContainer: {
flex: 1,
@@ -1936,7 +1937,9 @@ export const ActivityLog = memo(function ActivityLog({
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text style={messageTextStyle}>{displayMessage}</Text>
<Text style={messageTextStyle} selectable>
{displayMessage}
</Text>
{metadata && (
<View style={activityLogStylesheet.detailsRow}>
<Text style={activityLogStylesheet.detailsText}>Details</Text>
@@ -2419,12 +2422,13 @@ function useDetailWheelPropagationBlocker(input: {
const { detailWrapperRef, enabled } = input;
useEffect(() => {
if (!enabled) {
return;
return () => {};
}
const node = detailWrapperRef.current as unknown as HTMLElement | null;
if (!node || typeof node.addEventListener !== "function") {
return;
const rawRef: unknown = detailWrapperRef.current;
if (!(rawRef instanceof HTMLElement)) {
return () => {};
}
const node = rawRef;
const stopWheelPropagation = (event: WheelEvent) => {
if (shouldStopDetailWheelPropagation(node, event)) {
event.stopPropagation();
@@ -2594,7 +2598,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
enabled: !isNative && isExpanded && hasDetailContent,
});
const shimmerLabelStyle = useMemo(
const shimmerLabelStyle = useMemo<StyleProp<TextStyle>>(
() =>
buildShimmerTextStyle({
isWebShimmer,
@@ -2603,7 +2607,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
webShimmerTrackStart,
webShimmerTrackEnd,
offsetX: labelOffsetX,
}) as never,
}),
[
isWebShimmer,
webShimmerPeakWidth,
@@ -2614,7 +2618,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
],
);
const shimmerSecondaryStyle = useMemo(
const shimmerSecondaryStyle = useMemo<StyleProp<TextStyle>>(
() =>
buildShimmerTextStyle({
isWebShimmer,
@@ -2623,7 +2627,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
webShimmerTrackStart,
webShimmerTrackEnd,
offsetX: secondaryOffsetX,
}) as never,
}),
[
isWebShimmer,
webShimmerPeakWidth,
@@ -2805,9 +2809,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
interface ToolCallProps {
toolName: string;
args?: unknown | null;
result?: unknown | null;
error?: unknown | null;
args?: unknown;
result?: unknown;
error?: unknown;
status: "executing" | "running" | "completed" | "failed" | "canceled";
detail?: ToolCallDetail;
cwd?: string;
@@ -2946,7 +2950,7 @@ export const ToolCall = memo(function ToolCall({
useEffect(() => {
if (!onInlineDetailsExpandedChange) {
return;
return () => {};
}
return () => {
onInlineDetailsExpandedChange(false);
@@ -1,169 +0,0 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PrPaneData } from "@/utils/pr-pane-data";
import { PrPane } from "./pr-pane";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
colors: {
surfaceSidebar: "#0b0b0b",
surfaceSidebarHover: "#151515",
border: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
statusSuccess: "#30d158",
statusDanger: "#ff453a",
statusWarning: "#f2c945",
statusMerged: "#a371f7",
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
CircleCheck: createIcon("CircleCheck"),
CircleDot: createIcon("CircleDot"),
CircleSlash: createIcon("CircleSlash"),
CircleX: createIcon("CircleX"),
ExternalLink: createIcon("ExternalLink"),
GitMerge: createIcon("GitMerge"),
GitPullRequest: createIcon("GitPullRequest"),
GitPullRequestClosed: createIcon("GitPullRequestClosed"),
GitPullRequestDraft: createIcon("GitPullRequestDraft"),
MessageSquare: createIcon("MessageSquare"),
};
});
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function makeData(overrides: Partial<PrPaneData> = {}): PrPaneData {
return {
number: 1284,
title: "fix(server,app): coalesce tool call stream events",
state: "open",
url: "https://github.com/getpaseo/paseo/pull/1284",
reviewDecision: "changes_requested",
awaitingReviewers: [],
checks: [
{ name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" },
{ name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" },
],
activity: [
{
kind: "review",
author: "alicek",
avatarColor: "#8b5cf6",
reviewState: "approved",
body: "LGTM",
age: "2h ago",
url: "#",
},
{
kind: "comment",
author: "bmartin",
avatarColor: "#f97316",
body: "Worth a benchmark before/after.",
age: "3h ago",
url: "#",
},
],
...overrides,
};
}
function render(data: PrPaneData) {
act(() => {
root?.render(<PrPane data={data} />);
});
}
describe("PrPane", () => {
it("renders the PR title and 'Open' state label from the data prop", () => {
render(makeData());
expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events");
expect(container?.textContent).toContain("Open");
});
it("renders the correct state label and icon for each PR state", () => {
render(makeData({ state: "draft" }));
expect(container?.textContent).toContain("Draft");
expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull();
render(makeData({ state: "merged" }));
expect(container?.textContent).toContain("Merged");
expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull();
render(makeData({ state: "closed" }));
expect(container?.textContent).toContain("Closed");
expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull();
});
it("derives check summary counts from the data prop", () => {
render(makeData());
const text = container?.textContent ?? "";
expect(text).toContain("typecheck");
expect(text).toContain("test");
// 1 passed, 1 failed, 0 pending — so pending pill is hidden.
expect(text).toMatch(/1.*1/);
});
it("derives review summary counts and renders activity rows", () => {
render(makeData());
const text = container?.textContent ?? "";
expect(text).toContain("alicek");
expect(text).toContain("Approved");
expect(text).toContain("bmartin");
expect(text).toContain("Commented");
});
});
+1 -14
View File
@@ -16,6 +16,7 @@ import {
MessageSquare,
} from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url";
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
import type {
CheckStatus,
PrPaneActivity,
@@ -283,13 +284,6 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
);
}
function getActivityVerb(item: PrPaneActivity): string {
if (item.kind === "comment") return "Commented";
if (item.reviewState === "approved") return "Approved";
if (item.reviewState === "changes_requested") return "Requested changes";
return "Reviewed";
}
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
if (state === "open") return theme.colors.statusSuccess;
if (state === "draft") return theme.colors.foregroundMuted;
@@ -304,13 +298,6 @@ function getStateIcon(state: PrState) {
return GitPullRequest;
}
function getStateLabel(state: PrState): string {
if (state === "draft") return "Draft";
if (state === "merged") return "Merged";
if (state === "closed") return "Closed";
return "Open";
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
@@ -29,7 +29,7 @@ interface PathRowProps {
function PathRow({ path, active, onSelect }: PathRowProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
void onSelect(path);
onSelect(path);
}, [onSelect, path]);
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -172,7 +172,7 @@ export function ProjectPickerModal() {
if (key === "Enter") {
event.preventDefault();
if (options.length > 0 && activeIndex < options.length) {
void handleSelectPath(options[activeIndex]!);
void handleSelectPath(options[activeIndex]);
} else if (query.trim()) {
handleSubmitCustom();
}
@@ -117,7 +117,7 @@ function CustomModelsSection(props: {
},
},
});
await refresh([provider as AgentProvider]);
await refresh([provider]);
},
[patchConfig, provider, refresh],
);
@@ -284,7 +284,7 @@ export function ProviderDiagnosticSheet({
}
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
const result = await client.getProviderDiagnostic(provider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
@@ -308,7 +308,7 @@ export function ProviderDiagnosticSheet({
if (!provider) {
return;
}
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
void Promise.all([refresh([provider]), fetchDiagnostic()]).catch((err) => {
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
});
}, [fetchDiagnostic, provider, refresh]);
@@ -37,9 +37,9 @@ import {
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
@@ -351,7 +351,7 @@ describe("sidebar workspace render isolation", () => {
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
...createWorkspaces()[1]!,
...createWorkspaces()[1],
status: "running",
},
]);
@@ -104,9 +104,14 @@ import {
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
@@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: workspace.workspaceId,
});
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return snapshot;
}
function restoreOptimisticallyHiddenWorkspace(input: {
serverId: string;
workspaceId: string;
snapshot: WorkspaceDescriptor | null;
}): void {
clearWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (input.snapshot) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
}
}
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -404,7 +439,7 @@ function WorkspaceStatusIndicator({
);
}
let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
let KindIcon: typeof ThemedMonitor | null;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = null;
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
toast.error(message);
});
})();
}, [
archiveWorktree,
isArchiving,
redirectAfterArchive,
toast,
workspace.name,
workspace.workspaceDirectory,
workspace.serverId,
workspace.workspaceId,
]);
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
});
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
});
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
return (
<>
@@ -2120,13 +2144,28 @@ function ProjectBlock({
}
setIsRemovingProject(true);
const snapshots = new Map(
project.workspaces.map((workspace) => [
workspace.workspaceId,
hideWorkspaceOptimistically(workspace),
]),
);
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
void Promise.allSettled(
project.workspaces.map(async (ws) => {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
try {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId,
workspaceId: ws.workspaceId,
snapshot: snapshots.get(ws.workspaceId) ?? null,
});
throw error;
}
}),
).then((results) => {
+35 -24
View File
@@ -125,6 +125,26 @@ interface SplitPaneDropData {
paneId: string;
}
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
}
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
return (
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
);
}
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
return isWorkspaceTabDragData(data) ? data : undefined;
}
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
if (isWorkspaceTabDragData(data)) return data;
if (isSplitPaneDropData(data)) return data;
return undefined;
}
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
node: SplitNode;
uiTabs: WorkspaceTab[];
@@ -185,10 +205,10 @@ const MountedTabSlot = memo(function MountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const wrapperStyle = useMemo(
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
[isVisible],
);
const wrapperStyle = useMemo(() => {
const display: "flex" | "none" = isVisible ? "flex" : "none";
return { display, flex: 1 };
}, [isVisible]);
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
@@ -388,8 +408,8 @@ export function SplitContainer({
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as WorkspaceTabDragData | undefined;
if (data?.kind !== "workspace-tab") {
const data = asWorkspaceTabDragData(event.active.data.current);
if (!data) {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
@@ -406,11 +426,8 @@ export function SplitContainer({
const updateDropPreview = useCallback(
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
const activeData = asWorkspaceTabDragData(event.active.data.current);
const overData = asDragOverData(event.over?.data.current);
if (activeData?.kind !== "workspace-tab") {
setDropPreview(null);
@@ -513,11 +530,8 @@ export function SplitContainer({
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
const activeData = asWorkspaceTabDragData(event.active.data.current);
const overData = asDragOverData(event.over?.data.current);
setActiveDragTabId(null);
@@ -905,17 +919,14 @@ function SplitPaneView({
useEffect(() => {
if (isNative) {
return;
return () => {};
}
const paneElement = paneRef.current as unknown as HTMLElement | null;
if (
!paneElement ||
typeof paneElement.addEventListener !== "function" ||
typeof paneElement.removeEventListener !== "function"
) {
return;
const rawRef: unknown = paneRef.current;
if (!(rawRef instanceof HTMLElement)) {
return () => {};
}
const paneElement = rawRef;
const handlePanePointerDown = (event: PointerEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
@@ -3,6 +3,7 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
@@ -18,6 +19,7 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
import {
@@ -127,6 +129,7 @@ interface TerminalEmulatorProps {
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
@@ -140,6 +143,16 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function isTerminalState(value: unknown): value is TerminalState {
return (
typeof value === "object" &&
value !== null &&
"rows" in value &&
"cols" in value &&
"grid" in value
);
}
function ensureTerminalScrollbarStyle(): void {
if (typeof document === "undefined") {
return;
@@ -181,6 +194,7 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
@@ -198,6 +212,8 @@ export default function TerminalEmulator({
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const xtermThemeRef = useRef(xtermTheme);
xtermThemeRef.current = xtermTheme;
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
onRendererReadyChangeRef.current = onRendererReadyChange;
const mountCallbacksRef = useRef({
onInput,
onResize,
@@ -224,20 +240,41 @@ export default function TerminalEmulator({
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
useDOMImperativeHandle(
ref as Ref<DOMImperativeFactory>,
() =>
({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
domBridgeRef,
(): DOMImperativeFactory => ({
writeOutput: (...args) => {
const text = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text });
},
renderSnapshot: (...args) => {
const state = args[0];
if (state === null) {
runtimeRef.current?.renderSnapshot({ state: null });
} else if (isTerminalState(state)) {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}) as unknown as DOMImperativeFactory,
}
},
clear: () => {
runtimeRef.current?.clear();
},
}),
[],
);
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}),
[],
);
@@ -254,7 +291,7 @@ export default function TerminalEmulator({
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
return;
return () => {};
}
const SWIPE_MIN_PX = 22;
@@ -371,7 +408,7 @@ export default function TerminalEmulator({
const host = hostRef.current;
const root = rootRef.current;
if (!host || !root) {
return;
return () => {};
}
const runtime = new TerminalEmulatorRuntime();
@@ -389,9 +426,11 @@ export default function TerminalEmulator({
initialSnapshot: initialSnapshotRef.current,
theme: mountedThemeRef.current,
});
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
return () => {
runtime.unmount();
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}
@@ -416,7 +455,7 @@ export default function TerminalEmulator({
useEffect(() => {
if (focusRequestToken <= 0) {
return;
return () => {};
}
runtimeRef.current?.resize({ force: true });
return focusWithRetries({
@@ -444,14 +483,14 @@ export default function TerminalEmulator({
useEffect(() => {
const host = hostRef.current;
if (!host) {
return;
return () => {};
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
if (!viewportElement) {
viewportRef.current = null;
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
return;
return () => {};
}
viewportRef.current = viewportElement;
@@ -559,7 +598,7 @@ export default function TerminalEmulator({
useEffect(() => {
if (!isDraggingScrollbar) {
return;
return () => {};
}
const handlePointerMove = (event: PointerEvent) => {
+32 -2
View File
@@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
applyTerminalRendererReadyChange,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
interface TerminalPaneProps {
serverId: string;
@@ -164,6 +170,7 @@ export function TerminalPane({
const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
// Keep the latest measured size for whichever client currently owns the pane,
// but only dedupe resizes that this specific client has already pushed.
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
@@ -175,6 +182,7 @@ export function TerminalPane({
);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
@@ -195,6 +203,20 @@ export function TerminalPane({
const requestTerminalReflow = useCallback(() => {
setResizeRequestToken((current) => current + 1);
}, []);
const handleRendererReadyChange = useCallback(
(change: TerminalRendererReadyChange) => {
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
return;
}
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
if (snapshot) {
emulatorRef.current?.renderSnapshot(snapshot);
}
},
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
);
useEffect(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
@@ -617,6 +639,13 @@ export function TerminalPane({
if (!swipeGesturesEnabled) return;
onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]);
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
isWorkspaceFocused,
hasStreamError: Boolean(streamError),
isAttaching,
rendererReadyStreamKey,
terminalStreamKey,
});
if (!client || !isConnected) {
return (
@@ -634,11 +663,12 @@ export function TerminalPane({
<TerminalEmulator
ref={emulatorRef}
dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={`${scopeKey}:${terminalId}`}
streamKey={terminalStreamKey}
testId="terminal-surface"
xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
onInput={handleTerminalData}
@@ -654,7 +684,7 @@ export function TerminalPane({
<View style={styles.terminalGestureContainer} />
)}
{isAttaching && isWorkspaceFocused ? (
{showLoadingOverlay ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
+12 -7
View File
@@ -18,7 +18,7 @@ export type ToastVariant = "default" | "success" | "error";
export interface ToastShowOptions {
icon?: ReactNode;
variant?: ToastVariant;
durationMs?: number;
durationMs?: number | null;
nativeAndroid?: boolean;
testID?: string;
}
@@ -29,7 +29,7 @@ export interface ToastState {
nativeMessage: string | null;
icon?: ReactNode;
variant: ToastVariant;
durationMs: number;
durationMs: number | null;
testID?: string;
}
@@ -59,11 +59,12 @@ export function useToastHost(): {
}
const variant = options?.variant ?? "default";
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration = durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return;
}
@@ -148,8 +149,13 @@ export function ToastViewport({
}, [clearTimer, onDismiss, opacity, translateY]);
const scheduleDismiss = useCallback(
(durationMs: number) => {
(durationMs: number | null) => {
clearTimer();
if (durationMs === null) {
remainingDurationRef.current = 0;
dismissDeadlineRef.current = null;
return;
}
const nextDurationMs = Math.max(0, durationMs);
remainingDurationRef.current = nextDurationMs;
dismissDeadlineRef.current = Date.now() + nextDurationMs;
@@ -169,7 +175,7 @@ export function ToastViewport({
}, [clearTimer]);
const resumeDismiss = useCallback(() => {
if (!toast) {
if (!toast || toast.durationMs === null) {
return;
}
scheduleDismiss(remainingDurationRef.current || toast.durationMs);
@@ -204,7 +210,6 @@ export function ToastViewport({
}),
]).start();
remainingDurationRef.current = toast.durationMs;
scheduleDismiss(toast.durationMs);
return () => {
+2 -3
View File
@@ -228,8 +228,7 @@ export function ComboboxItem({
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.comboboxItem,
Boolean(hovered) &&
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
@@ -488,7 +487,7 @@ function handleDesktopEnterKey(input: DesktopKeyHandlerInput) {
if (input.orderedVisibleOptions.length === 0) return;
const { activeIndex, orderedVisibleOptions } = input;
const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
input.handleSelect(orderedVisibleOptions[index]!.id);
input.handleSelect(orderedVisibleOptions[index].id);
}
interface FloatingSizeSetters {
+30 -41
View File
@@ -11,7 +11,6 @@ import {
type ReactElement,
type ReactNode,
type Ref,
type MutableRefObject,
} from "react";
import {
ActivityIndicator,
@@ -89,7 +88,7 @@ function useControllableOpenState({
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
@@ -175,25 +174,23 @@ function computePosition({
return { x, y, actualPlacement };
}
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
return typeof fn === "function";
}
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
const wrapper = event as
| {
nativeEvent?: { pageX?: number; pageY?: number; clientX?: number; clientY?: number };
pageX?: number;
pageY?: number;
clientX?: number;
clientY?: number;
}
| null
| undefined;
const nativeEvent = wrapper?.nativeEvent ?? wrapper;
const pageX = nativeEvent?.pageX;
const pageY = nativeEvent?.pageY;
if (typeof event !== "object" || event === null) {
return null;
}
const nativeEvent = Reflect.get(event, "nativeEvent");
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
const pageX = Reflect.get(native, "pageX");
const pageY = Reflect.get(native, "pageY");
if (typeof pageX === "number" && typeof pageY === "number") {
return { pageX, pageY };
}
const clientX = nativeEvent?.clientX;
const clientY = nativeEvent?.clientY;
const clientX = Reflect.get(native, "clientX");
const clientY = Reflect.get(native, "clientY");
if (typeof clientX === "number" && typeof clientY === "number") {
return { pageX: clientX, pageY: clientY };
}
@@ -206,7 +203,7 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
return;
}
if (ref && typeof ref === "object") {
(ref as MutableRefObject<T>).current = value;
Object.assign(ref, { current: value });
}
}
@@ -326,10 +323,13 @@ export function ContextMenuTrigger({
if (isNative) {
return;
}
const e = event as { preventDefault?: () => void; stopPropagation?: () => void } | undefined;
e?.preventDefault?.();
e?.stopPropagation?.();
openAtEvent(event as GestureResponderEvent);
if (typeof event === "object" && event !== null) {
const preventDefault = Reflect.get(event, "preventDefault");
const stopPropagation = Reflect.get(event, "stopPropagation");
if (isCallable(preventDefault)) preventDefault.call(event);
if (isCallable(stopPropagation)) stopPropagation.call(event);
}
openAtEvent(event);
},
[openAtEvent],
);
@@ -337,7 +337,7 @@ export function ContextMenuTrigger({
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
return style({ pressed, hovered, open: ctx.open });
}
return style;
},
@@ -434,40 +434,29 @@ export function ContextMenuContent({
// Measure trigger when opening (fallback) and capture point anchors.
useEffect(() => {
if (useMobileSheet) {
if (useMobileSheet || !open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return;
}
if (!open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return;
return () => {};
}
if (anchorRect) {
setTriggerRect(anchorRect);
return;
return () => {};
}
if (!triggerRef.current) {
setTriggerRect(null);
return;
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect({
...rect,
y: rect.y + statusBarHeight,
});
return;
void measureElement(triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
return () => {
@@ -62,6 +62,11 @@ interface DropdownMenuContextValue {
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
export function useDropdownMenuClose(): () => void {
const { setOpen } = useDropdownMenuContext("useDropdownMenuClose");
return useCallback(() => setOpen(false), [setOpen]);
}
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
const ctx = useContext(DropdownMenuContext);
if (!ctx) {
@@ -81,7 +86,7 @@ function useControllableOpenState({
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
@@ -743,12 +748,12 @@ export function DropdownMenuItem({
return [
styles.item,
selectedStyle,
selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent"
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
];
},
+47 -46
View File
@@ -11,7 +11,6 @@ import {
useState,
type PropsWithChildren,
type ReactElement,
type Ref,
} from "react";
import {
Dimensions,
@@ -82,24 +81,20 @@ function shouldOpenOnFocus(): boolean {
return !isWeb || lastInputWasKeyboard;
}
function composeEventHandlers<E>(
original?: (event: E) => void,
injected?: (event: E) => void,
): (event: E) => void {
return (event: E) => {
original?.(event);
injected?.(event);
};
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
return typeof fn === "function";
}
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref && typeof ref === "object") {
(ref as { current: T }).current = value;
}
function composeEventHandlers(
original: unknown,
injected: (event: unknown) => void,
): (event: unknown) => void {
return (event: unknown) => {
if (isCallable(original)) {
original(event);
}
injected(event);
};
}
function useControllableOpenState({
@@ -113,7 +108,7 @@ function useControllableOpenState({
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
@@ -320,7 +315,7 @@ export function TooltipTrigger({
const handleHoverIn = useCallback(
(e?: unknown) => {
onHoverIn?.(e as never);
if (isCallable(onHoverIn)) onHoverIn(e);
scheduleOpen();
},
[onHoverIn, scheduleOpen],
@@ -328,7 +323,7 @@ export function TooltipTrigger({
const handleHoverOut = useCallback(
(e?: unknown) => {
onHoverOut?.(e as never);
if (isCallable(onHoverOut)) onHoverOut(e);
close();
},
[onHoverOut, close],
@@ -336,7 +331,7 @@ export function TooltipTrigger({
const handleFocus = useCallback(
(e: unknown) => {
onFocus?.(e as never);
if (isCallable(onFocus)) onFocus(e);
if (!ctx.enabled || disabled) return;
if (!shouldOpenOnFocus()) return;
clearOpenTimer();
@@ -347,7 +342,7 @@ export function TooltipTrigger({
const handleBlur = useCallback(
(e: unknown) => {
onBlur?.(e as never);
if (isCallable(onBlur)) onBlur(e);
close();
},
[close, onBlur],
@@ -355,7 +350,7 @@ export function TooltipTrigger({
const handlePress = useCallback(
(e: unknown) => {
onPress?.(e as never);
if (isCallable(onPress)) onPress(e);
if (!ctx.enabled || disabled) {
return;
}
@@ -394,26 +389,33 @@ export function TooltipTrigger({
throw new Error("TooltipTrigger with asChild expects a single React element child");
}
const childProps = child.props as Record<string, unknown>;
const mergedProps = {
...childProps,
const rawProps: unknown = child.props;
if (typeof rawProps !== "object" || rawProps === null) {
throw new Error("TooltipTrigger asChild child must have props object");
}
const mergedProps: Record<string, unknown> = {
...Object.assign({}, rawProps),
...triggerProps,
disabled: childProps.disabled || disabled,
onHoverIn: composeEventHandlers(childProps.onHoverIn as never, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut as never, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus as never, handleFocus),
onBlur: composeEventHandlers(childProps.onBlur as never, handleBlur),
onPress: composeEventHandlers(childProps.onPress as never, handlePress),
onPointerEnter: composeEventHandlers(childProps.onPointerEnter as never, handleHoverIn),
onPointerLeave: composeEventHandlers(childProps.onPointerLeave as never, handleHoverOut),
onMouseEnter: composeEventHandlers(childProps.onMouseEnter as never, handleHoverIn),
onMouseLeave: composeEventHandlers(childProps.onMouseLeave as never, handleHoverOut),
} as Record<string, unknown>;
disabled: Reflect.get(rawProps, "disabled") || disabled,
onHoverIn: composeEventHandlers(Reflect.get(rawProps, "onHoverIn"), handleHoverIn),
onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut),
onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus),
onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur),
onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress),
onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn),
onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut),
onMouseEnter: composeEventHandlers(Reflect.get(rawProps, "onMouseEnter"), handleHoverIn),
onMouseLeave: composeEventHandlers(Reflect.get(rawProps, "onMouseLeave"), handleHoverOut),
};
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
mergedProps[triggerRefProp] = (node: View | null) => {
assignRef(existingRefProp, node);
assignRef(ctx.triggerRef, node);
const existingRefProp = Reflect.get(rawProps, triggerRefProp);
mergedProps[triggerRefProp] = (node: View) => {
if (isCallable(existingRefProp)) {
existingRefProp(node);
} else if (existingRefProp && typeof existingRefProp === "object") {
Object.assign(existingRefProp, { current: node });
}
Object.assign(ctx.triggerRef, { current: node });
};
return cloneElement(child, mergedProps);
@@ -453,16 +455,15 @@ export function TooltipContent({
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return;
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(ctx.triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return;
void measureElement(ctx.triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
return () => {
@@ -140,9 +140,9 @@ export function useWebScrollViewScrollbar(
const scrollable = scrollableRef.current;
if (!scrollable) return;
if ("scrollToOffset" in scrollable) {
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
scrollable.scrollToOffset({ offset, animated: false });
} else {
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
scrollable.scrollTo({ y: offset, animated: false });
}
},
[scrollableRef],
+8 -16
View File
@@ -248,8 +248,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId;
const scrollContentContainerStyle = useMemo(
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
[theme.spacing, insets.bottom],
@@ -266,20 +264,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} />
<View style={styles.copyBlock}>
{isConnectingToSavedHosts ? (
<Text style={styles.subtitle}>Connecting…</Text>
) : (
<>
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
<Text style={styles.setupLinkText}>paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
) : null}
</>
)}
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
<Text style={styles.setupLinkText}>paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
) : null}
</View>
<View style={styles.actions}>
+14 -15
View File
@@ -14,11 +14,7 @@ import {
type TimelineReducerSideEffect,
} from "@/timeline/session-stream-reducers";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type {
AgentAttachment,
AgentStreamEventPayload,
SessionOutboundMessage,
} from "@server/shared/messages";
import type { AgentAttachment, SessionOutboundMessage } from "@server/shared/messages";
import { parseServerInfoStatusPayload } from "@server/shared/messages";
import {
buildAgentAttentionNotificationPayload,
@@ -42,7 +38,6 @@ import {
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
@@ -60,7 +55,10 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -543,6 +541,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
workspaces.set(workspace.id, workspace);
}
@@ -1196,7 +1197,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event as AgentStreamEventPayload;
const streamEvent = event;
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
@@ -1239,18 +1240,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") {
clearWorkspaceArchivePending({
serverId,
workspaceId: String(message.payload.id),
});
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
removeWorkspace(serverId, String(message.payload.id));
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
if (
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId,
workspace,
isArchivePending: isLocalWorktreeArchivePending,
})
) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
return;
}
mergeWorkspaces(serverId, [workspace]);
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
markWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
id: "/repo/worktree",
@@ -21,40 +26,83 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return { ...baseWorkspace, ...input };
}
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
it("suppresses archiving upserts for a locally pending archive", () => {
const isArchivePending = vi.fn(() => true);
describe("workspace archive pending suppression", () => {
it("tracks a locally pending workspace archive by id and directory", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
isWorkspaceArchivePending({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
isArchivePending,
workspaceId: "/repo/worktree",
}),
).toBe(true);
expect(isArchivePending).toHaveBeenCalledWith({
serverId: "server-1",
cwd: "/repo/worktree",
});
});
it("allows archiving upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
isWorkspaceArchivePending({
serverId: "server-1",
workspace: workspace(),
isArchivePending: () => false,
workspaceDirectory: "/repo/worktree",
}),
).toBe(false);
});
it("allows normal upserts while a local archive is pending", () => {
).toBe(true);
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
isArchivePending: () => true,
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
expect(
isWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
}),
).toBe(false);
});
it("suppresses upserts for a locally pending archive", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
it("allows upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace(),
}),
).toBe(false);
});
it("suppresses stale normal upserts while a local archive is pending", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
});
@@ -1,15 +1,96 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
}
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
export function clearWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return;
}
archives.delete(pendingArchiveKey({ serverId, workspaceId }));
if (archives.size === 0) {
pendingWorkspaceArchivesByServer.delete(serverId);
}
}
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
return false;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
serverId: string;
workspace: WorkspaceDescriptor;
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
}): boolean {
return (
input.workspace.archivingAt !== null &&
input.isArchivePending({
serverId: input.serverId,
cwd: input.workspace.workspaceDirectory,
})
);
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}
@@ -7,10 +7,11 @@ import {
useRef,
type ReactNode,
} from "react";
import { useWindowDimensions } from "react-native";
import { Keyboard, useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
@@ -65,11 +66,16 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
const didOpen = !previousIsOpen && isOpen;
if (!didStateChange) {
return;
}
if (didOpen && isCompactLayout && isNative) {
Keyboard.dismiss();
}
// Gesture onEnd already started the animation on the UI thread — skip to avoid
// a second competing withTiming that can desync translateX and backdropOpacity
// after a provider remount (e.g. theme change).
@@ -99,7 +105,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing, isCompactLayout]);
const animateToOpen = useCallback(() => {
"worklet";
+11 -11
View File
@@ -57,9 +57,9 @@ export function useVoice() {
export function useVoiceOptional(): VoiceContextValue | null {
const runtime = useContext(VoiceRuntimeContext);
const snapshot = useSyncExternalStore(
runtime ? runtime.subscribe : noopSubscribe,
runtime ? runtime.getSnapshot : getEmptySnapshot,
runtime ? runtime.getSnapshot : getEmptySnapshot,
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
);
if (!runtime) {
@@ -68,10 +68,10 @@ export function useVoiceOptional(): VoiceContextValue | null {
return {
...snapshot,
startVoice: runtime.startVoice,
stopVoice: runtime.stopVoice,
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
toggleMute: runtime.toggleMute,
startVoice: runtime.startVoice.bind(runtime),
stopVoice: runtime.stopVoice.bind(runtime),
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
toggleMute: runtime.toggleMute.bind(runtime),
};
}
@@ -86,9 +86,9 @@ export function useVoiceTelemetry() {
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
const runtime = useContext(VoiceRuntimeContext);
const snapshot = useSyncExternalStore(
runtime ? runtime.subscribeTelemetry : noopSubscribe,
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
runtime ? runtime.subscribeTelemetry.bind(runtime) : noopSubscribe,
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
);
return runtime ? snapshot : null;
@@ -140,7 +140,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
runtimeRef.current = runtime;
}
const engine = engineRef.current!;
const engine = engineRef.current;
const runtime = runtimeRef.current!;
useEffect(() => {
@@ -139,7 +139,7 @@ describe("desktop attachment store", () => {
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled();
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment);
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
path: "/managed/att_3.jpg",
@@ -1,300 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocalDaemonSection } from "./desktop-updates-section";
const { alertMock, confirmDialogMock, settingsState, daemonStatusState } = vi.hoisted(() => ({
alertMock: vi.fn(),
confirmDialogMock: vi.fn(),
settingsState: {
settings: {
releaseChannel: "stable" as const,
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: true,
},
},
updateSettings: vi.fn<
(updates: {
daemon?: {
manageBuiltInDaemon?: boolean;
keepRunningAfterQuit?: boolean;
};
}) => Promise<void>
>(),
},
daemonStatusState: {
data: {
status: {
serverId: "desktop",
status: "running" as const,
listen: null,
hostname: null,
pid: 123,
home: "/tmp/paseo",
version: "1.2.3",
desktopManaged: true,
error: null,
},
logs: {
logPath: "/tmp/paseo/daemon.log",
contents: "daemon log",
},
},
isLoading: false,
error: null as string | null,
setStatus: vi.fn(),
refetch: vi.fn(),
},
}));
vi.mock("react-native", () => ({
ActivityIndicator: () => React.createElement("div", { "data-testid": "loading-spinner" }),
Alert: { alert: alertMock },
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
View: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? (factory as (theme: Record<string, unknown>) => unknown)({
spacing: { 1: 4, 2: 8, 3: 12, 6: 24 },
borderRadius: { lg: 12 },
fontSize: { xs: 12, sm: 14 },
colors: {
foreground: "#111",
foregroundMuted: "#666",
palette: { amber: { 500: "#f59e0b" } },
},
})
: factory,
},
useUnistyles: () => ({
theme: {
iconSize: { sm: 14 },
colors: {
foreground: "#111",
foregroundMuted: "#666",
},
},
}),
}));
vi.mock("expo-clipboard", () => ({
setStringAsync: vi.fn(() => Promise.resolve()),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {
Activity: icon("Activity"),
ArrowUpRight: icon("ArrowUpRight"),
Copy: icon("Copy"),
FileText: icon("FileText"),
};
});
vi.mock("@/styles/settings", () => ({
settingsStyles: new Proxy(
{},
{
get: (_target, prop) => String(prop),
},
),
}));
vi.mock("@/screens/settings/settings-section", () => ({
SettingsSection: ({
children,
title,
trailing,
}: {
children?: React.ReactNode;
title: string;
trailing?: React.ReactNode;
}) =>
React.createElement(
"section",
null,
React.createElement("h2", null, title),
trailing,
children,
),
}));
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
children,
visible,
title,
}: {
children?: React.ReactNode;
visible?: boolean;
title?: string;
}) => (visible ? React.createElement("div", { "data-title": title }, children) : null),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
onPress,
disabled,
accessibilityLabel,
}: {
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
accessibilityLabel?: string;
}) =>
React.createElement(
"button",
{ type: "button", onClick: onPress, disabled, "aria-label": accessibilityLabel },
children,
),
}));
vi.mock("@/components/ui/switch", () => ({
Switch: ({
value,
onValueChange,
disabled,
accessibilityLabel,
}: {
value: boolean;
onValueChange?: (next: boolean) => void;
disabled?: boolean;
accessibilityLabel?: string;
}) =>
React.createElement("button", {
type: "button",
role: "switch",
"aria-label": accessibilityLabel,
"aria-checked": value,
disabled,
onClick: () => onValueChange?.(!value),
}),
}));
vi.mock("@/desktop/settings/desktop-settings", () => ({
useDesktopSettings: () => ({
...settingsState,
isLoading: false,
error: null,
}),
}));
vi.mock("@/desktop/hooks/use-daemon-status", () => ({
useDaemonStatus: () => daemonStatusState,
}));
vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: confirmDialogMock,
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(),
}));
vi.mock("@/desktop/updates/desktop-updates", () => ({
isVersionMismatch: vi.fn(() => false),
}));
const daemonCommandMocks = vi.hoisted(() => ({
getCliDaemonStatusMock: vi.fn(),
stopDesktopDaemonMock: vi.fn(),
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
shouldUseDesktopDaemon: vi.fn(() => true),
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
}));
vi.mock("@/utils/app-version", () => ({
resolveAppVersion: vi.fn(() => "1.2.3"),
}));
describe("LocalDaemonSection", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
alertMock.mockReset();
confirmDialogMock.mockReset();
settingsState.settings.daemon.manageBuiltInDaemon = true;
settingsState.settings.daemon.keepRunningAfterQuit = true;
settingsState.updateSettings.mockReset();
settingsState.updateSettings.mockResolvedValue();
daemonStatusState.data.status.status = "running";
daemonStatusState.setStatus.mockReset();
daemonStatusState.refetch.mockReset();
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status,
status: "stopped",
});
daemonCommandMocks.getCliDaemonStatusMock.mockReset();
});
it("renders the daemon toggles as switches reflecting current settings", () => {
const screen = render(<LocalDaemonSection />);
expect(screen.getByText("Keep daemon running after quit")).toBeTruthy();
expect(screen.getByText("Manage built-in daemon")).toBeTruthy();
const keepRunningSwitch = screen.getByRole("switch", {
name: "Keep daemon running after quit",
});
expect(keepRunningSwitch.getAttribute("aria-checked")).toBe("true");
const manageSwitch = screen.getByRole("switch", { name: "Manage built-in daemon" });
expect(manageSwitch.getAttribute("aria-checked")).toBe("true");
});
it("does not render a duplicate restart-daemon control", () => {
const screen = render(<LocalDaemonSection />);
expect(screen.queryByText("Restart daemon")).toBeNull();
expect(screen.queryByText("Start daemon")).toBeNull();
});
it("updates keep-running-after-quit without changing daemon lifecycle", async () => {
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Keep daemon running after quit" }));
await waitFor(() => {
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
keepRunningAfterQuit: false,
},
});
});
expect(confirmDialogMock).not.toHaveBeenCalled();
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
});
it("pauses built-in daemon management and persists the setting through desktop settings", async () => {
confirmDialogMock.mockResolvedValue(true);
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
});
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
manageBuiltInDaemon: false,
},
});
});
});
@@ -14,8 +14,10 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
@@ -38,62 +40,42 @@ function useDaemonManagementToggle(args: {
return;
}
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
void updateSettings({ manageBuiltInDaemon: true })
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
return;
}
void confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
setIsUpdatingDaemonManagement(true);
void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
})
.then((confirmed) => {
if (!confirmed) {
.then((result) => {
if (result.kind === "cancelled") {
return;
}
setIsUpdatingDaemonManagement(true);
const stopPromise =
daemonStatus?.status === "running"
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null);
void stopPromise
.then((newStatus) => {
if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
})
.then(() => {
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
if (result.newStatus) {
setStatus(result.newStatus);
}
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert(
"Error",
settings.manageBuiltInDaemon
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
: "Unable to update built-in daemon management.",
);
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
}, [
daemonStatus,
@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
import type { DesktopDaemonStatus } from "./desktop-daemon";
const runningManagedStatus: DesktopDaemonStatus = {
serverId: "desktop",
status: "running",
listen: null,
hostname: null,
pid: 123,
home: "/tmp/paseo",
version: "1.0.0",
desktopManaged: true,
error: null,
};
const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" };
function makeDeps(overrides?: {
confirm?: () => Promise<boolean>;
persistSettings?: (next: { manageBuiltInDaemon: boolean }) => Promise<void>;
startDaemon?: () => Promise<DesktopDaemonStatus>;
stopDaemon?: () => Promise<DesktopDaemonStatus>;
}) {
const calls: string[] = [];
return {
deps: {
confirm: overrides?.confirm ?? (() => Promise.resolve(true)),
persistSettings:
overrides?.persistSettings ??
(async () => {
calls.push("persist");
}),
startDaemon:
overrides?.startDaemon ??
(async () => {
calls.push("start");
return { ...runningManagedStatus, status: "running" as const };
}),
stopDaemon:
overrides?.stopDaemon ??
(async () => {
calls.push("stop");
return stoppedStatus;
}),
},
calls,
};
}
describe("executeDaemonManagementToggle", () => {
describe("enable path (currentlyManaging: false)", () => {
it("persists the new setting then starts the daemon", async () => {
const { deps, calls } = makeDeps();
const result = await executeDaemonManagementToggle(false, null, deps);
expect(result).toEqual({ kind: "enabled", newStatus: runningManagedStatus });
expect(calls).toEqual(["persist", "start"]);
});
it("persists manageBuiltInDaemon: true", async () => {
let persisted: { manageBuiltInDaemon: boolean } | null = null;
const { deps } = makeDeps({
persistSettings: async (next) => {
persisted = next;
},
});
await executeDaemonManagementToggle(false, null, deps);
expect(persisted).toEqual({ manageBuiltInDaemon: true });
});
});
describe("disable path (currentlyManaging: true)", () => {
it("returns cancelled without changing settings when confirmation is rejected", async () => {
const persistedSettings: unknown[] = [];
const { deps } = makeDeps({
confirm: () => Promise.resolve(false),
persistSettings: async (next) => {
persistedSettings.push(next);
},
});
const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps);
expect(result).toEqual({ kind: "cancelled" });
expect(persistedSettings).toHaveLength(0);
});
it("persists settings BEFORE stopping the daemon", async () => {
const callOrder: string[] = [];
const { deps } = makeDeps({
persistSettings: async () => {
callOrder.push("persist");
},
stopDaemon: async () => {
callOrder.push("stop");
return stoppedStatus;
},
});
await executeDaemonManagementToggle(true, runningManagedStatus, deps);
expect(callOrder).toEqual(["persist", "stop"]);
});
it("persists manageBuiltInDaemon: false when disabling", async () => {
let persisted: { manageBuiltInDaemon: boolean } | null = null;
const { deps } = makeDeps({
persistSettings: async (next) => {
persisted = next;
},
});
await executeDaemonManagementToggle(true, runningManagedStatus, deps);
expect(persisted).toEqual({ manageBuiltInDaemon: false });
});
it("stops the daemon when it is running and desktop-managed", async () => {
const { deps, calls } = makeDeps();
const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps);
expect(calls).toContain("stop");
expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus });
});
it("skips stop when daemon is running but not desktop-managed", async () => {
const { deps, calls } = makeDeps();
const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false };
const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps);
expect(calls).not.toContain("stop");
expect(result).toEqual({ kind: "disabled", newStatus: null });
});
it("skips stop when daemon is stopped (regardless of desktopManaged)", async () => {
const { deps, calls } = makeDeps();
const result = await executeDaemonManagementToggle(true, stoppedStatus, deps);
expect(calls).not.toContain("stop");
expect(result).toEqual({ kind: "disabled", newStatus: null });
});
it("skips stop when daemonStatus is null", async () => {
const { deps, calls } = makeDeps();
const result = await executeDaemonManagementToggle(true, null, deps);
expect(calls).not.toContain("stop");
expect(result).toEqual({ kind: "disabled", newStatus: null });
});
});
});
@@ -0,0 +1,41 @@
import type { DesktopDaemonStatus } from "./desktop-daemon";
export interface DaemonManagementToggleDeps {
confirm: () => Promise<boolean>;
persistSettings: (next: { manageBuiltInDaemon: boolean }) => Promise<void>;
startDaemon: () => Promise<DesktopDaemonStatus>;
stopDaemon: () => Promise<DesktopDaemonStatus>;
}
export type DaemonManagementToggleResult =
| { kind: "cancelled" }
| { kind: "enabled"; newStatus: DesktopDaemonStatus }
| { kind: "disabled"; newStatus: DesktopDaemonStatus | null };
export async function executeDaemonManagementToggle(
currentlyManaging: boolean,
daemonStatus: Pick<DesktopDaemonStatus, "status" | "desktopManaged"> | null,
deps: DaemonManagementToggleDeps,
): Promise<DaemonManagementToggleResult> {
if (!currentlyManaging) {
await deps.persistSettings({ manageBuiltInDaemon: true });
const newStatus = await deps.startDaemon();
return { kind: "enabled", newStatus };
}
const confirmed = await deps.confirm();
if (!confirmed) {
return { kind: "cancelled" };
}
// Settings must persist before the daemon is stopped so the persisted
// state reflects what was actually applied if the stop fails.
await deps.persistSettings({ manageBuiltInDaemon: false });
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
const newStatus = await deps.stopDaemon();
return { kind: "disabled", newStatus };
}
return { kind: "disabled", newStatus: null };
}
@@ -13,7 +13,7 @@ function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]!);
binary += String.fromCharCode(bytes[index]);
}
return globalThis.btoa(binary);
}
+15
View File
@@ -21,8 +21,22 @@ export interface DesktopDialogOpenOptions {
}>;
}
export interface DesktopDialogAskWithCheckboxOptions extends DesktopDialogAskOptions {
checkboxLabel: string;
checkboxChecked?: boolean;
}
export interface DesktopDialogAskWithCheckboxResult {
confirmed: boolean;
dontAskAgain: boolean;
}
export interface DesktopDialogBridge {
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
askWithCheckbox?: (
message: string,
options: DesktopDialogAskWithCheckboxOptions,
) => Promise<DesktopDialogAskWithCheckboxResult>;
open?: (options?: DesktopDialogOpenOptions) => Promise<string | string[] | null>;
}
@@ -76,6 +90,7 @@ export interface DesktopBrowserShortcutEvent {
export interface DesktopBrowserBridge {
setActivePane?: (browserId: string | null) => Promise<void>;
clearPartition?: (browserId: string) => Promise<void>;
}
export interface DesktopInvokeBridge {
@@ -110,7 +110,7 @@ export class DictationStreamSender {
let sent = 0;
while (this.sendSeq < this.segments.length) {
const seq = this.sendSeq;
const audio = this.segments[seq]!;
const audio = this.segments[seq];
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
this.sendSeq = seq + 1;
sent += 1;
@@ -35,7 +35,7 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
function makeProviderMap(
...definitions: AgentProviderDefinition[]
): Map<AgentProvider, AgentProviderDefinition> {
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
return new Map(definitions.map((d) => [d.id, d]));
}
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
@@ -196,8 +196,8 @@ function resolveProvider(input: {
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
return initialValues.provider;
}
if (preferences?.provider && allowedProviderMap.has(preferences.provider as AgentProvider)) {
return preferences.provider as AgentProvider;
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
return preferences.provider;
}
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
return null;
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
const matchingProviders = new Set(
args.snapshotEntries
.filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
.map((entry) => entry.provider),
);
@@ -24,7 +24,7 @@ afterEach(() => {
});
describe("useAgentInitialization", () => {
it("always requests a canonical tail bootstrap even with an authoritative cursor", () => {
it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore
@@ -43,6 +43,27 @@ describe("useAgentInitialization", () => {
void result.current.ensureAgentIsInitialized(agentId);
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("requests a bounded canonical tail when no authoritative cursor is available", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
const { result } = renderHook(() =>
useAgentInitialization({ serverId, client: client as never }),
);
act(() => {
void result.current.ensureAgentIsInitialized(agentId);
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
@@ -51,6 +72,34 @@ describe("useAgentInitialization", () => {
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
it("times out initialization after 30 seconds", async () => {
vi.useFakeTimers();
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
const { result } = renderHook(() =>
useAgentInitialization({ serverId, client: client as never }),
);
const promise = result.current.ensureAgentIsInitialized(agentId);
act(() => {
vi.advanceTimersByTime(29_999);
});
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined();
act(() => {
vi.advanceTimersByTime(1);
});
await expect(promise).rejects.toThrow("History sync timed out after 30s");
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined();
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get(agentId)).toBe(
false,
);
vi.useRealTimers();
});
it("refresh fetches a bounded canonical tail after refreshing the agent", async () => {
const client = makeClient();
const { result } = renderHook(() =>
@@ -10,7 +10,7 @@ import {
} from "@/utils/agent-initialization";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
const INIT_TIMEOUT_MS = 5 * 60_000;
const INIT_TIMEOUT_MS = 30_000;
export function useAgentInitialization({
serverId,
@@ -42,13 +42,25 @@ export function useAgentInitialization({
return existing.promise;
}
const timelineRequest = {
direction: "tail" as const,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical" as const,
};
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const hasAuthoritativeHistory =
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest =
hasAuthoritativeHistory && cursor
? {
direction: "after" as const,
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical" as const,
}
: {
direction: "tail" as const,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical" as const,
};
const deferred = createInitDeferred(key, "tail");
const deferred = createInitDeferred(key, timelineRequest.direction);
const timeoutId = setTimeout(() => {
setAgentInitializing(agentId, false);
rejectInitDeferred(
@@ -0,0 +1,174 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
export interface DraftStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
export interface StoredDraft {
text: string;
attachments: UserComposerAttachment[];
cwd: string;
}
export interface AgentInputDraftCore {
load(initialCwd?: string): Promise<StoredDraft | null>;
save(draft: StoredDraft): Promise<void>;
clear(): Promise<void>;
}
export function createAgentInputDraftCore(input: {
storage: DraftStorage;
storageKey: string;
}): AgentInputDraftCore {
return {
async load(initialCwd?: string) {
const json = await input.storage.getItem(input.storageKey);
if (!json) return null;
try {
const parsed = JSON.parse(json) as Record<string, unknown>;
return {
text: typeof parsed.text === "string" ? parsed.text : "",
attachments: Array.isArray(parsed.attachments)
? (parsed.attachments as UserComposerAttachment[])
: [],
cwd: typeof parsed.cwd === "string" ? parsed.cwd : (initialCwd ?? ""),
};
} catch {
return null;
}
},
async save(draft) {
await input.storage.setItem(input.storageKey, JSON.stringify(draft));
},
async clear() {
await input.storage.removeItem(input.storageKey);
},
};
}
export interface DraftKeyContext {
selectedServerId: string | null;
}
export type DraftKeyInput = string | ((context: DraftKeyContext) => string);
export function resolveDraftKey(input: {
draftKey: DraftKeyInput;
selectedServerId: string | null;
}): string {
if (typeof input.draftKey === "function") {
return input.draftKey({ selectedServerId: input.selectedServerId });
}
return input.draftKey;
}
export function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
return input.selectedModel.trim();
}
export function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftComposerCommandConfig(input: {
provider: AgentProvider | null;
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.provider || !cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
export function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
}): DraftAgentStatusBarProps {
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
models: formState.availableModels,
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
allProviderModels: formState.allProviderModels,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
};
}
export function hasDraftContent(input: {
text: string;
attachments: UserComposerAttachment[];
cwd: string;
}): boolean {
return (
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
);
}
export function areAttachmentsEqual(input: {
left: UserComposerAttachment[];
right: UserComposerAttachment[];
}): boolean {
if (input.left.length !== input.right.length) {
return false;
}
return input.left.every((attachment, index) => {
const other = input.right[index];
return JSON.stringify(attachment) === JSON.stringify(other);
});
}
@@ -1,194 +1,188 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildDraftComposerCommandConfig,
createAgentInputDraftCore,
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "./use-agent-input-draft-core";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined,
},
}));
vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: async () => undefined,
}));
vi.mock("./use-agent-form-state", () => ({
useAgentFormState: () => ({
selectedServerId: "host-1",
setSelectedServerId: () => undefined,
setSelectedServerIdFromUser: () => undefined,
selectedProvider: "codex",
setProviderFromUser: () => undefined,
selectedMode: "auto",
setModeFromUser: () => undefined,
selectedModel: "",
setModelFromUser: () => undefined,
selectedThinkingOptionId: "",
setThinkingOptionFromUser: () => undefined,
workingDir: "/repo",
setWorkingDir: () => undefined,
setWorkingDirFromUser: () => undefined,
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
providerDefinitionMap: new Map(),
agentDefinition: undefined,
modeOptions: [{ id: "auto", label: "Auto" }],
availableModels: [],
allProviderModels: new Map(),
isAllModelsLoading: false,
availableThinkingOptions: [],
isModelLoading: false,
modelError: null,
refreshProviderModels: () => undefined,
setProviderAndModelFromUser: () => undefined,
workingDirIsEmpty: false,
persistFormPreferences: async () => undefined,
}),
}));
let __private__: typeof import("./use-agent-input-draft").__private__;
beforeAll(async () => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
function makeStorage() {
const map = new Map<string, string>();
return {
getItem: async (key: string) => map.get(key) ?? null,
setItem: async (key: string, value: string) => {
map.set(key, value);
},
configurable: true,
removeItem: async (key: string) => {
map.delete(key);
},
};
}
describe("resolveDraftKey", () => {
it("returns a string draft key unchanged", () => {
expect(
resolveDraftKey({
draftKey: "draft:key",
selectedServerId: "host-1",
}),
).toBe("draft:key");
});
({ __private__ } = await import("./use-agent-input-draft"));
it("resolves a computed draft key from the selected server", () => {
expect(
resolveDraftKey({
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
selectedServerId: "host-1",
}),
).toBe("draft:host-1");
});
});
describe("useAgentInputDraft", () => {
describe("__private__.resolveDraftKey", () => {
it("returns an object draft key string unchanged", () => {
expect(
__private__.resolveDraftKey({
draftKey: "draft:key",
selectedServerId: "host-1",
}),
).toBe("draft:key");
});
it("resolves a computed draft key from the selected server", () => {
expect(
__private__.resolveDraftKey({
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
selectedServerId: "host-1",
}),
).toBe("draft:host-1");
});
describe("resolveEffectiveComposerModelId", () => {
it("returns the selected model trimmed", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: " gpt-5.4-mini ",
availableModels: [],
}),
).toBe("gpt-5.4-mini");
});
describe("__private__.resolveEffectiveComposerModelId", () => {
const models = [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
},
{
provider: "codex",
id: "gpt-5.4-mini",
label: "gpt-5.4-mini",
},
];
it("returns empty string when no model selected", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: "",
availableModels: [],
}),
).toBe("");
});
});
it("prefers the selected model when present", () => {
expect(
__private__.resolveEffectiveComposerModelId({
selectedModel: "gpt-5.4-mini",
availableModels: models,
}),
).toBe("gpt-5.4-mini");
});
describe("resolveEffectiveComposerThinkingOptionId", () => {
const models = [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
];
it("returns empty string when no model selected", () => {
expect(
__private__.resolveEffectiveComposerModelId({
selectedModel: "",
availableModels: models,
}),
).toBe("");
});
it("prefers the selected thinking option when present", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "medium",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("medium");
});
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
const models = [
{
it("falls back to the model default thinking option", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("high");
});
});
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
buildDraftComposerCommandConfig({
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
];
it("prefers the selected thinking option when present", () => {
expect(
__private__.resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "medium",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("medium");
});
it("falls back to the model default thinking option", () => {
expect(
__private__.resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("high");
});
cwd: " ",
modeOptions: [],
selectedMode: "",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
});
describe("__private__.buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
__private__.buildDraftComposerCommandConfig({
provider: "codex",
cwd: " ",
modeOptions: [],
selectedMode: "",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
});
it("builds the draft command config from derived composer state", () => {
expect(
__private__.buildDraftComposerCommandConfig({
provider: "codex",
cwd: "/repo",
modeOptions: [{ id: "auto", label: "Auto" }],
selectedMode: "auto",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftComposerCommandConfig({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
});
modeOptions: [{ id: "auto", label: "Auto" }],
selectedMode: "auto",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
});
});
});
describe("createAgentInputDraftCore", () => {
it("load returns null when nothing has been saved", async () => {
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
expect(await core.load()).toBeNull();
});
it("load returns the draft that was saved", async () => {
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
await core.save({ text: "hello world", attachments: [], cwd: "/repo" });
expect(await core.load()).toEqual({ text: "hello world", attachments: [], cwd: "/repo" });
});
it("clear removes the persisted draft", async () => {
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
await core.save({ text: "hello", attachments: [], cwd: "/repo" });
await core.clear();
expect(await core.load()).toBeNull();
});
it("load seeds cwd from initialCwd when cwd is absent from stored data", async () => {
const storage = makeStorage();
await storage.setItem("test", JSON.stringify({ text: "hello", attachments: [] }));
const core = createAgentInputDraftCore({ storage, storageKey: "test" });
const draft = await core.load("/initial");
expect(draft?.cwd).toBe("/initial");
});
it("round-trips attachments unchanged", async () => {
const attachment = {
kind: "github_issue" as const,
item: {
kind: "issue" as const,
number: 42,
title: "Unify attachments",
url: "https://github.com/example/repo/issues/42",
state: "open" as const,
body: "body",
labels: ["composer"],
},
};
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
await core.save({ text: "", attachments: [attachment], cwd: "/repo" });
const draft = await core.load();
expect(draft?.attachments).toEqual([attachment]);
});
it("isolated keys do not share state", async () => {
const storage = makeStorage();
const a = createAgentInputDraftCore({ storage, storageKey: "key-a" });
const b = createAgentInputDraftCore({ storage, storageKey: "key-b" });
await a.save({ text: "from a", attachments: [], cwd: "" });
expect(await b.load()).toBeNull();
expect(await a.load()).toMatchObject({ text: "from a" });
});
});
+10 -123
View File
@@ -8,9 +8,17 @@ import {
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftComposerCommandConfig,
buildDraftStatusControls,
hasDraftContent,
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type DraftKeyInput,
} from "@/hooks/use-agent-input-draft-core";
import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
type AttachmentUpdater =
| UserComposerAttachment[]
@@ -24,12 +32,6 @@ interface AgentInputDraftComposerOptions {
lockedWorkingDir?: string;
}
interface DraftKeyContext {
selectedServerId: string | null;
}
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
interface UseAgentInputDraftInput {
draftKey: DraftKeyInput;
initialCwd?: string;
@@ -57,121 +59,6 @@ interface AgentInputDraft {
composerState: DraftComposerState | null;
}
function hasDraftContent(input: {
text: string;
attachments: UserComposerAttachment[];
cwd: string;
}): boolean {
return (
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
);
}
function areAttachmentsEqual(input: {
left: UserComposerAttachment[];
right: UserComposerAttachment[];
}): boolean {
if (input.left.length !== input.right.length) {
return false;
}
return input.left.every((attachment, index) => {
const other = input.right[index];
return JSON.stringify(attachment) === JSON.stringify(other);
});
}
function resolveDraftKey(input: {
draftKey: DraftKeyInput;
selectedServerId: string | null;
}): string {
if (typeof input.draftKey === "function") {
return input.draftKey({ selectedServerId: input.selectedServerId });
}
return input.draftKey;
}
function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
return input.selectedModel.trim();
}
function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
function buildDraftComposerCommandConfig(input: {
provider: AgentProvider | null;
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.provider || !cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
}): DraftAgentStatusBarProps {
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
models: formState.availableModels,
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
allProviderModels: formState.allProviderModels,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
};
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState({
@@ -72,33 +72,15 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
}
}
/**
* Convert audio file URI to Blob for daemon transport
* Returns a Blob-like object that works in React Native
*/
async function uriToBlob(uri: string): Promise<Blob> {
// Use expo-file-system to read the file
const file = new File(uri);
const base64 = await file.base64();
const size = file.size;
// React Native doesn't support creating Blobs from binary data
// Create a Blob-like object that has the methods we need
const blobLike = {
type: "audio/m4a",
size: size,
arrayBuffer: async () => {
// Convert base64 to ArrayBuffer
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
},
} as Blob;
return blobLike;
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: "audio/m4a" });
}
/**
@@ -163,19 +145,17 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
// Monitor audio levels if metering is enabled
// Use configRef to access the latest callback without recreating the effect
useEffect(() => {
const currentConfig = configRef.current;
if (!currentConfig?.onAudioLevel || !recorderState.isRecording) return;
if (!configRef.current?.onAudioLevel || !recorderState.isRecording) {
return undefined;
}
const interval = setInterval(() => {
const metering = recorderState.metering;
if (metering !== undefined && metering !== null) {
// Normalize metering value (typically ranges from -160 to 0 dB)
// Convert to 0-1 range where 0 is silence and 1 is loud
// We'll use -40 dB as the threshold for "loud"
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
configRef.current?.onAudioLevel?.(normalized);
}
}, 100); // Check every 100ms
}, 100);
return () => clearInterval(interval);
}, [recorderState.metering, recorderState.isRecording]);
@@ -217,20 +197,18 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
await recorder.prepareToRecordAsync();
attemptGuardRef.current.assertCurrent(attemptId);
await recorder.record();
recorder.record();
attemptGuardRef.current.assertCurrent(attemptId);
} catch (error) {
setRecordingStartTime(null);
if (error instanceof AttemptCancelledError) {
return;
}
if ((error as { message?: string })?.message !== "Recording cancelled") {
const message = error instanceof Error ? error.message : String(error);
if (message !== "Recording cancelled") {
console.error("[AudioRecorder] Failed to start recording:", error);
}
throw new Error(
`Failed to start audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
{ cause: error },
);
throw new Error(`Failed to start audio recording: ${message}`, { cause: error });
}
}, []);
@@ -296,10 +274,8 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
} catch (error) {
setRecordingStartTime(null);
console.error("[AudioRecorder] Failed to stop recording:", error);
throw new Error(
`Failed to stop audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
{ cause: error },
);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to stop audio recording: ${message}`, { cause: error });
} finally {
startStopMutexRef.current = null;
}
@@ -312,7 +288,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
}, []);
const isRecording = useCallback(() => {
return Boolean(recorderState.isRecording);
return recorderState.isRecording;
}, [recorderState.isRecording]);
// Return stable object using useMemo
@@ -341,7 +341,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
return () => {
attemptGuard.cancel();
try {
void mediaRecorder.current?.stop();
mediaRecorder.current?.stop();
} catch {
// Ignore stop during unmount.
}
+1 -1
View File
@@ -332,7 +332,7 @@ export function useCommandCenter() {
if (currentItems.length === 0) return;
event.preventDefault();
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
handleSelectItemRef.current(currentItems[index]!);
handleSelectItemRef.current(currentItems[index]);
return;
}
@@ -68,7 +68,7 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
const int16ToFloat32 = (input: Int16Array): Float32Array => {
const out = new Float32Array(input.length);
for (let i = 0; i < input.length; i += 1) {
out[i] = input[i]! / 32768;
out[i] = input[i] / 32768;
}
return out;
};
@@ -209,8 +209,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
const decodeAudioData = useCallback(
async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
const maybePromise = context.decodeAudioData(buffer);
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
return maybePromise as Promise<AudioBuffer>;
if (maybePromise && typeof maybePromise.then === "function") {
return maybePromise;
}
return await new Promise<AudioBuffer>((resolve, reject) => {
context.decodeAudioData(buffer, resolve, reject);
@@ -276,7 +276,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
},
});
const stream = rawStream as MediaStream;
const stream = rawStream;
const context = new AudioContextCtor();
@@ -347,7 +347,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
const recorder = new RecorderCtor(stream, {
mimeType: "audio/webm;codecs=opus",
} as MediaRecorderOptions) as MediaRecorder;
} as MediaRecorderOptions);
const recorderRefs: RecorderRefs = {
recorder,
@@ -260,7 +260,7 @@ export function useKeyboardShortcuts({
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) {
const target = event.target instanceof Element ? (event.target as Element) : null;
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
@@ -0,0 +1,22 @@
/**
* @vitest-environment jsdom
*/
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useLatchedBoolean } from "./use-latched-boolean";
describe("useLatchedBoolean", () => {
it("stays true after the input first becomes true", () => {
const { result, rerender } = renderHook(({ value }) => useLatchedBoolean(value), {
initialProps: { value: false },
});
expect(result.current).toBe(false);
rerender({ value: true });
expect(result.current).toBe(true);
rerender({ value: false });
expect(result.current).toBe(true);
});
});
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
export function useLatchedBoolean(value: boolean): boolean {
const [hasLatched, setHasLatched] = useState(value);
useEffect(() => {
if (value) {
setHasLatched(true);
}
}, [value]);
return hasLatched || value;
}
+2 -2
View File
@@ -86,7 +86,7 @@ export function usePrPaneData({
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
const status = checkoutPrStatus.status;
const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status);
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false;
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
const unsupportedKey =
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
@@ -100,7 +100,7 @@ export function usePrPaneData({
timelineUnsupported,
});
const timelineQuery = useQuery<PullRequestTimeline, Error>({
const timelineQuery = useQuery<PullRequestTimeline>({
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
queryFn: async () => {
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
@@ -252,7 +252,6 @@ describe("useProjects", () => {
});
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hiddenUnsupportedRemoteCount",
"hostCount",
"hosts",
"onlineHostCount",
-2
View File
@@ -15,7 +15,6 @@ export interface ProjectHostError {
export interface UseProjectsResult {
projects: ProjectSummary[];
hiddenUnsupportedRemoteCount: number;
hostErrors: ProjectHostError[];
isLoading: boolean;
isFetching: boolean;
@@ -127,7 +126,6 @@ export function useProjects(): UseProjectsResult {
return {
projects: projectsQuery.data?.projects ?? [],
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
hostErrors: projectsQuery.data?.hostErrors ?? [],
isLoading: projectsQuery.isLoading,
isFetching: projectsQuery.isFetching,
@@ -86,6 +86,7 @@ describe("use-settings", () => {
theme: "light",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
releaseChannel: "stable",
});
});
@@ -128,6 +129,7 @@ describe("use-settings", () => {
expect(result).toEqual({
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
});
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
@@ -166,6 +168,7 @@ describe("use-settings", () => {
expect(result).toEqual({
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
@@ -184,6 +187,7 @@ describe("use-settings", () => {
expect(result).toEqual({
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
manageBuiltInDaemon: true,
releaseChannel: "stable",
});
+24 -1
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/query/query-client";
import {
DEFAULT_DESKTOP_SETTINGS,
loadDesktopSettings,
@@ -16,12 +17,15 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
export interface AppSettings {
theme: ThemeName | "auto";
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
}
export interface Settings extends AppSettings {
@@ -32,6 +36,7 @@ export interface Settings extends AppSettings {
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -114,6 +119,9 @@ export function useSettings(): UseSettingsReturn {
if (updates.sendBehavior !== undefined) {
appUpdates.sendBehavior = updates.sendBehavior;
}
if (updates.serviceUrlBehavior !== undefined) {
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
@@ -162,6 +170,15 @@ export function useSettings(): UseSettingsReturn {
};
}
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
const current =
appQueryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage());
const next = { ...current, ...updates };
appQueryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
}
export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
try {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
@@ -218,11 +235,17 @@ export async function loadSettingsFromStorage(): Promise<Settings> {
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme as AppSettings["theme"];
result.theme = stored.theme;
}
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
result.sendBehavior = stored.sendBehavior;
}
if (
typeof stored.serviceUrlBehavior === "string" &&
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
) {
result.serviceUrlBehavior = stored.serviceUrlBehavior;
}
return result;
}
@@ -11,6 +11,7 @@ import {
} from "@/stores/session-store-hooks";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: {
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
+7 -4
View File
@@ -708,7 +708,10 @@ function ChatAgentContent({
useEffect(() => {
if (connectionStatus === "online") {
reconnectToastArmedRef.current = false;
if (reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = false;
panelToast.dismiss();
}
return;
}
if (connectionStatus === "idle") {
@@ -717,11 +720,11 @@ function ChatAgentContent({
if (!reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = true;
panelToast.api.show("Reconnecting...", {
durationMs: 2200,
durationMs: null,
testID: "agent-reconnecting-toast",
});
}
}, [connectionStatus, panelToast.api]);
}, [connectionStatus, panelToast]);
useEffect(() => {
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
@@ -747,7 +750,7 @@ function ChatAgentContent({
};
}, []);
const isInitializing = agentId ? isInitializingFromMap !== false : false;
const isInitializing = agentId ? isInitializingFromMap : false;
const isHistorySyncing = useMemo(() => {
if (!agentId || !isInitializing) {
return false;
+1 -1
View File
@@ -21,7 +21,7 @@ export function polyfillCrypto(): void {
if (typeof g.TextEncoder !== "function") {
class BufferTextEncoder {
encode(input = ""): Uint8Array {
return Uint8Array.from(Buffer.from(String(input), "utf8"));
return Uint8Array.from(Buffer.from(input, "utf8"));
}
}
g.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder;
+3 -3
View File
@@ -197,7 +197,7 @@ describe("useInlineReviewController", () => {
body: "first comment",
});
act(() => result.current.onEditComment(reviewTarget, savedComment!));
act(() => result.current.onEditComment(reviewTarget, savedComment));
expect(result.current.editor).toEqual({
target: reviewTarget,
commentId: savedComment?.id,
@@ -208,8 +208,8 @@ describe("useInlineReviewController", () => {
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
act(() => result.current.onEditComment(reviewTarget, updatedComment!));
act(() => result.current.onDeleteComment(updatedComment!.id));
act(() => result.current.onEditComment(reviewTarget, updatedComment));
act(() => result.current.onDeleteComment(updatedComment.id));
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
expect(result.current.editor).toBeNull();

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