Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 00870740ef Fix committed diff excluding dirty worktree 2026-04-21 15:00:08 +07:00
Mohamed Boudra ebf8a21c08 Fix TTLCache import and rename schedule provider resolver 2026-04-21 14:19:51 +07:00
Mohamed Boudra 515ba62be6 Persist sidebar collapse state and stabilize Metro port 2026-04-21 14:01:16 +07:00
Mohamed Boudra 32a20cbeea Merge branch 'research/mcp-list-agents-summary' 2026-04-21 13:46:25 +07:00
Mohamed Boudra 9877936d13 feat: require provider/model format and return compact list items 2026-04-21 13:44:55 +07:00
Mohamed Boudra 1a473ed7f3 Persist composer preferences and stabilize hydration 2026-04-21 13:43:22 +07:00
Mohamed Boudra 3832053736 fix: provider-scoped snapshot refresh with self-healing invalidation 2026-04-21 13:14:59 +07:00
Mohamed Boudra b01e274cce Fix provider refresh scope and self-healing 2026-04-21 12:41:28 +07:00
Mohamed Boudra 92e701be8f style: adjust typography and standardize ui text 2026-04-20 21:31:59 +07:00
Mohamed Boudra c2c990e763 docs: note beta rename in changelog 2026-04-20 20:49:38 +07:00
Mohamed Boudra 76b9f89f68 fix: tolerate beta release creation races 2026-04-20 20:41:24 +07:00
github-actions[bot] 970bf828b3 fix: update lockfile signatures and Nix hash 2026-04-20 13:40:07 +00:00
Mohamed Boudra dfc814147a feat: add beta release channel 2026-04-20 20:38:56 +07:00
Mohamed Boudra 831d990ef7 fix(release): avoid notes sync create races 2026-04-20 19:37:46 +07:00
Mohamed Boudra e09fd41adc fix(release): patch existing release notes by id 2026-04-20 19:33:39 +07:00
github-actions[bot] 8d32167826 fix: update lockfile signatures and Nix hash 2026-04-20 12:29:27 +00:00
Mohamed Boudra 1cfae98bf5 chore(release): cut 0.1.60-rc.3 2026-04-20 19:28:23 +07:00
Mohamed Boudra 4cbfe95473 fix(server): treat pi aborts as cancellations 2026-04-20 19:02:37 +07:00
Mohamed Boudra 242f61937a fix(app): stabilize terminal creation handler 2026-04-20 19:02:34 +07:00
Mohamed Boudra d913c4a9b7 fix(app): isolate bottom sheet modal scopes 2026-04-20 19:02:31 +07:00
Mohamed Boudra 2cd296e38f Fix workspace shortcut navigation 2026-04-20 18:06:09 +07:00
Mohamed Boudra 95c1d1fe0f Fix workspace composer focus routing 2026-04-20 17:41:00 +07:00
Mohamed Boudra 33ac657fed fix(app): treat workspace diff stats as authoritative 2026-04-20 17:40:11 +07:00
Mohamed Boudra 067125bd4f Fix composer safe-area ownership 2026-04-20 17:35:20 +07:00
Mohamed Boudra 93095aa1d2 Fix dictation readiness after app refresh 2026-04-20 17:23:17 +07:00
Mohamed Boudra ac45f23213 Fix mobile sidebar regressions and workspace e2e 2026-04-20 16:38:12 +07:00
github-actions[bot] b91749a316 fix: update lockfile signatures and Nix hash 2026-04-20 09:15:24 +00:00
Mohamed Boudra a6423f7f0a Improve workspace navigation and render isolation 2026-04-20 16:14:22 +07:00
Mohamed Boudra 403fb03c97 feat(app): retain workspace screens on sidebar switch 2026-04-20 12:55:58 +07:00
Mohamed Boudra f8411d121e Separate explorer checkout from panel state 2026-04-20 12:40:39 +07:00
Mohamed Boudra 9ea75afd37 Fix welcome screen unistyles leak 2026-04-20 12:40:30 +07:00
Mohamed Boudra 006894274e fix(desktop): patch React DevTools manifest for Electron compatibility 2026-04-20 11:16:21 +07:00
Mohamed Boudra 951fadc104 fix(mcp): accept null in list_models output schema 2026-04-20 11:06:21 +07:00
Mohamed Boudra 8600f183b1 feat(sidebar): restore PR state color, swap badge order, brighten checks on hover
- PR icon color reflects merge state (open/merged/closed) via the
  existing getWorkspacePrIconColor helper.
- PR badge is rendered before the failed-checks badge in the workspace
  row.
- Hovering the PR badge swaps the GitPullRequest icon for ExternalLink
  in place (same size, no layout shift) and removes the trailing arrow.
- Hovering the checks row in the workspace hover card brightens the
  icon and "Checks" label to foreground.
2026-04-20 10:57:27 +07:00
Mohamed Boudra 002946d4dd fix(sidebar): prevent text selection when dragging project/workspace rows
The drag handle was attached only to the left sub-view of each row, so
grabbing the right half initiated no drag, and pointerdown on the branch
or project title raced the browser's text-selection start before
dnd-kit's activation threshold — often producing a text selection
instead of a drag.

Move dragHandleProps to the outer row wrapper, add userSelect: none to
the row styles, and tighten PointerSensor activation distance.
2026-04-20 10:49:46 +07:00
Mohamed Boudra fb02bc8795 fix(workspace): collapse header loading skeleton to a single bar
The header now shows only one row of text, so drop the second skeleton
and widen the primary bar to match.
2026-04-20 10:46:23 +07:00
Mohamed Boudra 53baa16bee refactor(checkout-diff): rely solely on subscription for data
Drop the one-shot getCheckoutDiff query and use skipToken; the
subscribe_checkout_diff flow populates the cache on subscribe response
and updates. Derive isLoading from the presence of cached payload and
remove the now-unused isError/error fields from git-diff-pane.
2026-04-20 10:45:59 +07:00
Mohamed Boudra 67eddbd6a9 perf(explorer-sidebar): memoize animation context value
Stabilize animateToOpen/animateToClose with useCallback and wrap the
provider value in useMemo so consumers don't re-render on every parent
re-render.
2026-04-20 10:45:55 +07:00
Mohamed Boudra 044546ae0c perf(explorer-sidebar): skip PR queries when sidebar is closed
Thread isOpen down to SidebarContent so usePrPaneData stays disabled
until the user actually opens the sidebar.
2026-04-20 10:45:51 +07:00
Mohamed Boudra bd748b6edc perf(workspace): gate side effects on stack route focus
Pass useIsFocused through as isRouteFocused rather than short-circuiting
the whole tree. Terminals query/subscription, checkout diff, providers
snapshot, keybindings, tab reconciliation, focused-agent writes, and
sidebar rendering all no-op when the route is in the stack but not
focused, so a hidden workspace screen no longer drives background work.
2026-04-20 10:45:48 +07:00
Mohamed Boudra 427664dfa7 fix(app): drop getId from workspace stack route
Expo Router maps getId to React Navigation getId, which reorders an
already-mounted workspace screen on Android native-stack/Fabric. Keep
workspace identity/retention outside the route-level API and leave a
comment warning against re-adding it.
2026-04-20 10:45:02 +07:00
Mohamed Boudra a0169bf7f4 feat(app): export isDev platform flag and enable runtime metrics in dev
Adds a shared isDev constant alongside isWeb/isNative and wires daemon
clients to collect runtime metrics (10s interval) only in development.
2026-04-20 10:44:57 +07:00
Mohamed Boudra 79968ee02d feat(daemon-client): add opt-in runtime metrics collector
Aggregates inbound message counts, bytes, handler timings, and
agent_stream breakdowns over a rolling window and flushes via logger.
Gated on runtimeMetricsIntervalMs config; disabled by default.
2026-04-20 10:44:43 +07:00
Mohamed Boudra afe0eaab5b Set APP_VARIANT=development in app startup scripts 2026-04-20 09:07:34 +07:00
Mohamed Boudra 04465f124c Add dev mock load-test provider 2026-04-20 00:38:10 +07:00
Mohamed Boudra 86b83c3366 Fix workspace diff stat watch refresh 2026-04-20 00:36:54 +07:00
github-actions[bot] 4a5dd986e1 fix: update lockfile signatures and Nix hash 2026-04-19 16:23:48 +00:00
Mohamed Boudra 64065061c5 chore(release): cut 0.1.60-rc.2 2026-04-19 23:21:12 +07:00
Mohamed Boudra 7b9636a22d fix(server): bypass diff cache on working-tree watch refresh
CheckoutDiffManager.refreshTarget was calling workspaceGitService.getCheckoutDiff
without forcing a cache bypass, so watch-fired refreshes within the 15s consumer
TTL returned the pre-mutation cached diff — fingerprint matched, no
checkout_diff_update was emitted, and the sidebar diff appeared frozen while an
agent edited files. Route watch-fired refreshes through the service with
{ force: true, reason: "working-tree-watch" } so they always recompute.
2026-04-19 23:19:41 +07:00
Mohamed Boudra 8165de7b06 feat(server): replay pi tool calls in streamHistory for resume
Pi's streamHistory only emitted text, so resuming a session lost tool
calls, tool results, and bash executions from the replayed timeline.
Iterate session.messages to also emit running/completed/failed tool_call
items and bashExecution shell calls, and tag user messages with a stable
messageId so hydrateTimelineFromLegacyProviderHistory can dedupe.

Cover it with a real OpenRouter-backed e2e test that runs a bash tool
call, resumes via PiDirectAgentClient.resumeSession, and asserts the
replayed timeline contains the user message, assistant message, and
completed shell tool_call with the expected output.
2026-04-19 23:17:40 +07:00
Mohamed Boudra 75f83b0b9b Merge refactor/session-store-identity into main 2026-04-19 22:36:10 +07:00
Mohamed Boudra badec6f587 Merge orchestrate/notif-routing-presence into main 2026-04-19 22:35:37 +07:00
Mohamed Boudra 5a2708653c feat(server,app): redesign git/GitHub refresh architecture with server authority
Centralizes refresh orchestration in WorkspaceGitService and GitHubService:
server coalesces, deduplicates, and throttles git/GitHub reads; clients peek
snapshots (cold reads fall through to a one-shot fetch) and never drive
refreshes. Introduces a two-TTL model (consumer vs internal), force-emit
bypass so fingerprint-matched refreshes still propagate, notifyGitMutation
wiring at ~13 action handlers, adaptive GitHub polling based on check state,
and checkout_status_update / prStatus push-to-cache wire messages. Fixes the
stale diff-stat bug and the inverted base-branch diff direction, and flips
next-action affordances promptly after commit / PR merge.
2026-04-19 22:35:26 +07:00
Mohamed Boudra 06b3fc2ec6 fix(server,app,desktop): route in-app notifications by presence cascade
Replaces the per-client stale/visibility policy with one shared
presence cascade so a backgrounded Firefox tab can no longer steal
in-app notifications from an active Electron desktop window.

Server (`agent-attention-policy.ts`): exports `PRESENCE_THRESHOLD_MS`
(3 min) and `computeNotificationPlan({ allStates, agentId, reason,
nowMs })` returning `{ inAppRecipientIndex, shouldPush }`. Cascade:
focus suppression requires presence; otherwise the most-recent
present client wins (lower-index tiebreak); push fires only when no
client is present, and never on `error`. `broadcastAgentAttention`
calls the policy once per event.

Electron (`daemon-manager.ts`, `idle.ts`): registers
`desktop_get_system_idle_time` returning `powerMonitor
.getSystemIdleTime() * 1000`. Renderer wrapper
`getDesktopSystemIdleTimeMs()` is best-effort: rejected IPC, null,
NaN, Infinity, or negative values return null without throwing.

Hook (`use-client-activity.ts`): Electron-only effect polls every 5s
and updates `lastActivityAtRef` only forward, so OS-wide activity
keeps a backgrounded Electron window present while in-window events
still update immediately.

`ClientHeartbeatMessageSchema` is unchanged; old/new clients and
daemons remain wire-compatible.

Tests: 52 focused tests across policy unit, dispatch, e2e, hook,
desktop wrapper, and permissions. Phase-3 dispatch test +
Phase-5 stale-poll test verified by mutation (breaking the focus
check / the `>` guard makes the exact regression fail).
2026-04-19 21:48:39 +07:00
Mohamed Boudra bd1fb311ea refactor(app): reshape session store reads around narrow hook surface
Eliminate sidebar re-render cascades by fixing both sides of the
subscription contract:

- Session-store writes preserve identity: setWorkspaces, mergeWorkspaces,
  removeWorkspace, and patchWorkspaceScripts return the previous Map/entry
  reference when content is unchanged.
- HostRuntimeController.updateSnapshot only notifies when a patched field
  actually changed; idle probe ticks no longer replace the snapshot.
- New canonical hook surface in stores/session-store-hooks.ts
  (useWorkspace, useWorkspaceFields, useWorkspaceStructure,
  useWorkspaceKeys, useResolveWorkspaceIdByCwd,
  useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority,
  useRecommendedProjectPaths, useHasWorkspaces). These are the only
  place to subscribe to workspaces.
- 15 greedy subscribers migrated to the hook surface. The sidebar is now
  driven by useWorkspaceStructure for identity/ordering; each row
  hydrates its own descriptor via useWorkspaceFields, so a workspace
  update re-renders only its row. Archive-redirect callbacks switched to
  event-time useSessionStore.getState().
- Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts,
  and the unused SidebarProjectEntry aggregates (activeCount,
  totalWorkspaces, aggregated statusBucket).

No user-visible behavior change. Targeted tests green (79 tests across
host-runtime, session-store, session-store-hooks, sidebar list, sidebar
row model, sidebar shortcuts, workspace source of truth).
2026-04-19 20:19:45 +07:00
Mohamed Boudra 8991d34f78 fix(app): allow collapsing the parent folder of a selected file (#500)
The file explorer had a useEffect that re-enforced expansion of every
ancestor of `selectedEntryPath` whenever `expandedPaths` changed. As a
result, the moment a user collapsed the parent folder of a file they
had just opened, the effect re-added that folder to the expanded set
and the folder appeared stuck open.

Selection and expansion are orthogonal: clicking a file should not
dictate that its parent folder stays open forever. There is also no
current caller that sets `selectedEntryPath` from outside the tree, so
the reveal-on-external-selection use case the effect was designed for
is unused. Delete the effect and its two now-dead helpers. If a future
"Reveal in Explorer" action is introduced, it can expand ancestors at
the call site.

Adds an E2E regression test that reproduces the original scenario:
expand a folder, open an image, collapse the parent folder, assert
children are hidden. Also asserts an unrelated sibling folder still
expands after the image is open, guarding against any future regression
that makes the bug global.
2026-04-19 16:49:09 +08:00
Mohamed Boudra 128d6668ae fix(server): update PR status test for new normalized fields
normalizeCheckoutPrStatusPayload always emits the full wire shape now
(number, repoOwner, repoName, isDraft, checks, checksStatus,
reviewDecision) — the workspace-git-watch test's toEqual assertion
still had the pre-PR-pane shape and was failing.

Update the expected object to match the actual wire contract.
2026-04-19 15:11:37 +07:00
Mohamed Boudra d9d6509880 fix(test): resolve Metro .web.ts variants in vitest
message-input.tsx imports ./composer-height-mirror, which only exists
as .web.ts / .native.ts / .d.ts. Metro picks the right variant at build
time, but vitest was using Vite's default extension list and failing to
resolve the import — breaking app tests in CI.

Prepend `.web.*` variants to `resolve.extensions` in both the root and
app vitest configs so test runs (which already alias react-native →
react-native-web) pick the web implementation, matching what Metro
does for the web bundle.
2026-04-19 15:11:37 +07:00
Mohamed Boudra 2d9cbb0453 fix(server): resolve remote-only branches in switcher checkout
Branch switcher listed remote-only branches as if they were local, then
threw "Branch not found" when clicked because the checkout path only
verified `refs/heads/<name>`. Explicit `origin/<name>` input went the
other way — verify passed, but checkout produced a detached HEAD.

Split the concerns into three layers:

- Enumerate: `listBranchSuggestions` returns provenance (`hasLocal`,
  `hasRemote`) so the wire payload no longer loses origin-only info.
  Fields are optional on `branchDetails` for backward compat.
- Resolve: new `resolveBranchCheckout` normalizes `origin/<name>` →
  `<name>`, verifies explicit `refs/heads/` and `refs/remotes/origin/`
  paths (no tag-DWIM), returns a tagged
  `{ local | remote-only | not-found }` result. Policy (local wins,
  origin fallback) lives here.
- Act: `checkoutExistingBranch` no longer does implicit DWIM. Local →
  `git checkout <name>`. Remote-only → `git checkout -b <name> --track
  origin/<name>`. Not-found → throws the existing
  `Branch not found: <name>` string. Response carries optional
  `source: "local" | "remote"`.

`validateBranch` now routes through the same resolver so both paths
agree on what a branch is.

Also fixes a related stale-base diff bug: worktrees created off
`origin/main` could show phantom "added" commits when the local `main`
lagged. `resolveBestComparisonBaseRef` is now origin-first for
diff/status. Merge still uses the "most-ahead" helper so unpublished
local commits aren't lost. `doesGitRefExist` tightened to only swallow
exit 1 (missing ref), propagating other git failures.
2026-04-19 14:26:52 +07:00
Mohamed Boudra e373c33108 feat(app,server): add pull request pane to the workspace sidebar
Introduce a PR tab in the explorer sidebar that shows status checks,
reviews, and the merged timeline for the current branch's pull
request. Extend checkout PR status with number, draft flag, repo
identity, and per-check workflow/duration; add a new
pull_request_timeline request/response pair backed by a GraphQL
query; enrich the workspace git snapshot to carry the fields through
to the client; and extend the checkout-git-actions store so timeline
queries are invalidated alongside status when mutations land.
2026-04-19 14:26:52 +07:00
Mohamed Boudra 6a36db9924 feat(server): invalidate GitHub cache after local git mutations
Pass the GitHub service into pullCurrentBranch, pushCurrentBranch,
createPullRequest, branch setup, and worktree archival, and invalidate
the cached PR status / repo view whenever a mutation succeeds. Have
mergeToBase return the worktree cwd it actually touched so the session
can invalidate the base checkout when the merge lands there.
2026-04-19 14:26:51 +07:00
Mohamed Boudra d3393b434a feat(app,server): rank new-workspace picker by recency with debounced search
Plumb updatedAt through GitHub issue/PR search and committerDate through
branch suggestions so the new-workspace picker interleaves branches and
PRs sorted by last activity. Debounce the search query so remote GitHub
searches are driven by what the user typed rather than a static list.
2026-04-19 14:26:51 +07:00
Mohamed Boudra 43bbf8cb12 feat(app): collapse workspace header button labels on narrow rows
Measure the header row width and hide text labels on the Open,
Scripts, and git action split buttons below 700px so the header
fits without wrapping on split-pane layouts.
2026-04-19 14:26:51 +07:00
Mohamed Boudra cfa3b009e3 fix(app): prevent branch switcher title from overflowing its row
Wrap the branch icon and title in a flex row with minWidth 0 and
overflow hidden so long titles truncate instead of pushing the
switcher off-screen.
2026-04-19 14:26:51 +07:00
Mohamed Boudra f411a2a383 feat(app): rank combobox options by word-boundary match quality
Introduce a shared scoreMatch utility that tiers matches by
word-boundary vs mid-word offset, and use it to rank combobox
options so e.g. "py" prefers "a/py" over "happy".
2026-04-19 14:26:51 +07:00
Mohamed Boudra c4d84ff2a3 chore: add workspace aliases to vitest config for app tests
Resolve @, @server, @getpaseo/relay, react, react-native, and xterm
ligatures from the app workspace so cross-workspace tests run.
2026-04-19 14:26:51 +07:00
Mohamed Boudra 73b0b60bdd fix(server): block dev-runner on Ctrl-C until supervisor finishes shutting down
The spawnSync parent had no signal handlers, so SIGINT killed it immediately
while the supervisor kept running in the background. The shell prompt came
back before the daemon had actually drained. Install no-op SIGINT/SIGTERM
handlers so spawnSync waits for the supervisor's graceful exit.
2026-04-19 14:26:51 +07:00
Mohamed Boudra 5a232d8cf1 fix(server,app): coalesce tool call stream events to prevent JS thread freeze
Pi's ACP binary streams 200+ redundant tool_call_update events per tool call,
bypassing the server coalescer and flooding the app with synchronous state
updates. Two-layer fix:

Server: extend AgentStreamCoalescer to handle tool_call items via last-write-wins
per callId (200ms window, up from 33ms). Terminal statuses flush immediately.

App: mergeToolCallDetail and appendAgentToolCall now return existing references
when fields are unchanged, preserving React.memo equality checks.
2026-04-19 14:26:50 +07:00
Mohamed Boudra 2238171955 fix(app): composer textarea shrinks back down on web
Measuring scrollHeight on the live textarea with an explicit height
set can only grow — it can never report a shorter content height.
The old `height: auto` toggle flickered across browsers.

Use a hidden off-DOM textarea that mirrors the composer's text-metric
styles and width, and read its scrollHeight on value/resize changes.
Gated by Metro file extension (.web / .native) so it's trivial to
rip out later.
2026-04-19 14:26:50 +07:00
fbc2371b95 feat(server): replace Pi ACP with direct SDK provider (#202)
* feat(server): replace Pi ACP integration with direct SDK provider

Replace the ACP-based Pi provider (which spawned a `pi-acp` subprocess
and talked JSON-RPC over stdio) with a direct in-process integration
using `@mariozechner/pi-coding-agent` as a library.

The new `PiDirectAgentClient` and `PiDirectAgentSession` use the Pi SDK
directly — creating sessions via `createAgentSession()`, managing models
via `ModelRegistry`, and mapping Pi's `AgentSessionEvent` stream to
Paseo's `AgentStreamEvent` types including:

- Thread/turn lifecycle events
- Assistant text and thinking/reasoning deltas
- Tool execution (bash, read, write, edit, find, grep, ls) with arg
  caching across start/update/end events
- Compaction events
- Session persistence and resume via `SessionManager`

Thinking levels flow through the existing `thinkingOptionId` /
`setThinkingOption()` infrastructure, not through features.

Includes 9 real e2e tests against a live Pi agent covering tool calls
(bash/read/write/edit), reasoning chunks, session persistence/resume,
model listing, and thinking option management.

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

* fix: update lockfile signatures and Nix hash

* refactor(server): improve Pi direct provider code quality

Address coding standards violations in pi-direct-agent.ts:

- Replace all `as Record<string, unknown>` / `as any` / `as unknown as`
  casts with properly typed boundaries using Pi SDK's exported tool
  input types (BashToolInput, ReadToolInput, EditToolInput, etc.)
- Introduce named interfaces at all boundaries: PiPromptPayload,
  ToolCallOutputSummary, PiModelReference, PiPersistenceMetadata
- Parse tool args once at boundary into typed discriminated union,
  then trust types internally — no more typeof probing in mappers
- Break all dense ternary chains into named steps / helper functions
- Import SDK types directly (RegisteredCommand, ResourceLoader, Skill)
  instead of casting through anonymous inline shapes
- Isolate the system prompt SDK escape hatch into a minimal boundary
  helper with a named interface

Behavior is preserved — all 9 real e2e tests still pass.

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

* fix: regenerate package-lock.json with resolved/integrity fields

The previous lockfile regeneration ran under an offline/cached npm
context and stripped resolved URLs and integrity hashes from existing
entries, which would break npm ci and Nix builds.

* fix: update Nix npmDepsHash for regenerated lockfile

* chore(server): bump @mariozechner/pi-coding-agent to ^0.67.68

* fix: update lockfile signatures and Nix hash

* fix(app): surface agent setter RPC errors as toasts

setAgentMode/Model/ThinkingOption/Feature errors from the daemon were
only logged to console, so failures like missing API keys silently
vanished. Add toast.error fallbacks at all call sites and hoist
ToastProvider above SessionProvider so session context can use it.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-19 14:45:01 +08:00
Mohamed Boudra f8815bf1c2 docs(website): rewrite worktrees page around scripts, services, and proxy 2026-04-18 21:15:06 +07:00
github-actions[bot] 93cda8e60f fix: update lockfile signatures and Nix hash 2026-04-18 13:58:06 +00:00
Mohamed Boudra c53d42acf8 chore(release): cut 0.1.60-rc.1 2026-04-18 20:56:49 +07:00
github-actions[bot] 2a519d96e5 fix: update lockfile signatures and Nix hash 2026-04-18 13:54:47 +00:00
Mohamed Boudra 07714773cc Revert "chore(release): cut 0.2.0-rc.1"
This reverts commit 9fb0ab9b2d.
2026-04-18 20:53:24 +07:00
github-actions[bot] ada623c1c6 fix: update lockfile signatures and Nix hash 2026-04-18 13:47:19 +00:00
Mohamed Boudra 9fb0ab9b2d chore(release): cut 0.2.0-rc.1 2026-04-18 20:46:06 +07:00
Mohamed Boudra 83bbc8f659 Merge branch 'dev' into main 2026-04-18 19:54:47 +07:00
Mohamed Boudra 7819019cea feat(server,app,website): prefix service URLs with project slug
Service hostnames now follow DNS convention with a project label so
independently opened projects no longer collide on routes like
`web.localhost`. Format becomes `{script}.{project}.localhost` for
default branches and `{script}.{branch}.{project}.localhost` for other
branches. The project slug comes from the GitHub repository name when a
remote exists, otherwise from the workspace directory basename.

Server changes:
- `buildScriptHostname` takes `{ projectSlug, branchName, scriptName }`
  and slugifies all labels at the boundary; `untitled` fallback only
  applies at hostname-label sites, never to shared `slugify`.
- `deriveProjectSlug(cwd)` and `parseGitHubRepoNameFromRemote` derive
  the slug; GitHub URL parsing now requires `host === "github.com"` to
  avoid embedded-path false matches.
- `ScriptRouteEntry` carries `projectSlug`; branch-rename handler reuses
  it instead of re-reading git in hot paths.
- Status projection derives slug once per call and uses route hostname
  when available; only falls back to building when no route exists.
- Spawn cleanup removes routes by `{ workspaceId, scriptName }` so
  cleanup after a branch rename doesn't leave a stale entry behind.

App and shared message tests updated to the new fixture shape; service
URLs continue to be opaque server-provided strings.

Landing-page service URL examples updated; allowlist docs unchanged.
2026-04-18 19:31:23 +07:00
Mohamed Boudra 23b20169ef test(app,server): align e2e specs with settings redesign, drop rg dep
- worktree-core test scans the server source via a pure Node walker
  instead of shelling out to `rg`, which is not installed on CI runners.
  Invariant and scope are unchanged.
- settings-host-page specs target the new detail-header label + rename
  affordance (settings-detail-header-title / host-page-label-edit-button)
  since the label card moved into the shared header as part of the
  modular settings refactor.
- settings-navigation compact tests assert the new two-tier Back
  semantics: root Back exits settings, detail Back returns to /settings.
2026-04-18 18:07:07 +07:00
Mohamed Boudra e5b0ea6d1b fix(paseo): pin app service to PASEO_PORT 2026-04-18 17:19:44 +07:00
Mohamed Boudra 4de4e90325 fix(app): close theme split escape hatches 2026-04-18 16:25:23 +07:00
Mohamed Boudra fa41ba76cd Merge branch 'orchestrate/agent-stream-coalescing' into dev 2026-04-18 16:09:05 +07:00
Mohamed Boudra 9f6570ca3e Merge branch 'config-work' into dev 2026-04-18 16:08:57 +07:00
Mohamed Boudra ccb956f98f wip(server): coalesce agent stream events and add runtime metrics
Introduce AgentStreamCoalescer to batch rapid-fire agent events before
broadcasting, reducing WebSocket chatter during tool-heavy turns. Wire
websocket-server runtime metrics so we can observe coalescing behavior
in tests. Work in progress.
2026-04-18 16:08:54 +07:00
Mohamed Boudra f483e62231 wip(app): keep keyboard shortcuts alive on /settings
Chrome gating currently disables shortcuts on non-workspace routes. Temporary
pathname check until chromeEnabled is split into separate workspace/global
concerns.
2026-04-18 16:08:43 +07:00
Mohamed Boudra c0bb34c514 docs: document service-script env vars and split dev into daemon+app
Document the PASEO_SERVICE_<NAME>_URL/PORT and PASEO_URL/PORT contract exposed
by the service-script runtime. Split paseo.json's monolithic "server" script
into "daemon" and "app" services so peer discovery exercises the new env.
2026-04-18 16:03:27 +07:00
Mohamed Boudra 6b5282ad43 refactor(server): extract workspace service env and port registry
Pull service env construction and port allocation out of worktree-bootstrap
into dedicated modules with their own tests, so the bootstrap path only
orchestrates. Adds peer collision detection and a port plan that is
computed once and reused across scripts.
2026-04-18 16:03:18 +07:00
Mohamed Boudra 72e4476acc refactor(app): replace providers-snapshot invalidate with refetchIfStale
invalidate() flagged the query stale and kicked off a refetch unconditionally,
causing a loading flash every time the model selector opened. Expose
refetchIfStale() instead so callers only refetch when data is actually stale.
2026-04-18 16:03:13 +07:00
Mohamed Boudra 5d76cc8840 feat(app): gate return-key submit icon to new workspace composer
The new-workspace composer is a one-shot "create" submit, not a chat send, so
a return-key glyph reads more accurately there than an arrow. Add a submitIcon
prop on Composer/MessageInput (defaulting to "arrow") and opt NewWorkspace
into "return".
2026-04-18 16:03:08 +07:00
Mohamed Boudra b0e0786419 fix(app): make cmd+o work regardless of sidebar visibility
The worktree.new keyboard handler was registered inside ProjectHeaderRow,
which only mounts when the sidebar is open. Register a single global handler
in the app layout keyed off the navigation-active workspace selection.
2026-04-18 16:03:01 +07:00
Mohamed Boudra 14cc1cc83c feat: support creating worktrees from GitHub pull requests 2026-04-18 12:08:30 +07:00
Mohamed Boudra 82f97e942d fix(server): suppress browser popups from ACP probes
Pass NO_BROWSER=true when probing ACP agents for models, modes, and
persisted sessions. Gemini CLI's ACP newSession handler calls refreshAuth
inline and will open a Google OAuth URL in the browser when no valid
cached token is present — every snapshot warm-up triggered this on
navigation. Real agent sessions still use their own launchContext.env,
so interactive auth continues to work when actually starting an agent.
2026-04-18 11:10:34 +07:00
Mohamed Boudra 59d9b2ebdd fix(app): bridge trigger and content in workspace hover card
Closes the hover card via a safe-zone hook that covers the trigger, the
content, and the rectangular bridge between them, so crossing the visual
gap no longer drops the hover. Replaces the event-based close flow whose
dropped pointerleave events left cards stuck open.
2026-04-18 10:58:17 +07:00
Mohamed Boudra d8ff049aff fix(app): only open tooltip on keyboard focus
Modal close restores focus to the tooltip's trigger, which was firing
onFocus and re-opening the tooltip. Gate onFocus on input modality so
only keyboard-driven focus opens it, matching W3C tooltip behavior.
Also removes the key-based remount workarounds that existed solely to
sidestep this bug.
2026-04-18 10:10:38 +07:00
Mohamed Boudra f08bff1b2d fix(app): centralize workspace diff stat rendering
The source-control button rendered diff stats inline with no formatting
while the sidebar used toLocaleString, so identical numbers looked
different. Route both through DiffStat and switch to compact notation
(e.g. 87,700 -> 87.7k) so large counts stay readable.
2026-04-18 09:56:14 +07:00
338 changed files with 41437 additions and 8043 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
- '!v*-rc.*' - '!v*-beta.*'
- 'app-v*' - 'app-v*'
- '!app-v*-rc.*' - '!app-v*-beta.*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+12 -6
View File
@@ -66,7 +66,9 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \ --title "Paseo $RELEASE_TAG" \
--generate-notes \ --generate-notes \
$prerelease_flag $prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
}
fi fi
publish-macos: publish-macos:
@@ -145,6 +147,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
@@ -154,7 +157,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: mac-manifest-${{ matrix.electron_arch }} name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
retention-days: 1 retention-days: 1
finalize-mac-manifest: finalize-mac-manifest:
@@ -227,8 +230,9 @@ jobs:
return out; return out;
} }
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8'); const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8'); const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
const arm64 = parseManifest(arm64Text); const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text); const x64 = parseManifest(x64Text);
@@ -243,7 +247,7 @@ jobs:
}; };
const output = toYaml(merged); const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output); fs.writeFileSync(manifestName, output);
console.log('Merged manifest:\n' + output); console.log('Merged manifest:\n' + output);
NODE NODE
@@ -251,7 +255,7 @@ jobs:
if: env.IS_SMOKE_TAG != 'true' if: env.IS_SMOKE_TAG != 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}" run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
publish-linux: publish-linux:
needs: [create-release] needs: [create-release]
@@ -316,6 +320,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
@@ -391,6 +396,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
+1 -3
View File
@@ -54,9 +54,7 @@ jobs:
fi fi
create_if_missing="false" create_if_missing="false"
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true" create_if_missing="true"
fi fi
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.1.60-beta.1 - 2026-04-20
### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
## 0.1.59 - 2026-04-16 ## 0.1.59 - 2026-04-16
### Added ### Added
+1 -1
View File
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit) - `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release) - `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores. Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands ### Useful commands
+27
View File
@@ -54,6 +54,33 @@ npm run db:query -- --db /path/to/db "SELECT ..."
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`. Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode. Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
## paseo.json service scripts
Every `scripts` entry with `"type": "service"` receives these environment variables:
| Variable | Value |
|---|---|
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
```json
{
"scripts": {
"web": {
"type": "service",
"command": "PORT=$PASEO_PORT npm run dev:web"
}
}
}
```
## Build sync gotchas ## Build sync gotchas
### Relay → Daemon ### Relay → Daemon
+28 -25
View File
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
There are two supported ways to ship from `main`: There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately. 1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet. 2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch) ## Standard release (patch)
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows) npm run release:push # Push HEAD + tag (triggers CI workflows)
``` ```
## Release candidate flow ## Beta flow
```bash ```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ... # ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ... npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
``` ```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1` - Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows - Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag - `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop` - Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release. - Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
Use the RC path when you need to: Use the beta path when you need to:
- test a build manually in a Linux or Windows VM - test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem - send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly - iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Website behavior ## Website behavior
- The website download page points to GitHub's latest published **stable** release. - The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target. - Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`. - The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build ## Fixing a failed release build
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK # Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC # Beta
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
``` ```
This ensures the checkout ref matches the actual code on `main` with the fix included. This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release - `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only - `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform - `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only - `android-vX.Y.Z` rebuilds the Android APK release only
@@ -105,24 +106,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types - `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop` - `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions - If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target. - The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
## Changelog format ## Changelog format
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed: Release notes depend on the changelog heading format. The heading **must** be strictly followed:
``` ```
## X.Y.Z - YYYY-MM-DD ## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
``` ```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage. No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy ## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**. - `CHANGELOG.md` includes stable releases and the current beta line.
- Do not add or edit changelog entries while iterating on RCs. - The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle. - The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Between stable releases, keep changelog work out of the repo until the final release is ready. - Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
## Changelog ownership ## Changelog ownership
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check ## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs. Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like: Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
The changelog always covers **stable-to-HEAD**: The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`. - **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC. - **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next. In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
## Completion checklist ## Completion checklist
+71 -1
View File
@@ -31,7 +31,7 @@ const styles = StyleSheet.create((theme) => ({
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons. On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, and library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`. This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
## Fix Patterns ## Fix Patterns
@@ -89,6 +89,38 @@ const { theme } = useUnistyles();
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value. Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
## `withUnistyles` And The `> *` Child-Selector Leak
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
```css
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
```
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
Symptoms to watch for:
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
Quick confirmation in DevTools console:
```js
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
```
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
## Hidden Sheet Content ## Hidden Sheet Content
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly. `@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
@@ -105,6 +137,44 @@ const { theme } = useUnistyles();
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content. Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
## Memoized Style Objects
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
Avoid indirect keys like this:
```tsx
const { theme, rt } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
```
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
```tsx
const { theme } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
```
If a style factory is cheap, skipping `useMemo` entirely is also fine.
## Static Theme Imports
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
Use `useUnistyles()` inside the component instead:
```tsx
const { theme } = useUnistyles();
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
```
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
## Adaptive Themes And Persisted Settings ## Adaptive Themes And Persisted Settings
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage. Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-Q8FCZ5cQs45ZL0XeaRzgZXmROmywT0YI/1Rqdlpbt/w="; npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
# Prevent onnxruntime-node's install script from running during automatic # 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). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+2831 -78
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.59", "version": "0.1.60-beta.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
@@ -47,19 +47,19 @@
"version:all:patch": "node scripts/set-release-version.mjs --mode patch", "version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor", "version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major", "version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch", "version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor", "version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major", "version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next", "version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote", "version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs", "release:push": "node scripts/push-current-release-tag.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push", "release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push", "release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push", "release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push", "release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push", "release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push", "release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push", "release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
@@ -0,0 +1,61 @@
import { test } from "./fixtures";
import {
collapseFolder,
expandFolder,
expectExplorerEntryHidden,
expectExplorerEntryVisible,
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "./helpers/file-explorer";
import { gotoWorkspace } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: WorkspaceSetupDaemonClient;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
files: [
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
{ path: "docs/guide.md", content: "# Guide\n" },
],
});
seedClient = await connectWorkspaceSetupClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) {
throw new Error(result.error ?? "Failed to seed workspace");
}
workspaceId = String(result.workspace.id);
});
test.afterAll(async () => {
await seedClient?.close();
await tempRepo?.cleanup();
});
test.describe("File explorer collapse", () => {
test("collapses an opened image file parent folder and still expands other folders", async ({
page,
}) => {
await gotoWorkspace(page, workspaceId);
await openFileExplorer(page);
await expandFolder(page, "assets");
await expectExplorerEntryVisible(page, "logo.png");
await openFileFromExplorer(page, "logo.png");
await expectFileTabOpen(page, "assets/logo.png");
await collapseFolder(page, "assets");
await expectExplorerEntryHidden(page, "logo.png");
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
});
});
+49 -1
View File
@@ -1,6 +1,6 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process"; import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises"; import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import net from "node:net"; import net from "node:net";
@@ -182,6 +182,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null; let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null; let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null; let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let relayProcess: ChildProcess | null = null; let relayProcess: ChildProcess | null = null;
type OfferPayload = { type OfferPayload = {
@@ -191,6 +192,47 @@ type OfferPayload = {
relay: { endpoint: string }; relay: { endpoint: string };
}; };
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
return binDir;
}
function stripAnsi(input: string): string { function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, ""); return input.replace(/\u001b\[[0-9;]*m/g, "");
} }
@@ -281,6 +323,7 @@ export default async function globalSetup() {
let relayPort = 0; let relayPort = 0;
const metroPort = await getAvailablePort(); const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")); paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer(); let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer(); const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer();
@@ -298,6 +341,10 @@ export default async function globalSetup() {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
paseoHome = null; paseoHome = null;
} }
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
}; };
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY); const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
@@ -482,6 +529,7 @@ export default async function globalSetup() {
cwd: serverDir, cwd: serverDir,
env: { env: {
...process.env, ...process.env,
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: paseoHome, PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon", PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_LISTEN: `0.0.0.0:${port}`,
+6 -4
View File
@@ -210,13 +210,15 @@ export async function openWorkspaceWithAgents(
} }
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> { export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({ await expect(
timeout: 30_000, page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
}); ).toBeVisible({ timeout: 30_000 });
} }
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> { export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, { await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
}); });
} }
+41
View File
@@ -0,0 +1,41 @@
import { expect, type Page } from "@playwright/test";
function fileExplorerTree(page: Page) {
return page.getByTestId("file-explorer-tree-scroll");
}
function fileExplorerEntry(page: Page, name: string) {
return fileExplorerTree(page).getByText(name, { exact: true }).first();
}
export async function openFileExplorer(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).first().click();
await page.getByTestId("explorer-tab-files").click();
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
}
export async function expandFolder(page: Page, folderName: string): Promise<void> {
await fileExplorerEntry(page, folderName).click();
}
export async function collapseFolder(page: Page, folderName: string): Promise<void> {
await fileExplorerEntry(page, folderName).click();
}
export async function openFileFromExplorer(page: Page, fileName: string): Promise<void> {
await fileExplorerEntry(page, fileName).click();
}
export async function expectExplorerEntryVisible(page: Page, name: string): Promise<void> {
await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 });
}
export async function expectExplorerEntryHidden(page: Page, name: string): Promise<void> {
await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 });
}
export async function expectFileTabOpen(page: Page, filePath: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({
timeout: 30_000,
});
}
+19 -11
View File
@@ -23,16 +23,18 @@ export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
/** Wait for the workspace tab bar to be visible. */ /** Wait for the workspace tab bar to be visible. */
export async function waitForTabBar(page: Page): Promise<void> { export async function waitForTabBar(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
} }
/** Return all tab test IDs currently in the tab bar. */ /** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> { export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator( const tabs = page
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])', .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
); .filter({ visible: true });
const count = await tabs.count(); const count = await tabs.count();
const ids: string[] = []; const ids: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
.locator( .locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]', '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
) )
.filter({ visible: true })
.first(); .first();
if (await activeTab.isVisible().catch(() => false)) { if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid"); return activeTab.getAttribute("data-testid");
@@ -67,14 +70,14 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */ /** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> { export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab"); const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */ /** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> { export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal"); const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
@@ -94,26 +97,30 @@ export async function waitForLauncherPanel(page: Page): Promise<void> {
/** Assert the new agent tab button is visible in the tab bar. */ /** Assert the new agent tab button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> { export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible(); await expect(
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
).toBeVisible();
} }
/** Assert the new terminal button is visible in the tab bar. */ /** Assert the new terminal button is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> { export async function assertTerminalTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible(); await expect(
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
).toBeVisible();
} }
// ─── Tab creation actions ───────────────────────────────────────────────── // ─── Tab creation actions ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */ /** Click the new agent tab button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> { export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab"); const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
/** Click the new terminal button to create a terminal tab. */ /** Click the new terminal button to create a terminal tab. */
export async function clickTerminal(page: Page): Promise<void> { export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal"); const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
@@ -131,13 +138,14 @@ export async function waitForTabWithTitle(
page page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])') .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: matcher }) .filter({ hasText: matcher })
.filter({ visible: true })
.first(), .first(),
).toBeVisible({ timeout }); ).toBeVisible({ timeout });
} }
/** Assert the new agent tab button is visible in the tab bar. */ /** Assert the new agent tab button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> { export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-agent-tab"); const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
const count = await buttons.count(); const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1); expect(count).toBeGreaterThanOrEqual(1);
} }
+45 -1
View File
@@ -153,7 +153,7 @@ export async function createWorktreeViaDaemon(
}; };
} }
export async function clickNewWorkspaceButton( export async function openNewWorkspaceComposer(
page: Page, page: Page,
input: { projectKey: string; projectDisplayName: string }, input: { projectKey: string; projectDisplayName: string },
): Promise<void> { ): Promise<void> {
@@ -168,7 +168,13 @@ export async function clickNewWorkspaceButton(
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, { await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000, timeout: 30_000,
}); });
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const createButton = page const createButton = page
.getByTestId("message-input-root") .getByTestId("message-input-root")
.getByRole("button", { name: "Create" }); .getByRole("button", { name: "Create" });
@@ -176,6 +182,44 @@ export async function clickNewWorkspaceButton(
await createButton.click(); await createButton.click();
} }
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
await expect(branchRow).toBeVisible({ timeout: 30_000 });
await branchRow.click();
}
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
await prRow.click();
}
export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pills = page.getByTestId("composer-github-attachment-pill");
await expect(pills).toHaveCount(1);
await expect(pills.first()).toContainText(`#${input.number}`);
await expect(pills.first()).toContainText(input.title);
}
export async function assertNewWorkspaceSidebarAndHeader( export async function assertNewWorkspaceSidebarAndHeader(
page: Page, page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string }, input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
+17 -5
View File
@@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
return ids; return ids;
} }
function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> { export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({ await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
} }
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> { export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const allTabIds = await getWorkspaceTabTestIds(page); const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_")); const count = await tabs.count();
const ids: string[] = [];
for (let index = 0; index < count; index += 1) {
const testId = await tabs.nth(index).getAttribute("data-testid");
if (testId && !ids.includes(testId)) {
ids.push(testId);
}
}
return ids;
} }
export async function expectOnlyWorkspaceAgentTabsVisible( export async function expectOnlyWorkspaceAgentTabsVisible(
@@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible(
expect(unexpected).toEqual([]); expect(unexpected).toEqual([]);
expect(visible.length).toBe(expected.size); expect(visible.length).toBe(expected.size);
for (const expectedId of expectedAgentIds) { for (const expectedId of expectedAgentIds) {
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({ await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
} }
+2 -2
View File
@@ -103,8 +103,8 @@ export async function expectWorkspaceHeader(
page: Page, page: Page,
input: { title: string; subtitle: string }, input: { title: string; subtitle: string },
): Promise<void> { ): Promise<void> {
const titleLocator = page.getByTestId("workspace-header-title"); const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
const subtitleLocator = page.getByTestId("workspace-header-subtitle"); const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
await expect(titleLocator.first()).toHaveText(input.title, { await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000, timeout: 30_000,
+56 -1
View File
@@ -5,6 +5,7 @@ import path from "node:path";
type TempRepo = { type TempRepo = {
path: string; path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>; cleanup: () => Promise<void>;
}; };
@@ -14,6 +15,7 @@ export const createTempGitRepo = async (
withRemote?: boolean; withRemote?: boolean;
paseoConfig?: Record<string, unknown>; paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>; files?: Array<{ path: string; content: string }>;
branches?: string[];
}, },
): Promise<TempRepo> => { ): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
@@ -47,19 +49,72 @@ export const createTempGitRepo = async (
} }
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" }); execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
const branchHeads: Record<string, string> = {};
const branches = Array.from(new Set(options?.branches ?? []));
for (const branch of branches) {
if (branch !== "main") {
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
cwd: repoPath,
stdio: "ignore",
});
}
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
cwd: repoPath,
stdio: "ignore",
});
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
cwd: repoPath,
stdio: "pipe",
})
.toString()
.trim();
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
}
if (withRemote) { if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e. // Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git"); const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true }); await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" }); execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
} }
return { return {
path: repoPath, path: repoPath,
branchHeads,
cleanup: async () => { cleanup: async () => {
await rm(repoPath, { recursive: true, force: true }); await rm(repoPath, { recursive: true, force: true });
}, },
}; };
}; };
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;
}> {
const currentBranch = execSync("git branch --show-current", {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
return {
currentBranch,
hasAncestor: (ref: string) => {
try {
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
cwd: worktreePath,
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
};
}
+105 -2
View File
@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures"; import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app"; import { gotoAppShell } from "./helpers/app";
@@ -8,9 +10,15 @@ import {
clickNewWorkspaceButton, clickNewWorkspaceButton,
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon, createWorktreeViaDaemon,
expectComposerGithubAttachmentPill,
expectStartingRefPickerTriggerPr,
openNewWorkspaceComposer,
openStartingRefPicker,
openProjectViaDaemon, openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { import {
expectSidebarWorkspaceSelected, expectSidebarWorkspaceSelected,
expectWorkspaceHeader, expectWorkspaceHeader,
@@ -240,7 +248,12 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName, subtitle: openedProject.projectDisplayName,
}); });
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]'); const activeWorkspaceDeckEntry = page
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
.filter({ visible: true });
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 }); await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
// Workspace setup may auto-open a setup tab that steals focus, // Workspace setup may auto-open a setup tab that steals focus,
@@ -254,4 +267,94 @@ test.describe("New workspace flow", () => {
await tempRepo.cleanup(); await tempRepo.cleanup();
} }
}); });
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
branches: ["main", "dev"],
});
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceId,
});
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, 515);
await expectStartingRefPickerTriggerPr(page, {
number: 515,
title: "Review selected start ref",
headRef: "feature/start-from-pr",
});
await expectComposerGithubAttachmentPill(page, {
number: 515,
title: "Review selected start ref",
});
} finally {
await tempRepo.cleanup();
}
});
}); });
+9 -11
View File
@@ -23,6 +23,12 @@ async function openHostPage(page: Page, serverId: string) {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
} }
async function expectHostLabelHeader(page: Page) {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
test.describe("Settings host page", () => { test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({ test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page, page,
@@ -34,16 +40,8 @@ test.describe("Settings host page", () => {
await openSettings(page); await openSettings(page);
await openHostPage(page, serverId); await openHostPage(page, serverId);
// Label renders as a title with a pencil edit affordance; the input is hidden until edit. // Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
await expect(page.getByTestId("host-page-label-card")).toBeVisible(); await expectHostLabelHeader(page);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
await expect(
page.getByTestId("host-page-label-card").getByText(TEST_HOST_LABEL, { exact: true }),
).toBeVisible();
// Desktop detail pane shows a ScreenHeader with the host label.
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
// Connections is its own section with a "Connections" heading and the seeded endpoint row. // Connections is its own section with a "Connections" heading and the seeded endpoint row.
const connectionsCard = page.getByTestId("host-page-connections-card"); const connectionsCard = page.getByTestId("host-page-connections-card");
@@ -124,7 +122,7 @@ test.describe("Settings host page", () => {
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`); await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByTestId("host-page-label-card")).toBeVisible(); await expectHostLabelHeader(page);
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
}); });
+16 -8
View File
@@ -93,6 +93,15 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await expect(page.getByTestId("settings-sidebar")).toBeVisible();
} }
async function expectCompactSettingsRootList(page: Page) {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
test("/settings renders only the sidebar list (no section content)", async ({ page }) => { test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await openCompactSettingsRoot(page); await openCompactSettingsRoot(page);
@@ -110,12 +119,12 @@ test.describe("Settings — compact master-detail", () => {
).toBeVisible(); ).toBeVisible();
// Section detail content is NOT rendered at the root. // Section detail content is NOT rendered at the root.
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0); await expectCompactSettingsRootList(page);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.getByTestId("host-page-label-card")).toHaveCount(0);
// Root shows the menu header, not a back button. const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0); await expect(rootBackButton).toBeVisible();
await rootBackButton.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
}); });
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => { test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
@@ -144,9 +153,8 @@ test.describe("Settings — compact master-detail", () => {
await expect(page).toHaveURL(/\/settings\/about$/); await expect(page).toHaveURL(/\/settings\/about$/);
await page.getByRole("button", { name: "Back", exact: true }).click(); await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/); await expectCompactSettingsRootList(page);
await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
}); });
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => { test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
@@ -96,6 +96,14 @@ test.describe("Workspace navigation regression", () => {
`workspace-tab-agent_${firstAgent.id}`, `workspace-tab-agent_${firstAgent.id}`,
]); ]);
const firstDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
);
const secondDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
);
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
@@ -126,27 +134,45 @@ test.describe("Workspace navigation regression", () => {
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`, `workspace-tab-agent_${secondAgent.id}`,
]); ]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(secondDeckEntry).toBeAttached();
await expect(secondDeckEntry).toBeHidden();
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.reload(); await page.reload();
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000, timeout: 30_000,
}); });
await expectSidebarWorkspaceSelected({ await expectSidebarWorkspaceSelected({
page, page,
serverId, serverId,
workspaceId: secondWorkspace.workspaceId, workspaceId: firstWorkspace.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName, title: firstWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName, subtitle: firstWorkspace.projectDisplayName,
}); });
await expectWorkspaceTabVisible(page, secondAgent.id); await expectWorkspaceTabVisible(page, firstAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id); await expectWorkspaceTabHidden(page, secondAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`, `workspace-tab-agent_${firstAgent.id}`,
]); ]);
} finally { } finally {
for (const agentId of agentIds) { for (const agentId of agentIds) {
@@ -94,7 +94,7 @@ test.describe("Workspace setup streaming", () => {
await navigateToWorkspaceViaSidebar(page, workspace.id); await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click(); await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
+8 -5
View File
@@ -1,10 +1,10 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.59", "version": "0.1.60-beta.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio", "build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps", "eas-build-post-install": "npm run build:workspace-deps",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.59", "@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.59", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.59", "@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -79,6 +79,7 @@
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12", "expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0", "lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7", "mnemonic-id": "^3.2.7",
"react": "19.1.0", "react": "19.1.0",
@@ -102,11 +103,13 @@
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0", "react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@testing-library/react": "^16.3.2",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
+70 -30
View File
@@ -10,7 +10,6 @@ import {
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context"; import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
@@ -63,8 +62,13 @@ import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { resolveActiveHost } from "@/utils/active-host";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { queryClient } from "@/query/query-client"; import { queryClient } from "@/query/query-client";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { import {
WEB_NOTIFICATION_CLICK_EVENT, WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail, type WebNotificationClickDetail,
@@ -82,7 +86,10 @@ import {
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto(); polyfillCrypto();
@@ -387,9 +394,11 @@ function AppContainer({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { settings, updateSettings } = useAppSettings(); const { settings, updateSettings } = useAppSettings();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
@@ -403,6 +412,32 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname();
const activeServerId = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
[daemons, pathname],
);
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState();
toggleDesktopSidebarsWithCheckoutIntent({
isAgentListOpen: desktop.agentListOpen,
isFileExplorerOpen: desktop.fileExplorerOpen,
openAgentList: openDesktopAgentList,
closeAgentList: closeDesktopAgentList,
closeFileExplorer: closeDesktopFileExplorer,
toggleFocusedFileExplorer: () =>
keyboardActionDispatcher.dispatch({
id: "sidebar.toggle.right",
scope: "sidebar",
}),
});
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]);
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
// global concerns like keyboard shortcuts. Split those out so settings (and
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
useEffect(() => { useEffect(() => {
const bp = UnistylesRuntime.breakpoint; const bp = UnistylesRuntime.breakpoint;
@@ -434,15 +469,16 @@ function AppContainer({
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]); }, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: chromeEnabled, enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout, isMobile: isCompactLayout,
toggleAgentList, toggleAgentList,
toggleFileExplorer, toggleBothSidebars: toggleDesktopSidebars,
toggleBothSidebars,
toggleFocusMode, toggleFocusMode,
cycleTheme, cycleTheme,
}); });
useActiveWorktreeNewAction();
const containerStyle = useMemo( const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }), () => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0], [theme.colors.surface0],
@@ -461,6 +497,10 @@ function AppContainer({
<UpdateBanner /> <UpdateBanner />
<CommandCenter /> <CommandCenter />
<ProjectPickerModal /> <ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceSetupDialog /> <WorkspaceSetupDialog />
<KeyboardShortcutsDialog /> <KeyboardShortcutsDialog />
</View> </View>
@@ -481,7 +521,7 @@ function MobileGestureWrapper({
chromeEnabled: boolean; chromeEnabled: boolean;
}) { }) {
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const horizontalScroll = useHorizontalScrollOptional(); const horizontalScroll = useHorizontalScrollOptional();
const { const {
translateX, translateX,
@@ -498,8 +538,8 @@ function MobileGestureWrapper({
const handleGestureOpen = useCallback(() => { const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true; gestureAnimatingRef.current = true;
openAgentList(); showMobileAgentList();
}, [openAgentList, gestureAnimatingRef]); }, [showMobileAgentList, gestureAnimatingRef]);
const openGesture = useMemo( const openGesture = useMemo(
() => () =>
@@ -806,14 +846,14 @@ function RootStack() {
<Stack.Screen name="settings/[section]" /> <Stack.Screen name="settings/[section]" />
<Stack.Screen name="pair-scan" /> <Stack.Screen name="pair-scan" />
</Stack.Protected> </Stack.Protected>
<Stack.Screen {/*
name="h/[serverId]/workspace/[workspaceId]" Do not add getId or dangerouslySingular back to the workspace route.
getId={({ params }) => { Expo Router maps dangerouslySingular to React Navigation getId, and
const serverId = getRouteParamValue(params?.serverId); getId repeatedly breaks Android native-stack/Fabric by reordering an
const workspaceId = getRouteParamValue(params?.workspaceId); already-mounted workspace screen. Keep workspace identity/retention
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined; outside this route-level native-stack API.
}} */}
/> <Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} /> <Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" /> <Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" /> <Stack.Screen name="h/[serverId]/sessions" />
@@ -830,6 +870,7 @@ function NavigationActiveWorkspaceObserver() {
useEffect(() => { useEffect(() => {
syncNavigationActiveWorkspace(navigationRef); syncNavigationActiveWorkspace(navigationRef);
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
const unsubscribeState = navigationRef.addListener("state", () => { const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef); syncNavigationActiveWorkspace(navigationRef);
}); });
@@ -837,6 +878,7 @@ function NavigationActiveWorkspaceObserver() {
syncNavigationActiveWorkspace(navigationRef); syncNavigationActiveWorkspace(navigationRef);
}); });
return () => { return () => {
unsubscribeBrowserLocation();
unsubscribeState(); unsubscribeState();
unsubscribeReady(); unsubscribeReady();
}; };
@@ -855,23 +897,21 @@ export default function RootLayout() {
<SafeAreaProvider> <SafeAreaProvider>
<KeyboardProvider> <KeyboardProvider>
<QueryProvider> <QueryProvider>
<BottomSheetModalProvider> <HostRuntimeBootstrapProvider>
<HostRuntimeBootstrapProvider> <PushNotificationRouter />
<PushNotificationRouter /> <ToastProvider>
<ProvidersWrapper> <ProvidersWrapper>
<SidebarAnimationProvider> <SidebarAnimationProvider>
<HorizontalScrollProvider> <HorizontalScrollProvider>
<ToastProvider> <OpenProjectListener />
<OpenProjectListener /> <AppWithSidebar>
<AppWithSidebar> <RootStack />
<RootStack /> </AppWithSidebar>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider> </HorizontalScrollProvider>
</SidebarAnimationProvider> </SidebarAnimationProvider>
</ProvidersWrapper> </ProvidersWrapper>
</HostRuntimeBootstrapProvider> </ToastProvider>
</BottomSheetModalProvider> </HostRuntimeBootstrapProvider>
</QueryProvider> </QueryProvider>
</KeyboardProvider> </KeyboardProvider>
</SafeAreaProvider> </SafeAreaProvider>
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes"; import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
@@ -32,21 +33,10 @@ function HostAgentReadyRouteContent() {
} }
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null; return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
}); });
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const hasHydratedWorkspaces = useSessionStore((state) => const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
); );
const resolvedWorkspaceId = useSessionStore((state) => { const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
if (!serverId || !agentId) {
return null;
}
return resolveWorkspaceIdByExecutionDirectory({
workspaces: state.sessions[serverId]?.workspaces?.values(),
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
});
});
useEffect(() => { useEffect(() => {
if (redirectedRef.current) { if (redirectedRef.current) {
@@ -102,8 +92,9 @@ function HostAgentReadyRouteContent() {
return; return;
} }
const cwd = result?.agent?.cwd?.trim(); const cwd = result?.agent?.cwd?.trim();
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({ const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: sessionWorkspaces?.values(), workspaces: workspaces?.values(),
workspaceDirectory: cwd, workspaceDirectory: cwd,
}); });
if (!workspaceId && !hasHydratedWorkspaces) { if (!workspaceId && !hasHydratedWorkspaces) {
@@ -133,7 +124,7 @@ function HostAgentReadyRouteContent() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]); }, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
return null; return null;
} }
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import { import {
useGlobalSearchParams, useGlobalSearchParams,
useLocalSearchParams, useLocalSearchParams,
@@ -7,6 +8,11 @@ import {
useRootNavigationState, useRootNavigationState,
} from "expo-router"; } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import {
activateNavigationWorkspaceSelection,
type ActiveWorkspaceSelection,
useNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { import {
@@ -95,6 +101,23 @@ function HostWorkspaceLayoutContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: ""; : "";
const openValue = getParamValue(globalParams.open); const openValue = getParamValue(globalParams.open);
const routeWorkspaceSelection = useMemo(
() =>
serverId && workspaceId
? {
serverId,
workspaceId,
}
: null,
[serverId, workspaceId],
);
useEffect(() => {
if (!routeWorkspaceSelection) {
return;
}
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
}, [routeWorkspaceSelection]);
useEffect(() => { useEffect(() => {
if (!openValue) { if (!openValue) {
@@ -144,11 +167,73 @@ function HostWorkspaceLayoutContent() {
return null; return null;
} }
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
}
function areWorkspaceSelectionsEqual(
left: ActiveWorkspaceSelection | null,
right: ActiveWorkspaceSelection | null,
): boolean {
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
}
function WorkspaceDeck({
fallbackSelection,
}: {
fallbackSelection: ActiveWorkspaceSelection | null;
}) {
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
);
useEffect(() => {
if (!activeSelection) {
return;
}
setMountedSelections((current) => {
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
return current;
}
return [...current, activeSelection];
});
}, [activeSelection]);
if (!activeSelection) {
return null;
}
return ( return (
<WorkspaceScreen <View style={styles.deck}>
key={`${serverId}:${workspaceId}`} {mountedSelections.map((selection) => {
serverId={serverId} const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
workspaceId={workspaceId} return (
/> <View
key={`${selection.serverId}:${selection.workspaceId}`}
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
);
})}
</View>
); );
} }
const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});
@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
@@ -7,7 +7,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import { import {
BottomSheetModal,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetTextInput, BottomSheetTextInput,
@@ -16,6 +15,10 @@ import {
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
type EscHandler = () => void; type EscHandler = () => void;
@@ -109,9 +112,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[4], gap: theme.spacing[4],
flexGrow: 1, flexGrow: 1,
}, },
bottomSheetHandle: {
backgroundColor: theme.colors.surface2,
},
bottomSheetHeader: { bottomSheetHeader: {
paddingHorizontal: theme.spacing[6], paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4], paddingTop: theme.spacing[4],
@@ -166,7 +166,6 @@ export interface AdaptiveModalSheetProps {
children: ReactNode; children: ReactNode;
headerActions?: ReactNode; headerActions?: ReactNode;
snapPoints?: string[]; snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string; testID?: string;
/** Override the max width of the desktop card. */ /** Override the max width of the desktop card. */
desktopMaxWidth?: number; desktopMaxWidth?: number;
@@ -183,7 +182,6 @@ export function AdaptiveModalSheet({
children, children,
headerActions, headerActions,
snapPoints, snapPoints,
stackBehavior,
testID, testID,
desktopMaxWidth, desktopMaxWidth,
onFilesDropped, onFilesDropped,
@@ -191,33 +189,13 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const sheetRef = useRef<BottomSheetModal>(null); const titleColor = theme.colors.foreground;
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
useEffect(() => { visible,
if (!isMobile) return; isEnabled: isMobile,
if (visible) { onClose,
dismissingForVisibilityRef.current = false; });
sheetRef.current?.present();
} else {
dismissingForVisibilityRef.current = true;
sheetRef.current?.dismiss();
}
}, [visible, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
if (dismissingForVisibilityRef.current) {
dismissingForVisibilityRef.current = false;
return;
}
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -233,7 +211,7 @@ export function AdaptiveModalSheet({
if (isMobile) { if (isMobile) {
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={sheetRef} ref={sheetRef}
snapPoints={resolvedSnapPoints} snapPoints={resolvedSnapPoints}
index={0} index={0}
@@ -241,16 +219,15 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground} backgroundComponent={SheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle} handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
accessible={false} accessible={false}
> >
<View style={styles.bottomSheetHeader} testID={testID}> <View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}> <View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}> <Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title} {title}
</Text> </Text>
{subtitle} {subtitle}
@@ -271,7 +248,7 @@ export function AdaptiveModalSheet({
) : ( ) : (
<View style={styles.bottomSheetStaticContent}>{children}</View> <View style={styles.bottomSheetStaticContent}>{children}</View>
)} )}
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -279,7 +256,7 @@ export function AdaptiveModalSheet({
<> <>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerTitleGroup}> <View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}> <Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title} {title}
</Text> </Text>
{subtitle} {subtitle}
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
import type { StyleProp, ViewStyle, TextProps } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
@@ -22,7 +21,6 @@ import {
ShieldAlert, ShieldAlert,
ShieldOff, ShieldOff,
} from "lucide-react-native"; } from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type { import type {
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -31,6 +29,10 @@ import type {
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { baseColors } from "@/styles/theme"; import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
@@ -86,6 +88,8 @@ export function DropdownField({
renderTrigger, renderTrigger,
testID, testID,
}: DropdownFieldProps): ReactElement { }: DropdownFieldProps): ReactElement {
const { theme } = useUnistyles();
if (renderTrigger) { if (renderTrigger) {
return ( return (
<> <>
@@ -115,7 +119,7 @@ export function DropdownField({
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}> <Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
{value || placeholder} {value || placeholder}
</Text> </Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null} {warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
@@ -151,6 +155,8 @@ export function SelectField({
valueEllipsizeMode, valueEllipsizeMode,
testID, testID,
}: SelectFieldProps): ReactElement { }: SelectFieldProps): ReactElement {
const { theme } = useUnistyles();
const getWebKey = useCallback((event: unknown): string | null => { const getWebKey = useCallback((event: unknown): string | null => {
if (!event || typeof event !== "object") return null; if (!event || typeof event !== "object") return null;
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown }; const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
@@ -210,7 +216,7 @@ export function SelectField({
{value || placeholder || "Select..."} {value || placeholder || "Select..."}
</Text> </Text>
</View> </View>
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} /> <ChevronRight size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null} {warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
@@ -229,7 +235,21 @@ interface DropdownSheetProps {
} }
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) { function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />; const { theme } = useUnistyles();
return (
<Animated.View
pointerEvents="none"
style={[
style,
{
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
]}
/>
);
} }
export function DropdownSheet({ export function DropdownSheet({
@@ -238,31 +258,18 @@ export function DropdownSheet({
onClose, onClose,
children, children,
}: DropdownSheetProps): ReactElement { }: DropdownSheetProps): ReactElement {
const bottomSheetRef = useRef<BottomSheetModal>(null); const { theme } = useUnistyles();
const titleColor = theme.colors.foreground;
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
onClose(); onClose();
}, [onClose]); }, [onClose]);
useEffect(() => {
if (visible) {
bottomSheetRef.current?.present();
} else {
bottomSheetRef.current?.dismiss();
}
}, [visible]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} /> <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
@@ -271,8 +278,8 @@ export function DropdownSheet({
); );
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={sheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
@@ -280,12 +287,14 @@ export function DropdownSheet({
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundComponent={DropdownSheetBackground} backgroundComponent={DropdownSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle} handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text> <Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
{title}
</Text>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Close sheet" accessibilityLabel="Close sheet"
@@ -293,7 +302,7 @@ export function DropdownSheet({
hitSlop={10} hitSlop={10}
testID="dropdown-sheet-close" testID="dropdown-sheet-close"
> >
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} /> <X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
<BottomSheetScrollView <BottomSheetScrollView
@@ -303,7 +312,7 @@ export function DropdownSheet({
> >
{children} {children}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -413,6 +422,8 @@ export function FormSelectTrigger({
valueEllipsizeMode, valueEllipsizeMode,
testID, testID,
}: CompactSelectFieldProps): ReactElement { }: CompactSelectFieldProps): ReactElement {
const { theme } = useUnistyles();
const getWebKey = useCallback((event: unknown): string | null => { const getWebKey = useCallback((event: unknown): string | null => {
if (!event || typeof event !== "object") return null; if (!event || typeof event !== "object") return null;
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown }; const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
@@ -469,7 +480,7 @@ export function FormSelectTrigger({
<View style={styles.compactSelectValueContainer}> <View style={styles.compactSelectValueContainer}>
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null} {showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
{isLoading ? ( {isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : ( ) : (
<Text <Text
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder} style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
@@ -480,7 +491,7 @@ export function FormSelectTrigger({
</Text> </Text>
)} )}
</View> </View>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
); );
} }
@@ -518,6 +529,8 @@ export function AgentConfigRow({
onSelectThinkingOption, onSelectThinkingOption,
disabled, disabled,
}: AgentConfigRowProps): ReactElement { }: AgentConfigRowProps): ReactElement {
const { theme } = useUnistyles();
const providerOptions: ComboSelectOption[] = useMemo( const providerOptions: ComboSelectOption[] = useMemo(
() => () =>
providerDefinitions.map((def) => ({ providerDefinitions.map((def) => ({
@@ -576,7 +589,7 @@ export function AgentConfigRow({
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"} placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
disabled={disabled || providerOptions.length === 0} disabled={disabled || providerOptions.length === 0}
onSelect={onSelectProvider} onSelect={onSelectProvider}
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />} icon={<Bot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
testID="draft-provider-select" testID="draft-provider-select"
/> />
@@ -591,9 +604,7 @@ export function AgentConfigRow({
disabled={disabled} disabled={disabled}
isLoading={isModelLoading} isLoading={isModelLoading}
onSelect={onSelectModel} onSelect={onSelectModel}
icon={ icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false} showLabel={false}
testID="draft-model-select" testID="draft-model-select"
/> />
@@ -607,7 +618,7 @@ export function AgentConfigRow({
placeholder="Default" placeholder="Default"
disabled={disabled || modeOptions.length === 0} disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode} onSelect={onSelectMode}
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />} icon={<ModeIcon size={theme.iconSize.md} color={modeIconColor} />}
showLabel={false} showLabel={false}
testID="draft-mode-select" testID="draft-mode-select"
/> />
@@ -622,9 +633,7 @@ export function AgentConfigRow({
placeholder="Select..." placeholder="Select..."
disabled={disabled} disabled={disabled}
onSelect={onSelectThinkingOption} onSelect={onSelectThinkingOption}
icon={ icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false} showLabel={false}
/> />
</View> </View>
@@ -969,6 +978,8 @@ export function GitOptionsSection({
attachWorktreeError, attachWorktreeError,
onSelectWorktreePath, onSelectWorktreePath,
}: GitOptionsSectionProps): ReactElement { }: GitOptionsSectionProps): ReactElement {
const { theme } = useUnistyles();
const isLoading = status === "loading"; const isLoading = status === "loading";
const isCreateMode = worktreeMode === "create"; const isCreateMode = worktreeMode === "create";
const isAttachMode = worktreeMode === "attach"; const isAttachMode = worktreeMode === "attach";
@@ -1103,7 +1114,7 @@ export function GitOptionsSection({
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
placeholder="branch name" placeholder="branch name"
placeholderTextColor={defaultTheme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit} onSubmitEditing={handleConfirmEdit}
/> />
<Pressable <Pressable
@@ -1111,19 +1122,16 @@ export function GitOptionsSection({
hitSlop={8} hitSlop={8}
style={styles.baseBranchIconButton} style={styles.baseBranchIconButton}
> >
<Check <Check size={theme.iconSize.md} color={theme.colors.palette.green[500]} />
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
</Pressable> </Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}> <Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} /> <X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}> <Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
<Text style={styles.baseBranchValue}>{displayBranch}</Text> <Text style={styles.baseBranchValue}>{displayBranch}</Text>
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} /> <Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
)} )}
</View> </View>
@@ -1171,14 +1179,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
bottomSheetBackground: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
},
bottomSheetHeader: { bottomSheetHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -1220,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
dropdownSheetTitle: { dropdownSheetTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center", textAlign: "center",
}, },
dropdownSheetScrollContent: { dropdownSheetScrollContent: {
@@ -59,6 +59,51 @@ describe("submitAgentInput", () => {
expect(clearDraft).toHaveBeenCalledWith("sent"); expect(clearDraft).toHaveBeenCalledWith("sent");
}); });
it("preserves the composer before an in-flight submit resolves when requested", async () => {
const deferred = createDeferredPromise<void>();
const attachments = [{ id: "img-1" }];
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
await deferred.promise;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " keep me ",
attachments,
submitBehavior: "preserve-and-lock",
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setAttachments,
setSendError,
setIsProcessing,
});
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "keep me",
attachments,
});
expect(setUserInput).not.toHaveBeenCalled();
expect(setAttachments).not.toHaveBeenCalled();
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
deferred.resolve();
await expect(submitPromise).resolves.toBe("submitted");
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("queues while the agent is running and clears the composer immediately", async () => { it("queues while the agent is running and clears the composer immediately", async () => {
const queueMessage = vi.fn(); const queueMessage = vi.fn();
const submitMessage = vi.fn(); const submitMessage = vi.fn();
@@ -5,6 +5,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
attachments: TAttachment[]; attachments: TAttachment[];
hasExternalContent?: boolean; hasExternalContent?: boolean;
allowEmptySubmit?: boolean; allowEmptySubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
forceSend?: boolean; forceSend?: boolean;
isAgentRunning: boolean; isAgentRunning: boolean;
canSubmit: boolean; canSubmit: boolean;
@@ -23,6 +24,7 @@ export async function submitAgentInput<TAttachment>(
): Promise<AgentInputSubmitResult> { ): Promise<AgentInputSubmitResult> {
const trimmedMessage = input.message.trim(); const trimmedMessage = input.message.trim();
const attachments = input.attachments; const attachments = input.attachments;
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
if ( if (
!trimmedMessage && !trimmedMessage &&
@@ -39,14 +41,18 @@ export async function submitAgentInput<TAttachment>(
if (input.isAgentRunning && !input.forceSend) { if (input.isAgentRunning && !input.forceSend) {
input.queueMessage({ message: trimmedMessage, attachments }); input.queueMessage({ message: trimmedMessage, attachments });
input.setUserInput(""); if (shouldClearOnSubmit) {
input.setAttachments([]); input.setUserInput("");
input.setAttachments([]);
}
return "queued"; return "queued";
} }
// Clear immediately so optimistic stream updates and composer state stay in sync. // Clear immediately so optimistic stream updates and composer state stay in sync.
input.setUserInput(""); if (shouldClearOnSubmit) {
input.setAttachments([]); input.setUserInput("");
input.setAttachments([]);
}
input.setSendError(null); input.setSendError(null);
input.setIsProcessing(true); input.setIsProcessing(true);
@@ -57,8 +63,10 @@ export async function submitAgentInput<TAttachment>(
return "submitted"; return "submitted";
} catch (error) { } catch (error) {
input.onSubmitError?.(error); input.onSubmitError?.(error);
input.setUserInput(trimmedMessage); if (shouldClearOnSubmit) {
input.setAttachments(attachments); input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
}
input.setSendError(error instanceof Error ? error.message : "Failed to send message"); input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setIsProcessing(false); input.setIsProcessing(false);
return "failed"; return "failed";
@@ -52,6 +52,8 @@ import {
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform"; import { isWeb as platformIsWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
type StatusOption = { type StatusOption = {
id: string; id: string;
@@ -90,7 +92,7 @@ type ControlledAgentStatusBarProps = {
export interface DraftAgentStatusBarProps { export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[]; providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider; selectedProvider: AgentProvider | null;
onSelectProvider: (provider: AgentProvider) => void; onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[]; modeOptions: AgentMode[];
selectedMode: string; selectedMode: string;
@@ -253,7 +255,8 @@ function ControlledStatusBar({
: undefined; : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette); const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider); const hasSelectedProvider = provider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
const hasAnyControl = const hasAnyControl =
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
@@ -424,12 +427,7 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? ( {thinkingOptions && thinkingOptions.length > 0 ? (
<> <>
<Tooltip <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref"> <TooltipTrigger asChild triggerRefProp="ref">
<Pressable <Pressable
ref={thinkingAnchorRef} ref={thinkingAnchorRef}
@@ -470,12 +468,7 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? ( {modeOptions && modeOptions.length > 0 ? (
<> <>
<Tooltip <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref"> <TooltipTrigger asChild triggerRefProp="ref">
<Pressable <Pressable
ref={modeAnchorRef} ref={modeAnchorRef}
@@ -628,7 +621,9 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences" accessibilityLabel="Agent preferences"
testID="agent-preferences-button" testID="agent-preferences-button"
> >
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.prefsButtonText} numberOfLines={1}> <Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel} {displayModel}
</Text> </Text>
@@ -638,7 +633,6 @@ function ControlledStatusBar({
title="Preferences" title="Preferences"
visible={prefsOpen} visible={prefsOpen}
onClose={() => setPrefsOpen(false)} onClose={() => setPrefsOpen(false)}
stackBehavior="replace"
testID="agent-preferences-sheet" testID="agent-preferences-sheet"
> >
{canSelectModel ? ( {canSelectModel ? (
@@ -671,7 +665,12 @@ function ControlledStatusBar({
pointerEvents="none" pointerEvents="none"
testID="agent-preferences-model" testID="agent-preferences-model"
> >
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text> <Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View> </View>
@@ -873,23 +872,23 @@ export const AgentStatusBar = memo(function AgentStatusBar({
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
); );
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast();
const { const {
entries: snapshotEntries, entries: snapshotEntries,
isLoading: snapshotIsLoading, isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching, refetchIfStale: refetchSnapshotIfStale,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(serverId, agent?.cwd); } = useProvidersSnapshot(serverId, agent?.cwd);
const snapshotModels = useMemo(() => { const snapshotSelectedEntry = useMemo(() => {
if (!snapshotEntries || !agent?.provider) { if (!snapshotEntries || !agent?.provider) {
return null; return null;
} }
const entry = snapshotEntries.find((e) => e.provider === agent.provider); return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
return entry?.models ?? null;
}, [snapshotEntries, agent?.provider]); }, [snapshotEntries, agent?.provider]);
const models = snapshotModels; const models = snapshotSelectedEntry?.models ?? null;
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
const agentProviderDefinitions = useMemo(() => { const agentProviderDefinitions = useMemo(() => {
const definition = agent?.provider const definition = agent?.provider
@@ -900,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const agentProviderModels = useMemo(() => { const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && snapshotModels) { if (agent?.provider && models) {
map.set(agent.provider, snapshotModels); map.set(agent.provider, models);
} }
return map; return map;
}, [agent?.provider, snapshotModels]); }, [agent?.provider, models]);
const displayMode = const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -964,6 +963,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
} }
void client.setAgentMode(agentId, modeId).catch((error) => { void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentStatusBar] setAgentMode failed", error); console.warn("[AgentStatusBar] setAgentMode failed", error);
toast.error(toErrorMessage(error));
}); });
}} }}
modelOptions={modelOptions} modelOptions={modelOptions}
@@ -985,6 +985,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
}); });
void client.setAgentModel(agentId, modelId).catch((error) => { void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error); console.warn("[AgentStatusBar] setAgentModel failed", error);
toast.error(toErrorMessage(error));
}); });
}} }}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
@@ -1020,6 +1021,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
} }
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
}); });
}} }}
features={agent.features} features={agent.features}
@@ -1042,10 +1044,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
}); });
void client.setAgentFeature(agentId, featureId, value).catch((error) => { void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error); console.warn("[AgentStatusBar] setAgentFeature failed", error);
toast.error(toErrorMessage(error));
}); });
}} }}
isModelLoading={snapshotIsLoading || snapshotIsFetching} isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={invalidateSnapshot} onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
disabled={!client} disabled={!client}
/> />
@@ -1101,6 +1104,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ""; const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
const hasSelectedProvider = selectedProvider !== null;
if (platformIsWeb) { if (platformIsWeb) {
return ( return (
@@ -1108,7 +1112,7 @@ export function DraftAgentStatusBar({
<CombinedModelSelector <CombinedModelSelector
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels} allProviderModels={allProviderModels}
selectedProvider={selectedProvider} selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel} selectedModel={selectedModel}
onSelect={onSelectProviderAndModel} onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
@@ -1124,20 +1128,22 @@ export function DraftAgentStatusBar({
onOpen={onModelSelectorOpen} onOpen={onModelSelectorOpen}
onClose={onDropdownClose} onClose={onDropdownClose}
/> />
<ControlledStatusBar {selectedProvider ? (
provider={selectedProvider} <ControlledStatusBar
providerDefinitions={providerDefinitions} provider={selectedProvider}
modeOptions={mappedModeOptions} providerDefinitions={providerDefinitions}
selectedModeId={effectiveSelectedMode} modeOptions={mappedModeOptions}
onSelectMode={onSelectMode} selectedModeId={effectiveSelectedMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} onSelectMode={onSelectMode}
selectedThinkingOptionId={effectiveSelectedThinkingOption} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
onSelectThinkingOption={onSelectThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
features={features} onSelectThinkingOption={onSelectThinkingOption}
onSetFeature={onSetFeature} features={features}
onDropdownClose={onDropdownClose} onSetFeature={onSetFeature}
disabled={disabled} onDropdownClose={onDropdownClose}
/> disabled={disabled}
/>
) : null}
</View> </View>
); );
} }
@@ -1150,10 +1156,10 @@ export function DraftAgentStatusBar({
return ( return (
<> <>
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider ?? ""}
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels} allProviderModels={allProviderModels}
modeOptions={mappedModeOptions} modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
selectedModeId={effectiveSelectedMode} selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode} onSelectMode={onSelectMode}
modelOptions={modelOptions} modelOptions={modelOptions}
@@ -125,7 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>( const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(), new Set(),
); );
const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided) // Get serverId (fallback to agent's serverId if not provided)
@@ -199,17 +199,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false, setCurrentPath: false,
}); });
setExplorerTabForCheckout({ const checkout = {
serverId: resolvedServerId, serverId: resolvedServerId,
cwd: agent.cwd, cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true, isGit: agent.projectPlacement?.checkout?.isGit ?? true,
tab: "files", };
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
isCompact: isMobile,
checkout,
}); });
openFileExplorer();
}, },
[ [
agent.cwd, agent.cwd,
openFileExplorer, isMobile,
openFileExplorerForCheckout,
requestDirectoryListing, requestDirectoryListing,
resolvedServerId, resolvedServerId,
router, router,
@@ -10,6 +10,7 @@ interface AttachmentPillProps {
onRemove: () => void; onRemove: () => void;
openAccessibilityLabel: string; openAccessibilityLabel: string;
removeAccessibilityLabel: string; removeAccessibilityLabel: string;
disabled?: boolean;
testID?: string; testID?: string;
children: ReactNode; children: ReactNode;
} }
@@ -19,6 +20,7 @@ export function AttachmentPill({
onRemove, onRemove,
openAccessibilityLabel, openAccessibilityLabel,
removeAccessibilityLabel, removeAccessibilityLabel,
disabled = false,
testID, testID,
children, children,
}: AttachmentPillProps) { }: AttachmentPillProps) {
@@ -33,6 +35,7 @@ export function AttachmentPill({
<Pressable <Pressable
testID={testID} testID={testID}
onPress={onOpen} onPress={onOpen}
disabled={disabled}
onHoverIn={() => setIsBodyHovered(true)} onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)} onHoverOut={() => setIsBodyHovered(false)}
accessibilityRole="button" accessibilityRole="button"
@@ -43,6 +46,7 @@ export function AttachmentPill({
</Pressable> </Pressable>
<Pressable <Pressable
onPress={onRemove} onPress={onRemove}
disabled={disabled}
onHoverIn={() => setIsCloseHovered(true)} onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)} onHoverOut={() => setIsCloseHovered(false)}
hitSlop={8} hitSlop={8}
@@ -45,10 +45,10 @@ export function BranchSwitcher({
}); });
const titleContent = ( const titleContent = (
<> <View style={styles.titleRow}>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null} {isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle> <ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
</> </View>
); );
if (!currentBranchName) { if (!currentBranchName) {
@@ -105,6 +105,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
minWidth: 0,
marginLeft: { marginLeft: {
xs: -theme.spacing[2], xs: -theme.spacing[2],
md: 0, md: 0,
@@ -116,9 +117,15 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
flexShrink: 1, flexShrink: 1,
minWidth: 0,
}, },
branchSwitcherTriggerHovered: { branchSwitcherTriggerHovered: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
overflow: "hidden",
},
})); }));
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
[onSelect], [onSelect],
); );
const ProviderIcon = getProviderIcon(selectedProvider); const hasSelectedProvider = selectedProvider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => { const selectedModelLabel = useMemo(() => {
if (!selectedModel) { if (!selectedModel) {
if (!hasSelectedProvider) {
return "Select model";
}
return isLoading ? "Loading..." : "Select model"; return isLoading ? "Loading..." : "Select model";
} }
const models = allProviderModels.get(selectedProvider); const models = allProviderModels.get(selectedProvider);
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
} }
const model = models.find((entry) => entry.id === selectedModel); const model = models.find((entry) => entry.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models); return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]); }, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => { const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") { if (view.kind !== "provider") {
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
}) })
) : ( ) : (
<> <>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail"> <Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel} {triggerLabel}
</Text> </Text>
@@ -657,7 +663,6 @@ export function CombinedModelSelector({
onSelect={() => {}} onSelect={() => {}}
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef} anchorRef={anchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
desktopMinWidth={360} desktopMinWidth={360}
@@ -0,0 +1 @@
export * from "./composer-height-mirror.native";
@@ -0,0 +1,13 @@
import type { RefObject } from "react";
interface Args {
value: string;
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
onHeight: (height: number) => void;
}
export function useComposerHeightMirror(_args: Args): void {
// No-op on native: onContentSizeChange drives height natively.
}
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import type { RefObject } from "react";
interface Args {
value: string;
textareaRef: RefObject<HTMLElement | null>;
minHeight: number;
maxHeight: number;
onHeight: (height: number) => void;
}
const COPIED_STYLES = [
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"fontVariant",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textTransform",
"textIndent",
"whiteSpace",
"wordWrap",
"overflowWrap",
"wordBreak",
"tabSize",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
] as const;
export function useComposerHeightMirror({
value,
textareaRef,
minHeight,
maxHeight,
onHeight,
}: Args): void {
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
paramsRef.current = { value, minHeight, maxHeight, onHeight };
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
if (typeof document === "undefined") return;
const mirror = document.createElement("textarea");
mirror.setAttribute("aria-hidden", "true");
mirror.setAttribute("tabindex", "-1");
mirror.readOnly = true;
mirror.rows = 1;
const style = mirror.style;
style.position = "absolute";
style.top = "0";
style.left = "0";
style.visibility = "hidden";
style.pointerEvents = "none";
style.overflow = "hidden";
style.border = "0";
style.margin = "0";
style.resize = "none";
style.zIndex = "-1";
style.boxSizing = "border-box";
document.body.appendChild(mirror);
mirrorRef.current = mirror;
return () => {
mirror.remove();
mirrorRef.current = null;
};
}, []);
const measure = useCallback(() => {
const mirror = mirrorRef.current;
const source = textareaRef.current;
if (!mirror || !source || typeof window === "undefined") return;
if (!(source instanceof HTMLElement)) return;
const cs = window.getComputedStyle(source);
const ms = mirror.style;
for (const prop of COPIED_STYLES) {
ms[prop] = cs[prop];
}
ms.width = `${source.clientWidth}px`;
const { value, minHeight, maxHeight, onHeight } = paramsRef.current;
// Trailing newline is collapsed by textarea measurement — pad with a space.
mirror.value = value.endsWith("\n") ? `${value} ` : value;
const next = Math.max(minHeight, Math.min(maxHeight, mirror.scrollHeight));
onHeight(next);
}, [textareaRef]);
useLayoutEffect(() => {
measure();
}, [value, measure]);
useEffect(() => {
const source = textareaRef.current;
if (!source || !(source instanceof HTMLElement)) return;
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measure());
observer.observe(source);
return () => observer.disconnect();
}, [textareaRef, measure]);
}
+135 -3
View File
@@ -20,10 +20,12 @@ const {
deleteAttachmentsMock, deleteAttachmentsMock,
encodeImagesMock, encodeImagesMock,
openExternalUrlMock, openExternalUrlMock,
markScrollInvestigationRenderMock,
mockSessionState, mockSessionState,
setAgentStreamTailMock, setAgentStreamTailMock,
setAgentStreamHeadMock, setAgentStreamHeadMock,
setQueuedMessagesMock, setQueuedMessagesMock,
agentDirectoryStatusMock,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const theme = { const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
@@ -102,6 +104,17 @@ const {
string, string,
{ {
agents: Map<string, { status: string; lastUsage: null }>; agents: Map<string, { status: string; lastUsage: null }>;
serverInfo: {
serverId: string;
hostname: string | null;
version: string | null;
capabilities?: {
voice?: {
dictation: { enabled: boolean; reason: string };
voice: { enabled: boolean; reason: string };
};
};
} | null;
queuedMessages: Map<string, unknown[]>; queuedMessages: Map<string, unknown[]>;
agentStreamHead: Map<string, unknown[]>; agentStreamHead: Map<string, unknown[]>;
agentStreamTail: Map<string, unknown[]>; agentStreamTail: Map<string, unknown[]>;
@@ -114,6 +127,17 @@ const {
sessions: { sessions: {
server: { server: {
agents: new Map([["agent", { status: "idle", lastUsage: null }]]), agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
serverInfo: {
serverId: "server",
hostname: "test",
version: "0.0.0",
capabilities: {
voice: {
dictation: { enabled: true, reason: "" },
voice: { enabled: true, reason: "" },
},
},
},
queuedMessages: new Map(), queuedMessages: new Map(),
agentStreamHead: new Map(), agentStreamHead: new Map(),
agentStreamTail: new Map(), agentStreamTail: new Map(),
@@ -135,6 +159,8 @@ const {
); );
mockSessionState.setAgentStreamTail = setAgentStreamTailMock; mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock; mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
const markScrollInvestigationRenderMock = vi.fn();
const agentDirectoryStatusMock = vi.fn(() => "ready");
return { return {
theme, theme,
@@ -147,10 +173,12 @@ const {
deleteAttachmentsMock: vi.fn(async () => {}), deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images), encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}), openExternalUrlMock: vi.fn(async () => {}),
markScrollInvestigationRenderMock,
mockSessionState, mockSessionState,
setAgentStreamTailMock, setAgentStreamTailMock,
setAgentStreamHeadMock, setAgentStreamHeadMock,
setQueuedMessagesMock, setQueuedMessagesMock,
agentDirectoryStatusMock,
}; };
}); });
@@ -177,6 +205,7 @@ vi.mock("lucide-react-native", () => {
React.createElement("span", { ...props, "data-icon": name }); React.createElement("span", { ...props, "data-icon": name });
return { return {
ArrowUp: createIcon("ArrowUp"), ArrowUp: createIcon("ArrowUp"),
CornerDownLeft: createIcon("CornerDownLeft"),
Square: createIcon("Square"), Square: createIcon("Square"),
Pencil: createIcon("Pencil"), Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"), AudioLines: createIcon("AudioLines"),
@@ -216,7 +245,7 @@ vi.mock("react-native-safe-area-context", () => ({
vi.mock("@/runtime/host-runtime", () => ({ vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient, useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true, useHostRuntimeIsConnected: () => true,
useHostRuntimeAgentDirectoryStatus: () => "ready", useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(),
})); }));
vi.mock("@/stores/session-store", () => { vi.mock("@/stores/session-store", () => {
@@ -312,7 +341,7 @@ vi.mock("@/contexts/toast-context", () => ({
})); }));
vi.mock("@/utils/scroll-jank-investigation", () => ({ vi.mock("@/utils/scroll-jank-investigation", () => ({
markScrollInvestigationRender: vi.fn(), markScrollInvestigationRender: markScrollInvestigationRenderMock,
markScrollInvestigationEvent: vi.fn(), markScrollInvestigationEvent: vi.fn(),
})); }));
@@ -359,6 +388,20 @@ vi.mock("@/hooks/use-dictation", () => ({
})); }));
vi.mock("@/utils/server-info-capabilities", () => ({ vi.mock("@/utils/server-info-capabilities", () => ({
getVoiceReadinessState: ({
serverInfo,
mode,
}: {
serverInfo: {
capabilities?: {
voice?: {
dictation?: { enabled: boolean; reason: string };
voice?: { enabled: boolean; reason: string };
};
};
} | null;
mode: "dictation" | "voice";
}) => serverInfo?.capabilities?.voice?.[mode] ?? null,
resolveVoiceUnavailableMessage: () => null, resolveVoiceUnavailableMessage: () => null,
})); }));
@@ -546,9 +589,23 @@ beforeEach(() => {
deleteAttachmentsMock.mockClear(); deleteAttachmentsMock.mockClear();
encodeImagesMock.mockClear(); encodeImagesMock.mockClear();
openExternalUrlMock.mockClear(); openExternalUrlMock.mockClear();
markScrollInvestigationRenderMock.mockClear();
setAgentStreamTailMock.mockClear(); setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear(); setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear(); setQueuedMessagesMock.mockClear();
agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready");
mockSessionState.sessions.server.serverInfo = {
serverId: "server",
hostname: "test",
version: "0.0.0",
capabilities: {
voice: {
dictation: { enabled: true, reason: "" },
voice: { enabled: true, reason: "" },
},
},
};
mockSessionState.sessions.server.agentStreamHead = new Map(); mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map(); mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map(); mockSessionState.sessions.server.queuedMessages = new Map();
@@ -579,9 +636,13 @@ function imageAttachment(id: string): AttachmentMetadata {
function ComposerHarness({ function ComposerHarness({
initialText = "", initialText = "",
initialAttachments = [], initialAttachments = [],
isSubmitLoading = false,
submitBehavior,
}: { }: {
initialText?: string; initialText?: string;
initialAttachments?: ComposerAttachment[]; initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) { }) {
const [text, setText] = useState(initialText); const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments); const [attachments, setAttachments] = useState(initialAttachments);
@@ -603,6 +664,8 @@ function ComposerHarness({
return next; return next;
}); });
}} }}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
cwd="/repo" cwd="/repo"
clearDraft={vi.fn()} clearDraft={vi.fn()}
/> />
@@ -611,7 +674,12 @@ function ComposerHarness({
} }
function renderComposer( function renderComposer(
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {}, input: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
) { ) {
act(() => { act(() => {
root?.render(<ComposerHarness {...input} />); root?.render(<ComposerHarness {...input} />);
@@ -651,6 +719,12 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'); return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
} }
function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent",
).length;
}
describe("Composer attachments", () => { describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => { it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer(); renderComposer();
@@ -835,6 +909,64 @@ describe("Composer attachments", () => {
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]); expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
}); });
it("does not re-render MessageInput when opening the attachment lightbox", () => {
const image = imageAttachment("img-lightbox-render");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
const renderCountBeforeLightbox = countMessageInputRenders();
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox);
});
it("still re-renders MessageInput when submit loading semantics change", () => {
renderComposer({ initialText: "pending submit", isSubmitLoading: false });
const renderCountBeforeLoading = countMessageInputRenders();
renderComposer({ initialText: "pending submit", isSubmitLoading: true });
expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1);
expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true);
});
it("enables dictation from server capabilities before the agent directory finishes loading", () => {
agentDirectoryStatusMock.mockReturnValue("initial_loading");
renderComposer();
expect(document.querySelector('[aria-label="Start dictation"]')).toHaveProperty(
"disabled",
false,
);
});
it("locks the preserved draft while submit loading", () => {
renderComposer({
initialText: "keep this prompt",
initialAttachments: [{ kind: "github_pr", item: prItem }],
isSubmitLoading: true,
submitBehavior: "preserve-and-lock",
});
const textInput = document.querySelector('[aria-label="Message agent..."]');
const attachButton = queryByTestId("message-input-attach-button");
const pill = queryByTestId("composer-github-attachment-pill");
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(textInput).toHaveProperty("readOnly", true);
expect(textInput).toHaveProperty("value", "keep this prompt");
expect(attachButton).toHaveProperty("disabled", true);
expect(pill).not.toBeNull();
expect(removeButton).not.toBeNull();
click(pill!);
click(removeButton!);
expect(openExternalUrlMock).not.toHaveBeenCalled();
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("closes the image lightbox when its close button is pressed", () => { it("closes the image lightbox when its close button is pressed", () => {
const image = imageAttachment("img-close"); const image = imageAttachment("img-close");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] }); renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
+250 -185
View File
@@ -1,5 +1,5 @@
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native"; import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -14,7 +14,6 @@ import {
Paperclip, Paperclip,
} from "lucide-react-native"; } from "lucide-react-native";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream"; import { generateMessageId, type StreamItem } from "@/types/stream";
@@ -33,7 +32,7 @@ import {
type MessageInputRef, type MessageInputRef,
type AttachmentMenuItem, type AttachmentMenuItem,
} from "./message-input"; } from "./message-input";
import { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
@@ -70,6 +69,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -100,8 +100,10 @@ interface ComposerProps {
allowEmptySubmit?: boolean; allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */ /** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string; submitButtonAccessibilityLabel?: string;
submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */ /** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean; blurOnSubmit?: boolean;
value: string; value: string;
@@ -132,6 +134,7 @@ interface ComposerProps {
const EMPTY_ARRAY: readonly QueuedMessage[] = []; const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills"; const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
const StableMessageInput = memo(MessageInput);
export function Composer({ export function Composer({
agentId, agentId,
@@ -141,7 +144,9 @@ export function Composer({
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false, allowEmptySubmit = false,
submitButtonAccessibilityLabel, submitButtonAccessibilityLabel,
submitIcon = "arrow",
isSubmitLoading = false, isSubmitLoading = false,
submitBehavior = "clear",
blurOnSubmit = false, blurOnSubmit = false,
value, value,
onChangeText, onChangeText,
@@ -163,19 +168,20 @@ export function Composer({
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
const toast = useToast(); const toast = useToast();
const toastErrorRef = useRef(toast.error);
toastErrorRef.current = toast.error;
const voice = useVoiceOptional(); const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle"); const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel"); const dictationCancelKeys = useShortcutKeys("dictation-cancel");
const isDictationReady = const isDictationReady = useIsDictationReady({
isConnected && serverId,
(agentDirectoryStatus === "ready" || isConnected,
agentDirectoryStatus === "revalidating" || agentDirectoryStatus,
agentDirectoryStatus === "error_after_ready"); });
const { settings: appSettings } = useAppSettings(); const { settings: appSettings } = useAppSettings();
@@ -218,6 +224,7 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null); const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null); const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null); const messageInputRef = useRef<MessageInputRef>(null);
const isComposerLocked = submitBehavior === "preserve-and-lock" && isSubmitLoading;
const keyboardHandlerIdRef = useRef( const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
); );
@@ -233,6 +240,8 @@ export function Composer({
messageInputRef.current?.focus(); messageInputRef.current?.focus();
}, },
}); });
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
// Clear send error when user edits the input // Clear send error when user edits the input
useEffect(() => { useEffect(() => {
@@ -370,66 +379,82 @@ export function Composer({
[agentId, serverId, setQueuedMessages], [agentId, serverId, setQueuedMessages],
); );
function queueMessage(message: string, attachments: ComposerAttachment[]) { const queueMessage = useCallback(
const trimmedMessage = message.trim(); (message: string, attachments: ComposerAttachment[]) => {
if (!trimmedMessage && attachments.length === 0) return; const trimmedMessage = message.trim();
if (!trimmedMessage && attachments.length === 0) return;
const newItem = { const newItem = {
id: generateMessageId(), id: generateMessageId(),
text: trimmedMessage, text: trimmedMessage,
attachments, attachments,
}; };
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => { setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev); const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
return next; return next;
}); });
setUserInput(""); setUserInput("");
setSelectedAttachments([]); setSelectedAttachments([]);
} },
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
);
async function sendMessageWithContent( const sendMessageWithContent = useCallback(
message: string, async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => {
attachments: ComposerAttachment[], await submitAgentInput({
forceSend?: boolean, message,
) { attachments,
await submitAgentInput({ hasExternalContent,
message, allowEmptySubmit,
attachments, forceSend,
hasExternalContent, submitBehavior,
isAgentRunning,
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
queueMessage: ({ message, attachments }) => {
queueMessage(message, attachments);
},
submitMessage: async ({ message, attachments }) => {
await submitMessage(message, attachments);
},
clearDraft,
setUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(nextAttachments);
},
setSendError,
setIsProcessing,
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
});
},
[
allowEmptySubmit, allowEmptySubmit,
forceSend,
isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
queueMessage: ({ message, attachments }) => {
queueMessage(message, attachments);
},
submitMessage: async ({ message, attachments }) => {
await submitMessage(message, attachments);
},
clearDraft, clearDraft,
hasExternalContent,
isAgentRunning,
queueMessage,
setSelectedAttachments,
setUserInput, setUserInput,
setAttachments: (nextAttachments) => { submitBehavior,
setSelectedAttachments(nextAttachments); submitMessage,
}, ],
setSendError, );
setIsProcessing,
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
});
}
function handleSubmit(payload: MessagePayload) { const handleSubmit = useCallback(
if (blurOnSubmit) { (payload: MessagePayload) => {
messageInputRef.current?.blur(); if (blurOnSubmit) {
} messageInputRef.current?.blur();
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend); }
} void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
},
[blurOnSubmit, sendMessageWithContent],
);
const handlePickImage = useCallback(async () => { const handlePickImage = useCallback(async () => {
const result = await pickImages(); const result = await pickImages();
@@ -481,6 +506,18 @@ export function Composer({
} }
}, [isAgentRunning, isConnected]); }, [isAgentRunning, isConnected]);
const handleCancelAgent = useCallback(() => {
if (!isAgentRunning || isCancellingAgent) {
return;
}
if (!isConnected || !client) {
return;
}
setIsCancellingAgent(true);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
const handleKeyboardAction = useCallback( const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
if (!isPaneFocused) { if (!isPaneFocused) {
@@ -547,18 +584,6 @@ export function Composer({
mode: "translate", mode: "translate",
}); });
function handleCancelAgent() {
if (!isAgentRunning || isCancellingAgent) {
return;
}
if (!isConnected || !client) {
return;
}
setIsCancellingAgent(true);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false; const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
const handleToggleRealtimeVoice = useCallback(() => { const handleToggleRealtimeVoice = useCallback(() => {
@@ -576,10 +601,10 @@ export function Composer({
const message = const message =
error instanceof Error ? error.message : typeof error === "string" ? error : null; error instanceof Error ? error.message : typeof error === "string" ? error : null;
if (message && message.trim().length > 0) { if (message && message.trim().length > 0) {
toast.error(message); toastErrorRef.current(message);
} }
}); });
}, [agentId, hasAgent, isConnected, serverId, toast, voice]); }, [agentId, hasAgent, isConnected, serverId, voice]);
function handleEditQueuedMessage(id: string) { function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id); const item = queuedMessages.find((q) => q.id === id);
@@ -606,9 +631,12 @@ export function Composer({
} }
} }
const handleQueue = useCallback((payload: MessagePayload) => { const handleQueue = useCallback(
queueMessage(payload.text, payload.attachments); (payload: MessagePayload) => {
}, []); queueMessage(payload.text, payload.attachments);
},
[queueMessage],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
@@ -627,91 +655,109 @@ export function Composer({
return true; return true;
} }
return autocomplete.onKeyPress(event); return autocompleteOnKeyPressRef.current(event);
}, },
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent],
);
const cancelButton = useMemo(
() =>
isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null,
[ [
autocomplete, buttonIconSize,
dictationCancelKeys,
handleCancelAgent,
hasSendableContent, hasSendableContent,
isAgentRunning, isAgentRunning,
isCancellingAgent, isCancellingAgent,
isConnected, isConnected,
handleCancelAgent, isProcessing,
], ],
); );
const cancelButton =
isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent; const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const rightContent = const rightContent = useMemo(
showVoiceModeButton || cancelButton ? ( () =>
<View style={styles.rightControls}> showVoiceModeButton || cancelButton ? (
{showVoiceModeButton ? ( <View style={styles.rightControls}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> {showVoiceModeButton ? (
<TooltipTrigger <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
onPress={handleToggleRealtimeVoice} <TooltipTrigger
disabled={!isConnected || voice?.isVoiceSwitching} onPress={handleToggleRealtimeVoice}
accessibilityLabel="Enable Voice mode" disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityRole="button" accessibilityLabel="Enable Voice mode"
style={({ hovered }) => [ accessibilityRole="button"
styles.realtimeVoiceButton as any, style={({ hovered }) => [
(hovered ? styles.iconButtonHovered : undefined) as any, styles.realtimeVoiceButton as any,
(!isConnected || voice?.isVoiceSwitching (hovered ? styles.iconButtonHovered : undefined) as any,
? styles.buttonDisabled (!isConnected || voice?.isVoiceSwitching
: undefined) as any, ? styles.buttonDisabled
]} : undefined) as any,
> ]}
{({ hovered }) => >
voice?.isVoiceSwitching ? ( {({ hovered }) =>
<ActivityIndicator size="small" color="white" /> voice?.isVoiceSwitching ? (
) : ( <ActivityIndicator size="small" color="white" />
<AudioLines ) : (
size={buttonIconSize} <AudioLines
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} size={buttonIconSize}
/> color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
) />
} )
</TooltipTrigger> }
<TooltipContent side="top" align="center" offset={8}> </TooltipTrigger>
<View style={styles.tooltipRow}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Voice mode</Text> <View style={styles.tooltipRow}>
{voiceToggleKeys ? ( <Text style={styles.tooltipText}>Voice mode</Text>
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} /> {voiceToggleKeys ? (
) : null} <Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
</View> ) : null}
</TooltipContent> </View>
</Tooltip> </TooltipContent>
) : null} </Tooltip>
{cancelButton} ) : null}
</View> {cancelButton}
) : null; </View>
) : null,
[
buttonIconSize,
cancelButton,
handleToggleRealtimeVoice,
isConnected,
showVoiceModeButton,
theme.colors.foreground,
theme.colors.foregroundMuted,
voice,
voiceToggleKeys,
],
);
const hasContextWindowMeter = const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" && typeof agentState.contextWindowMaxTokens === "number" &&
@@ -719,15 +765,18 @@ export function Composer({
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null; const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null; const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
const beforeVoiceContent = ( const beforeVoiceContent = useMemo(
<View style={styles.contextWindowMeterSlot}> () => (
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? ( <View style={styles.contextWindowMeterSlot}>
<ContextWindowMeter {contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
maxTokens={contextWindowMaxTokens} <ContextWindowMeter
usedTokens={contextWindowUsedTokens} maxTokens={contextWindowMaxTokens}
/> usedTokens={contextWindowUsedTokens}
) : null} />
</View> ) : null}
</View>
),
[contextWindowMaxTokens, contextWindowUsedTokens],
); );
const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -802,20 +851,39 @@ export function Composer({
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], [setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
); );
const leftContent = const leftContent = useMemo(
resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( () =>
<DraftAgentStatusBar {...statusControls} /> resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
) : ( <DraftAgentStatusBar {...statusControls} />
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} /> ) : (
); <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
),
[agentId, focusInput, serverId, statusControls],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
attachButtonRef.current = node;
}, []);
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => {
setCursorIndex(selection.start);
}, []);
const handleFocusChange = useCallback(
(focused: boolean) => {
setIsMessageInputFocused(focused);
if (focused) {
onAttentionInputFocus?.();
}
},
[onAttentionInputFocus],
);
return ( return (
<Animated.View <Animated.View style={[styles.container, keyboardAnimatedStyle]}>
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} /> <AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */} {/* Input area */}
<View style={styles.inputAreaContainer}> <View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
<View style={styles.inputAreaContent}> <View style={styles.inputAreaContent}>
{/* Queue list */} {/* Queue list */}
{queuedMessages.length > 0 && ( {queuedMessages.length > 0 && (
@@ -874,6 +942,7 @@ export function Composer({
onRemove={() => handleRemoveAttachment(index)} onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel="Open image attachment" openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment" removeAccessibilityLabel="Remove image attachment"
disabled={isComposerLocked}
> >
<ImageAttachmentThumbnail image={attachment.metadata} /> <ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill> </AttachmentPill>
@@ -890,6 +959,7 @@ export function Composer({
onRemove={() => handleRemoveAttachment(index)} onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`} openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`} removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
disabled={isComposerLocked}
> >
<View style={styles.githubPillBody}> <View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}> <View style={styles.githubPillIcon}>
@@ -916,7 +986,7 @@ export function Composer({
) : null} ) : null}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */} {/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput <StableMessageInput
ref={messageInputRef} ref={messageInputRef}
value={userInput} value={userInput}
onChangeText={setUserInput} onChangeText={setUserInput}
@@ -924,14 +994,13 @@ export function Composer({
hasExternalContent={hasExternalContent} hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit} allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitIcon={submitIcon}
isSubmitDisabled={isProcessing || isSubmitLoading} isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading} isSubmitLoading={isProcessing || isSubmitLoading}
attachments={selectedAttachments} attachments={selectedAttachments}
cwd={cwd} cwd={cwd}
attachmentMenuItems={attachmentMenuItems} attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={(node) => { onAttachButtonRef={handleAttachButtonRef}
attachButtonRef.current = node;
}}
onAddImages={addImages} onAddImages={addImages}
client={client} client={client}
isReadyForDictation={isDictationReady} isReadyForDictation={isDictationReady}
@@ -950,15 +1019,8 @@ export function Composer({
onQueue={handleQueue} onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress} onKeyPress={handleCommandKeyPress}
onSelectionChange={(selection) => { onSelectionChange={handleSelectionChange}
setCursorIndex(selection.start); onFocusChange={handleFocusChange}
}}
onFocusChange={(focused) => {
setIsMessageInputFocused(focused);
if (focused) {
onAttentionInputFocus?.();
}
}}
onHeightChange={onComposerHeightChange} onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle} inputWrapperStyle={inputWrapperStyle}
/> />
@@ -1043,6 +1105,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
overflow: "visible", overflow: "visible",
padding: theme.spacing[4], padding: theme.spacing[4],
}, },
inputAreaLocked: {
opacity: 0.6,
},
inputAreaContent: { inputAreaContent: {
width: "100%", width: "100%",
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
+13 -4
View File
@@ -6,11 +6,20 @@ interface DiffStatProps {
deletions: number; deletions: number;
} }
const compactFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions }: DiffStatProps) { export function DiffStat({ additions, deletions }: DiffStatProps) {
return ( return (
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.additions}>+{additions.toLocaleString()}</Text> <Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{deletions.toLocaleString()}</Text> <Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View> </View>
); );
} }
@@ -25,11 +34,11 @@ const styles = StyleSheet.create((theme) => ({
additions: { additions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400], color: theme.colors.diffAddition,
}, },
deletions: { deletions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500], color: theme.colors.diffDeletion,
}, },
})); }));
@@ -142,7 +142,7 @@ export function DraggableList<T>({
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
distance: 8, distance: 6,
}, },
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
@@ -12,8 +12,12 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { PrPane } from "./pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
import { import {
usePanelStore, usePanelStore,
selectIsFileExplorerOpen,
MIN_EXPLORER_SIDEBAR_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab, type ExplorerTab,
@@ -49,9 +53,9 @@ export function ExplorerSidebar({
const isScreenFocused = useIsFocused(); const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const closeToAgent = usePanelStore((state) => state.closeToAgent); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const explorerTab = usePanelStore((state) => state.explorerTab); const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerWidth = usePanelStore((state) => state.explorerWidth); const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -78,9 +82,6 @@ export function ExplorerSidebar({
} }
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]); }, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const { const {
translateX, translateX,
backdropOpacity, backdropOpacity,
@@ -101,18 +102,20 @@ export function ExplorerSidebar({
logExplorerSidebar("handleClose", { logExplorerSidebar("handleClose", {
reason, reason,
isOpen, isOpen,
mobileView,
desktopFileExplorerOpen,
}); });
closeToAgent(); if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
}, },
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView], [closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
); );
const handleCloseFromGesture = useCallback(() => { const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true; gestureAnimatingRef.current = true;
closeToAgent(); showMobileAgent();
}, [closeToAgent, gestureAnimatingRef]); }, [gestureAnimatingRef, showMobileAgent]);
const enableSidebarCloseGesture = isMobile && isOpen; const enableSidebarCloseGesture = isMobile && isOpen;
@@ -289,6 +292,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile={isMobile} isMobile={isMobile}
isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
</Animated.View> </Animated.View>
@@ -321,6 +325,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile={false} isMobile={false}
isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
</View> </View>
@@ -337,6 +342,7 @@ interface SidebarContentProps {
workspaceRoot: string; workspaceRoot: string;
isGit: boolean; isGit: boolean;
isMobile: boolean; isMobile: boolean;
isOpen: boolean;
onOpenFile?: (filePath: string) => void; onOpenFile?: (filePath: string) => void;
} }
@@ -349,11 +355,24 @@ function SidebarContent({
workspaceRoot, workspaceRoot,
isGit, isGit,
isMobile, isMobile,
isOpen,
onOpenFile, onOpenFile,
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const padding = useWindowControlsPadding("explorerSidebar"); const padding = useWindowControlsPadding("explorerSidebar");
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab; const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
serverId,
cwd: workspaceRoot,
enabled: canQueryPullRequest && isOpen,
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
});
const hasPullRequest = prPane.prNumber !== null;
const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab =
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
return ( return (
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
@@ -381,6 +400,23 @@ function SidebarContent({
Files Files
</Text> </Text>
</Pressable> </Pressable>
{isGit && hasPullRequest && (
<Pressable
testID="explorer-tab-pr"
style={[styles.tab, resolvedTab === "pr" && styles.tabActive]}
onPress={() => onTabPress("pr")}
>
<GitHubIcon
size={13}
color={
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
<Text style={[styles.tabText, resolvedTab === "pr" && styles.tabTextActive]}>
{prTabLabel}
</Text>
</Pressable>
)}
</View> </View>
<View style={styles.headerRightSection}> <View style={styles.headerRightSection}>
{isMobile && ( {isMobile && (
@@ -409,6 +445,7 @@ function SidebarContent({
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
)} )}
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
</View> </View>
</View> </View>
); );
@@ -8,18 +8,8 @@ import {
Text, Text,
View, View,
} from "react-native"; } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
@@ -34,6 +24,7 @@ import {
X, X,
} from "lucide-react-native"; } from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons"; import { getFileIconSvg } from "@/components/material-file-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -195,35 +186,6 @@ export function FileExplorerPane({
}); });
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]); }, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
useEffect(() => {
if (!selectedEntryPath || !workspaceStateKey) {
return;
}
const parentDir = getParentDirectory(selectedEntryPath);
const ancestors = getAncestorDirectories(parentDir);
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
if (newPaths.length === 0) {
return;
}
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
newPaths.forEach((path) => {
if (!directories.has(path)) {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
});
}, [
directories,
workspaceStateKey,
expandedPaths,
requestDirectoryListing,
selectedEntryPath,
setExpandedPathsForWorkspace,
]);
const handleToggleDirectory = useCallback( const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => { (entry: ExplorerEntry) => {
if (!workspaceStateKey) { if (!workspaceStateKey) {
@@ -341,48 +303,6 @@ export function FileExplorerPane({
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void refetchExplorer(); void refetchExplorer();
}, [refetchExplorer]); }, [refetchExplorer]);
const refreshIconRotation = useSharedValue(0);
useEffect(() => {
if (isRefreshFetching) {
refreshIconRotation.value = 0;
refreshIconRotation.value = withRepeat(
withTiming(360, {
duration: 700,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(refreshIconRotation);
const remainder = refreshIconRotation.value % 360;
if (Math.abs(remainder) < 0.001) {
refreshIconRotation.value = 0;
return;
}
const remaining = 360 - remainder;
const duration = Math.max(80, Math.round((remaining / 360) * 700));
refreshIconRotation.value = withTiming(
360,
{
duration,
easing: Easing.linear,
},
(finished) => {
if (finished) {
refreshIconRotation.value = 0;
}
},
);
}, [isRefreshFetching, refreshIconRotation]);
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
}));
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
@@ -592,11 +512,15 @@ export function FileExplorerPane({
(hovered || pressed) && styles.iconButtonHovered, (hovered || pressed) && styles.iconButtonHovered,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Refresh files" accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
> >
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}> <View style={styles.refreshIcon}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> {isRefreshFetching ? (
</Animated.View> <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable> </Pressable>
</View> </View>
<FlatList <FlatList
@@ -682,35 +606,6 @@ function buildTreeRows({
return rows; return rows;
} }
function getParentDirectory(path: string): string {
const normalized = path.replace(/\/+$/, "");
if (!normalized || normalized === ".") {
return ".";
}
const lastSlash = normalized.lastIndexOf("/");
if (lastSlash === -1) {
return ".";
}
const dir = normalized.slice(0, lastSlash);
return dir.length > 0 ? dir : ".";
}
function getAncestorDirectories(directory: string): string[] {
const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, "");
if (!trimmed || trimmed === ".") {
return ["."];
}
const parts = trimmed.split("/").filter(Boolean);
const ancestors: string[] = ["."];
let acc = "";
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
ancestors.push(acc);
}
return ancestors;
}
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string { function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
if (!state) { if (!state) {
return "."; return ".";
@@ -16,9 +16,10 @@ import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps { interface GitActionsSplitButtonProps {
gitActions: GitActions; gitActions: GitActions;
hideLabels?: boolean;
} }
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) { export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const archiveShortcutKeys = useShortcutKeys("archive-worktree");
@@ -68,9 +69,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
) : ( ) : (
<View style={styles.splitButtonContent}> <View style={styles.splitButtonContent}>
{gitActions.primary.icon} {gitActions.primary.icon}
<Text style={styles.splitButtonText}> {!hideLabels && (
{getActionDisplayLabel(gitActions.primary)} <Text style={styles.splitButtonText}>
</Text> {getActionDisplayLabel(gitActions.primary)}
</Text>
)}
</View> </View>
)} )}
</Pressable> </Pressable>
+1 -28
View File
@@ -661,10 +661,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { const {
status, status,
isLoading: isStatusLoading, isLoading: isStatusLoading,
isFetching: isStatusFetching,
isError: isStatusError, isError: isStatusError,
error: statusError, error: statusError,
refresh: refreshStatus,
} = useCheckoutStatusQuery({ serverId, cwd }); } = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null; const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus); const isGit = Boolean(gitStatus);
@@ -683,10 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
files, files,
payloadError: diffPayloadError, payloadError: diffPayloadError,
isLoading: isDiffLoading, isLoading: isDiffLoading,
isFetching: isDiffFetching,
isError: isDiffError,
error: diffError,
refresh: refreshDiff,
} = useCheckoutDiffQuery({ } = useCheckoutDiffQuery({
serverId, serverId,
cwd, cwd,
@@ -699,14 +693,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
status: prStatus, status: prStatus,
githubFeaturesEnabled, githubFeaturesEnabled,
payloadError: prPayloadError, payloadError: prPayloadError,
refresh: refreshPrStatus,
} = useCheckoutPrStatusQuery({ } = useCheckoutPrStatusQuery({
serverId, serverId,
cwd, cwd,
enabled: isGit, enabled: isGit,
}); });
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
const [isManualRefresh, setIsManualRefresh] = useState(false);
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]); const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
const workspaceStateKey = useMemo( const workspaceStateKey = useMemo(
() => () =>
@@ -732,13 +723,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const headerHeightByPathRef = useRef<Record<string, number>>({}); const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByPathRef = useRef<Record<string, number>>({}); const bodyHeightByPathRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44); const defaultHeaderHeightRef = useRef<number>(44);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
void refreshDiff();
void refreshStatus();
void refreshPrStatus();
}, [refreshDiff, refreshStatus, refreshPrStatus]);
const shipDefaultStorageKey = useMemo(() => { const shipDefaultStorageKey = useMemo(() => {
if (!gitStatus?.repoRoot) { if (!gitStatus?.repoRoot) {
return null; return null;
@@ -901,13 +885,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
} }
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]); }, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Reset manual refresh flag when fetch completes
useEffect(() => {
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
// Clear diff mode override when auto mode changes (e.g., after commit) // Clear diff mode override when auto mode changes (e.g., after commit)
useEffect(() => { useEffect(() => {
setDiffModeOverride(null); setDiffModeOverride(null);
@@ -1092,9 +1069,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
); );
const hasChanges = files.length > 0; const hasChanges = files.length > 0;
const diffErrorMessage = const diffErrorMessage = diffPayloadError?.message ?? null;
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null; const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
const branchLabel = const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
@@ -1199,8 +1174,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
onContentSizeChange={scrollbar.onContentSizeChange} onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16} scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar} showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts. // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll. // Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
removeClippedSubviews={false} removeClippedSubviews={false}
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header"; import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title"; import { ScreenTitle } from "./screen-title";
import { HeaderToggleButton } from "./header-toggle-button"; import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -46,18 +46,16 @@ export function SidebarMenuToggle({
}: SidebarMenuToggleProps = {}) { }: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor = const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
return ( return (
<HeaderToggleButton <HeaderToggleButton
onPress={toggleAgentList} onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
tooltipLabel="Toggle sidebar" tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys} tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide} tooltipSide={tooltipSide}
@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { LayoutChangeEvent } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native"; import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -17,6 +18,7 @@ interface ScreenHeaderProps {
leftStyle?: StyleProp<ViewStyle>; leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>; rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean; borderless?: boolean;
onRowLayout?: (event: LayoutChangeEvent) => void;
} }
/** /**
@@ -29,6 +31,7 @@ export function ScreenHeader({
leftStyle, leftStyle,
rightStyle, rightStyle,
borderless, borderless,
onRowLayout,
}: ScreenHeaderProps) { }: ScreenHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -42,6 +45,7 @@ export function ScreenHeader({
<View style={styles.header}> <View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}> <View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View <View
onLayout={onRowLayout}
style={[ style={[
styles.row, styles.row,
{ {
@@ -84,11 +88,13 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
minWidth: 0,
}, },
right: { right: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
flexShrink: 0,
}, },
borderless: { borderless: {
borderBottomColor: "transparent", borderBottomColor: "transparent",
@@ -25,6 +25,7 @@ export function ScreenTitle({ children, numberOfLines = 1, testID, style }: Scre
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
text: { text: {
flexShrink: 1, flexShrink: 1,
minWidth: 0,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: { fontWeight: {
xs: "400", xs: "400",
@@ -0,0 +1,248 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
const panelState = {
isOpen: false,
showMobileAgent: vi.fn(),
};
return {
panelState,
useSidebarWorkspacesListMock: vi.fn(),
theme: {
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
colors: {
surfaceSidebar: "#111",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#444",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
borderAccent: "#666",
accent: "#0a84ff",
accentForeground: "#fff",
palette: {
green: { 400: "#30d158" },
amber: { 500: "#ffd60a" },
red: { 500: "#ff453a" },
},
},
},
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
absoluteFillObject: {},
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Extrapolation: { CLAMP: "clamp" },
interpolate: () => 0,
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useAnimatedStyle: (factory: () => unknown) => factory(),
useSharedValue: (value: unknown) => ({ value }),
}));
vi.mock("react-native-gesture-handler", () => {
const chain = {
enabled: () => chain,
hitSlop: () => chain,
manualActivation: () => chain,
onTouchesDown: () => chain,
onTouchesMove: () => chain,
onStart: () => chain,
onUpdate: () => chain,
onEnd: () => chain,
onFinalize: () => chain,
withRef: () => chain,
};
return {
Gesture: { Pan: () => chain },
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
};
});
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
MessagesSquare: createIcon("MessagesSquare"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
};
});
vi.mock("expo-router", () => ({
router: { push: vi.fn() },
usePathname: () => "/hosts/srv",
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => true,
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/stores/panel-store", () => ({
MIN_SIDEBAR_WIDTH: 260,
MAX_SIDEBAR_WIDTH: 420,
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "srv", label: "Local" }],
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
}));
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
}));
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
useSidebarShortcutModel: () => ({
collapsedProjectKeys: new Set<string>(),
shortcutIndexByWorkspaceKey: new Map<string, number>(),
toggleProjectCollapsed: vi.fn(),
}),
}));
vi.mock("@/contexts/sidebar-animation-context", () => ({
useSidebarAnimation: () => ({
translateX: { value: 0 },
backdropOpacity: { value: 0 },
windowWidth: 390,
animateToOpen: vi.fn(),
animateToClose: vi.fn(),
isGesturing: { value: false },
gestureAnimatingRef: { current: false },
closeGestureRef: { current: undefined },
}),
}));
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/utils/desktop-window", () => ({
useWindowControlsPadding: () => ({ top: 0 }),
}));
vi.mock("@/utils/host-routes", () => ({
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
buildSettingsRoute: () => "/settings",
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
parseServerIdFromPathname: () => "srv",
}));
vi.mock("@/hooks/use-open-project-picker", () => ({
useOpenProjectPicker: () => vi.fn(),
}));
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
}));
vi.mock("./sidebar-workspace-list", () => ({
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
React.createElement(
"div",
{ "data-testid": "sidebar-workspace-list" },
projects.map((project) => project.projectName).join(","),
),
}));
vi.mock("./sidebar-agent-list-skeleton", () => ({
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => React.createElement("span", null),
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: () => null,
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
}));
vi.stubGlobal("React", React);
import { LeftSidebar } from "./left-sidebar";
describe("LeftSidebar", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
panelState.isOpen = false;
panelState.showMobileAgent.mockReset();
useSidebarWorkspacesListMock.mockReset();
useSidebarWorkspacesListMock.mockReturnValue({
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
isInitialLoad: false,
isRevalidating: false,
refreshAll: vi.fn(),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
await act(async () => {
root?.render(<LeftSidebar />);
});
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
serverId: "srv",
enabled: true,
});
});
});
+22 -27
View File
@@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store"; import {
usePanelStore,
selectIsAgentListOpen,
MIN_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
} from "@/stores/panel-store";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarWorkspaceList } from "./sidebar-workspace-list"; import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -52,10 +57,10 @@ import {
buildHostSessionsRoute, buildHostSessionsRoute,
buildSettingsRoute, buildSettingsRoute,
mapPathnameToServer, mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { resolveActiveHost } from "@/utils/active-host";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
@@ -116,24 +121,16 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) =>
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
const closeToAgent = usePanelStore((state) => state.closeToAgent); );
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts(); const daemons = useHosts();
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeDaemon = useMemo(
const activeDaemon = useMemo(() => { () => resolveActiveHost({ hosts: daemons, pathname }),
if (daemons.length === 0) { [daemons, pathname],
return null; );
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch;
}
}
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null; const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => { const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host"; if (!activeDaemon) return "No host";
@@ -183,14 +180,12 @@ export const LeftSidebar = memo(function LeftSidebar({
const hostTriggerRef = useRef<View | null>(null); const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId, serverId: activeServerId,
enabled: isOpen, enabled: isCompactLayout || isOpen,
}); });
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel(projects); useSidebarShortcutModel({ projects, isInitialLoad });
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -208,18 +203,18 @@ export const LeftSidebar = memo(function LeftSidebar({
const openProjectPicker = useOpenProjectPicker(activeServerId); const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => { const handleOpenProjectMobile = useCallback(() => {
closeToAgent(); showMobileAgent();
void openProjectPicker(); void openProjectPicker();
}, [closeToAgent, openProjectPicker]); }, [showMobileAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => { const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker(); void openProjectPicker();
}, [openProjectPicker]); }, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => { const handleSettingsMobile = useCallback(() => {
closeToAgent(); showMobileAgent();
router.push(buildSettingsRoute()); router.push(buildSettingsRoute());
}, [closeToAgent]); }, [showMobileAgent]);
const handleSettingsDesktop = useCallback(() => { const handleSettingsDesktop = useCallback(() => {
router.push(buildSettingsRoute()); router.push(buildSettingsRoute());
@@ -272,7 +267,7 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top} insetsTop={insets.top}
insetsBottom={insets.bottom} insetsBottom={insets.bottom}
isOpen={isOpen} isOpen={isOpen}
closeToAgent={closeToAgent} closeToAgent={showMobileAgent}
handleOpenProject={handleOpenProjectMobile} handleOpenProject={handleOpenProjectMobile}
handleSettings={handleSettingsMobile} handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate} handleViewMoreNavigate={handleViewMoreNavigate}
@@ -1,9 +1,15 @@
import React from "react"; import React, { createRef } from "react";
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type AttachmentMenuItem } from "./message-input"; import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
startDictationMock: vi.fn(),
cancelDictationMock: vi.fn(),
confirmDictationMock: vi.fn(),
}));
const { theme } = vi.hoisted(() => ({ const { theme } = vi.hoisted(() => ({
theme: { theme: {
@@ -49,9 +55,10 @@ vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) => const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name }); React.createElement("span", { ...props, "data-icon": name });
return { return {
ArrowUp: createIcon("ArrowUp"),
Mic: createIcon("Mic"), Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"), MicOff: createIcon("MicOff"),
ArrowUp: createIcon("ArrowUp"), CornerDownLeft: createIcon("CornerDownLeft"),
Plus: createIcon("Plus"), Plus: createIcon("Plus"),
Square: createIcon("Square"), Square: createIcon("Square"),
}; };
@@ -84,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({
duration: 0, duration: 0,
error: null, error: null,
status: "idle", status: "idle",
startDictation: vi.fn(), startDictation: startDictationMock,
cancelDictation: vi.fn(), cancelDictation: cancelDictationMock,
confirmDictation: vi.fn(), confirmDictation: confirmDictationMock,
retryFailedDictation: vi.fn(), retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(), discardFailedDictation: vi.fn(),
}), }),
@@ -213,6 +220,9 @@ beforeEach(() => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
startDictationMock.mockReset();
cancelDictationMock.mockReset();
confirmDictationMock.mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -226,18 +236,27 @@ afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
function renderMessageInput(menuItems: AttachmentMenuItem[]) { interface RenderMessageInputOptions {
value?: string;
submitIcon?: "arrow" | "return";
}
function renderMessageInput(
menuItems: AttachmentMenuItem[],
{ value = "", submitIcon }: RenderMessageInputOptions = {},
) {
act(() => { act(() => {
root?.render( root?.render(
<MessageInput <MessageInput
value="" value={value}
onChangeText={vi.fn()} onChangeText={vi.fn()}
onSubmit={vi.fn()} onSubmit={vi.fn()}
attachments={[]} attachments={[]}
cwd="/repo" cwd="/repo"
attachmentMenuItems={menuItems} attachmentMenuItems={menuItems}
client={{ isConnected: true } as never} client={{ isConnected: true } as never}
isAgentRunning isAgentRunning={false}
submitIcon={submitIcon}
onQueue={vi.fn()} onQueue={vi.fn()}
/>, />,
); );
@@ -302,4 +321,70 @@ describe("MessageInput attachments", () => {
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0); expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
}); });
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
renderMessageInput([], { value: "Send this" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
renderMessageInput([], { value: "Create this", submitIcon: "return" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
});
});
describe("MessageInput dictation shortcuts", () => {
it("does not poison the dictation toggle when readiness is temporarily false", () => {
const inputRef = createRef<MessageInputRef>();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
isAgentRunning={false}
isReadyForDictation={false}
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).not.toHaveBeenCalled();
expect(confirmDictationMock).not.toHaveBeenCalled();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
isAgentRunning={false}
isReadyForDictation
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).toHaveBeenCalledTimes(1);
expect(confirmDictationMock).not.toHaveBeenCalled();
});
}); });
+21 -68
View File
@@ -20,7 +20,7 @@ import {
forwardRef, forwardRef,
} from "react"; } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation"; import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls"; import { DictationOverlay } from "./dictation-controls";
@@ -56,6 +56,7 @@ import {
markScrollInvestigationRender, markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation"; } from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useComposerHeightMirror } from "./composer-height-mirror";
export type ImageAttachment = AttachmentMetadata; export type ImageAttachment = AttachmentMetadata;
@@ -85,6 +86,7 @@ export interface MessageInputProps {
allowEmptySubmit?: boolean; allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */ /** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string; submitButtonAccessibilityLabel?: string;
submitIcon?: "arrow" | "return";
isSubmitDisabled?: boolean; isSubmitDisabled?: boolean;
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
attachments: ComposerAttachment[]; attachments: ComposerAttachment[];
@@ -217,6 +219,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false, allowEmptySubmit = false,
submitButtonAccessibilityLabel, submitButtonAccessibilityLabel,
submitIcon = "arrow",
isSubmitDisabled = false, isSubmitDisabled = false,
isSubmitLoading = false, isSubmitLoading = false,
attachments, attachments,
@@ -328,7 +331,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, },
})); }));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null);
const overlayTransition = useSharedValue(0); const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false); const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value); const valueRef = useRef(value);
@@ -398,12 +400,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
} else { } else {
onChangeText(nextValue); onChangeText(nextValue);
} }
if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
measureWebInputHeight("dictation");
});
}
}, },
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior], [onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
); );
@@ -484,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
toast.error(dictationUnavailableMessage); toast.error(dictationUnavailableMessage);
return; return;
} }
if (!canStartDictation()) {
isDictatingRef.current = false;
return;
}
// Keep hotkey toggling deterministic between the async start call and the // Keep hotkey toggling deterministic between the async start call and the
// state-ref sync effect, so a rapid second toggle routes to confirm. // state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true; isDictatingRef.current = true;
await startDictation(); await startDictation();
}, [dictationUnavailableMessage, startDictation, toast]); }, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
// Animate overlay // Animate overlay
useEffect(() => { useEffect(() => {
@@ -830,76 +830,27 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}; };
}, [getWebTextArea]); }, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean {
if (!isWeb) return false;
const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0;
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
baselineInputHeightRef.current = scrollHeight;
logWebStickyBottom("composer_baseline_measured", {
source,
baseline: scrollHeight,
});
}
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT;
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline;
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget));
const previousHeight = inputHeightRef.current;
if (Math.abs(previousHeight - bounded) >= 1) {
inputHeightRef.current = bounded;
setInputHeight(bounded);
onHeightChange?.(bounded);
logWebStickyBottom("composer_height_changed", {
source,
previousHeight,
nextHeight: bounded,
scrollHeight,
clientHeight: textarea.clientHeight ?? null,
offsetHeight: textarea.offsetHeight ?? null,
baseline,
rawTarget,
});
return true;
}
return false;
}
function setBoundedInputHeight(nextHeight: number) { function setBoundedInputHeight(nextHeight: number) {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
if (Math.abs(inputHeightRef.current - bounded) < 1) return; if (Math.abs(inputHeightRef.current - bounded) < 1) return;
const previousHeight = inputHeightRef.current;
inputHeightRef.current = bounded; inputHeightRef.current = bounded;
setInputHeight(bounded); setInputHeight(bounded);
onHeightChange?.(bounded); onHeightChange?.(bounded);
logWebStickyBottom("composer_height_changed_native", {
previousHeight,
nextHeight: bounded,
});
} }
useComposerHeightMirror({
value,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
onHeight: setBoundedInputHeight,
});
function handleContentSizeChange( function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>, event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) { ) {
const contentHeight = event.nativeEvent.contentSize.height; if (isWeb) return;
if (isWeb) { setBoundedInputHeight(event.nativeEvent.contentSize.height);
logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight,
});
if (baselineInputHeightRef.current === null && contentHeight > 0) {
baselineInputHeightRef.current = contentHeight;
logWebStickyBottom("composer_baseline_measured", {
source: "contentSizeChange",
baseline: contentHeight,
});
}
setBoundedInputHeight(contentHeight);
return;
}
setBoundedInputHeight(contentHeight);
} }
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) { function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
@@ -1180,6 +1131,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
> >
{isSubmitLoading ? ( {isSubmitLoading ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : submitIcon === "return" ? (
<CornerDownLeft size={buttonIconSize} color="white" />
) : ( ) : (
<ArrowUp size={buttonIconSize} color="white" /> <ArrowUp size={buttonIconSize} color="white" />
)} )}
+32 -5
View File
@@ -53,7 +53,6 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { theme } from "@/styles/theme";
import { createMarkdownStyles } from "@/styles/markdown-styles"; import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme"; import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
@@ -119,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes"; const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer"; const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
"data:image/png;base64",
"data:image/gif;base64",
"data:image/jpeg;base64",
"https://",
"http://",
] as const;
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
type MarkdownWithStableRendererProps = {
children: ReactNode;
style: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules;
markdownit: MarkdownIt;
onLinkPress: (url: string) => boolean;
allowedImageHandlers: readonly string[];
topLevelMaxExceededItem: ReactNode;
};
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% { 0% {
@@ -341,6 +360,7 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true, isLastInGroup = true,
disableOuterSpacing, disableOuterSpacing,
}: UserMessageProps) { }: UserMessageProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false); const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false);
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
onLinkPress, onLinkPress,
}: MemoizedMarkdownBlockProps) { }: MemoizedMarkdownBlockProps) {
return ( return (
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}> <MarkdownWithStableRenderer
style={styles}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text} {text}
</Markdown> </MarkdownWithStableRenderer>
); );
}); });
@@ -962,10 +989,10 @@ export const AssistantMessage = memo(function AssistantMessage({
client, client,
disableOuterSpacing, disableOuterSpacing,
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { theme, rt } = useUnistyles(); const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => { const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true }); const parser = MarkdownIt({ typographer: true, linkify: true });
@@ -0,0 +1,169 @@
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");
});
});
+397
View File
@@ -0,0 +1,397 @@
import { useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
ExternalLink,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
GitPullRequestDraft,
MessageSquare,
} from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url";
import type {
CheckStatus,
PrPaneActivity,
PrPaneCheck,
PrPaneData,
PrState,
} from "@/utils/pr-pane-data";
export function PrPane({ data }: { data: PrPaneData }) {
const { theme } = useUnistyles();
const [checksOpen, setChecksOpen] = useState(true);
const [reviewsOpen, setReviewsOpen] = useState(true);
const passed = data.checks.filter((c) => c.status === "success").length;
const failed = data.checks.filter((c) => c.status === "failure").length;
const pending = data.checks.filter((c) => c.status === "pending").length;
const approvals = data.activity.filter(
(a) => a.kind === "review" && a.reviewState === "approved",
).length;
const changesRequested = data.activity.filter(
(a) => a.kind === "review" && a.reviewState === "changes_requested",
).length;
const commentCount = data.activity.filter(
(a) => a.kind === "comment" || (a.kind === "review" && a.reviewState === "commented"),
).length;
const stateColor = getStateColor(data.state, theme);
const StateIcon = getStateIcon(data.state);
const stateLabel = getStateLabel(data.state);
return (
<View style={styles.root}>
<Pressable onPress={() => void openExternalUrl(data.url)} style={styles.header}>
{({ hovered }) => (
<>
<View style={styles.stateLine}>
<StateIcon size={14} color={stateColor} />
<Text style={[styles.stateLabel, { color: stateColor }]}>{stateLabel}</Text>
</View>
<Text style={styles.title} numberOfLines={3}>
{data.title}
{hovered ? (
<Text>
{" "}
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
</Text>
) : null}
</Text>
</>
)}
</Pressable>
<View style={styles.divider} />
<Section
title="Checks"
open={checksOpen}
onToggle={() => setChecksOpen((o) => !o)}
summary={
<>
<SummaryPill
count={passed}
color={theme.colors.statusSuccess}
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
/>
<SummaryPill
count={failed}
color={theme.colors.statusDanger}
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
/>
<SummaryPill
count={pending}
color={theme.colors.statusWarning}
icon={<CircleDot size={12} color={theme.colors.statusWarning} />}
/>
</>
}
>
{data.checks.map((check, idx) => (
<CheckRow key={`${check.name}-${idx}`} check={check} />
))}
</Section>
<View style={styles.divider} />
<Section
title="Reviews"
open={reviewsOpen}
onToggle={() => setReviewsOpen((o) => !o)}
summary={
<>
<SummaryPill
count={approvals}
color={theme.colors.statusSuccess}
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
/>
<SummaryPill
count={changesRequested}
color={theme.colors.statusDanger}
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
/>
<SummaryPill
count={commentCount}
color={theme.colors.foregroundMuted}
icon={<MessageSquare size={11} color={theme.colors.foregroundMuted} />}
/>
</>
}
>
{data.activity.map((item, idx) => (
<ActivityRow key={`${item.author}-${idx}`} item={item} />
))}
</Section>
</View>
);
}
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: React.ReactNode;
children: React.ReactNode;
}
function Section({ title, open, onToggle, summary, children }: SectionProps) {
const { theme } = useUnistyles();
return (
<View style={open ? styles.sectionOpen : undefined}>
<Pressable style={styles.sectionHeader} onPress={onToggle}>
{open ? (
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
) : (
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.summaryWrap}>{summary}</View>
</Pressable>
{open && (
<ScrollView
style={styles.sectionBody}
contentContainerStyle={styles.sectionBodyContent}
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
)}
</View>
);
}
function SummaryPill({
count,
color,
icon,
}: {
count: number;
color: string;
icon: React.ReactNode;
}) {
if (count === 0) return null;
return (
<View style={styles.summaryPill}>
{icon}
<Text style={[styles.summaryPillText, { color }]}>{count}</Text>
</View>
);
}
function CheckRow({ check }: { check: PrPaneCheck }) {
return (
<Pressable
onPress={() => void openExternalUrl(check.url)}
style={({ hovered }) => [styles.row, hovered && styles.hoverable]}
>
<CheckStatusIcon status={check.status} />
<Text style={styles.rowTitle} numberOfLines={1}>
{check.name}
</Text>
{check.workflow && (
<Text style={styles.rowMetaMid} numberOfLines={1}>
{check.workflow}
</Text>
)}
{check.duration && <Text style={styles.rowMeta}>{check.duration}</Text>}
</Pressable>
);
}
function CheckStatusIcon({ status }: { status: CheckStatus }) {
const { theme } = useUnistyles();
if (status === "success") return <CircleCheck size={14} color={theme.colors.statusSuccess} />;
if (status === "failure") return <CircleX size={14} color={theme.colors.statusDanger} />;
if (status === "pending") return <CircleDot size={14} color={theme.colors.statusWarning} />;
return <CircleSlash size={14} color={theme.colors.foregroundMuted} />;
}
function ActivityRow({ item }: { item: PrPaneActivity }) {
const verb = getActivityVerb(item);
return (
<Pressable
onPress={() => void openExternalUrl(item.url)}
style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]}
>
<View style={[styles.avatar, { backgroundColor: item.avatarColor }]}>
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
</View>
<View style={styles.activityMain}>
<View style={styles.activityHeader}>
<Text style={styles.rowTitle} numberOfLines={1}>
{item.author}
</Text>
<Text style={styles.rowMetaMid}>{verb}</Text>
<Text style={styles.rowMeta}>{item.age}</Text>
</View>
<Text style={styles.rowBody} numberOfLines={2}>
{item.body}
</Text>
</View>
</Pressable>
);
}
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;
if (state === "merged") return theme.colors.statusMerged;
return theme.colors.statusDanger;
}
function getStateIcon(state: PrState) {
if (state === "draft") return GitPullRequestDraft;
if (state === "merged") return GitMerge;
if (state === "closed") return GitPullRequestClosed;
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,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
hoverable: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
header: {
flexDirection: "column",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
},
stateLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
stateLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
title: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
lineHeight: 19,
},
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
sectionOpen: {
flexShrink: 1,
minHeight: 0,
},
sectionBody: {
flexShrink: 1,
minHeight: 0,
},
sectionBodyContent: {
paddingBottom: theme.spacing[3],
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
activityRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
activityMain: { flex: 1, minWidth: 0, gap: 2 },
activityHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
rowTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
rowMeta: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: "auto",
},
rowMetaMid: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
rowBody: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
lineHeight: 16,
},
avatar: {
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
marginTop: 1,
},
avatarText: {
fontSize: 10,
fontWeight: theme.fontWeight.normal,
color: "#fff",
},
}));
@@ -3,35 +3,25 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const serverId = useActiveServerId();
const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const serverId = useMemo(() => {
const fromPath = parseServerIdFromPathname(pathname);
if (fromPath) return fromPath;
return daemons[0]?.serverId ?? null;
}, [pathname, daemons]);
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) => const recommendedPaths = useRecommendedProjectPaths(serverId);
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -39,13 +29,6 @@ export function ProjectPickerModal() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const openProject = useOpenProject(serverId); const openProject = useOpenProject(serverId);
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}, [workspaces]);
const directorySuggestionsQuery = useQuery({ const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, query], queryKey: ["project-picker-directory-suggestions", serverId, query],
queryFn: async () => { queryFn: async () => {
@@ -1,9 +1,9 @@
import { AlertCircle, Search } from "lucide-react-native"; import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
); );
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void refresh([provider as AgentProvider]); if (!provider) {
void fetchDiagnostic({ keepCurrent: true }); return;
}
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
});
}, [fetchDiagnostic, provider, refresh]); }, [fetchDiagnostic, provider, refresh]);
useEffect(() => { useEffect(() => {
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
refreshInFlight ? sheetStyles.disabled : null, refreshInFlight ? sheetStyles.disabled : null,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`} accessibilityLabel={
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
}
> >
<SpinningRefreshIcon {refreshInFlight ? (
spinning={refreshInFlight} <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
size={theme.iconSize.sm} ) : (
color={theme.colors.foregroundMuted} <RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
/> )}
</Pressable> </Pressable>
} }
> >
@@ -0,0 +1,453 @@
/**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceScriptPayload } from "@server/shared/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import React from "react";
import type { ReactElement } from "react";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
getHostRuntimeStore,
type HostRuntimeController,
type HostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
const SERVER_ID = "sidebar-render-count";
type RenderCounts = {
frame: number;
headers: Record<string, number>;
rows: Record<string, number>;
projectSelection: Record<string, number>;
rowSelection: Record<string, number>;
};
const runningScript: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
};
function workspace(input: {
id: string;
projectId: string;
projectDisplayName: string;
name: string;
status?: WorkspaceDescriptor["status"];
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
projectKind: "git",
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
diffStat: null,
scripts: input.scripts ?? [],
};
}
function createWorkspaces(): WorkspaceDescriptor[] {
return [
workspace({
id: "a-main",
projectId: "project-a",
projectDisplayName: "Project A",
name: "main",
scripts: [runningScript],
}),
workspace({
id: "a-one",
projectId: "project-a",
projectDisplayName: "Project A",
name: "one",
}),
workspace({
id: "a-two",
projectId: "project-a",
projectDisplayName: "Project A",
name: "two",
}),
workspace({
id: "b-main",
projectId: "project-b",
projectDisplayName: "Project B",
name: "main",
}),
workspace({
id: "b-one",
projectId: "project-b",
projectDisplayName: "Project B",
name: "one",
}),
workspace({
id: "b-two",
projectId: "project-b",
projectDisplayName: "Project B",
name: "two",
}),
];
}
function makeHost(): HostProfile {
const now = "2026-04-19T00:00:00.000Z";
return {
serverId: SERVER_ID,
label: "Render Count Host",
lifecycle: {},
connections: [],
preferredConnectionId: null,
createdAt: now,
updatedAt: now,
};
}
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
getHostRuntimeStore().syncHosts([makeHost()]);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrderByServerId: {
[SERVER_ID]: ["project-a", "project-b"],
},
workspaceOrderByServerAndProject: {
[`${SERVER_ID}::project-a`]: [
`${SERVER_ID}:a-main`,
`${SERVER_ID}:a-one`,
`${SERVER_ID}:a-two`,
],
[`${SERVER_ID}::project-b`]: [
`${SERVER_ID}:b-main`,
`${SERVER_ID}:b-one`,
`${SERVER_ID}:b-two`,
],
},
});
});
}
function resetCounts(counts: RenderCounts): void {
counts.frame = 0;
counts.headers = {};
counts.rows = {};
counts.projectSelection = {};
counts.rowSelection = {};
}
function incrementRecord(record: Record<string, number>, key: string): void {
record[key] = (record[key] ?? 0) + 1;
}
function ProjectHeaderProbe({
project,
counts,
}: {
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
incrementRecord(counts.headers, project.projectKey);
return null;
}
function WorkspaceRowProbe({
serverId,
workspaceId,
counts,
}: {
serverId: string;
workspaceId: string;
counts: RenderCounts;
}): null {
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
);
if (workspace) {
incrementRecord(counts.rows, workspace.workspaceId);
}
return null;
}
function ProjectActiveProbe({
serverId,
project,
counts,
}: {
serverId: string;
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
useIsNavigationProjectActive({
serverId,
workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId),
});
incrementRecord(counts.projectSelection, project.projectKey);
return null;
}
function WorkspaceSelectionProbe({
serverId,
workspaceId,
counts,
}: {
serverId: string;
workspaceId: string;
counts: RenderCounts;
}): null {
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
incrementRecord(counts.rowSelection, workspaceId);
return null;
}
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
return (
<>
{projects.map((project) => (
<div key={project.projectKey}>
<ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{project.workspaces.map((workspace) => (
<React.Fragment key={workspace.workspaceKey}>
<WorkspaceRowProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
<WorkspaceSelectionProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
</React.Fragment>
))}
</div>
))}
</>
);
}
function getHostController(): HostRuntimeController {
const controllers = (
getHostRuntimeStore() as unknown as {
controllers: Map<string, HostRuntimeController>;
}
).controllers;
const controller = controllers.get(SERVER_ID);
if (!controller) {
throw new Error("Host runtime controller was not initialized");
}
return controller;
}
function updateControllerSnapshot(
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
): void {
(
getHostController() as unknown as {
updateSnapshot: (
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
) => void;
}
).updateSnapshot(patch);
}
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<SidebarFrameProbe counts={counts} />);
});
resetCounts(counts);
return { root, container };
}
describe("sidebar workspace render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(async () => {
initializeSidebarState(createWorkspaces());
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
act(() => {
syncNavigationActiveWorkspace({ current: null });
getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
});
});
it("re-renders only the changed workspace row for a status update", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
...createWorkspaces()[1]!,
status: "running",
},
]);
});
expect(counts.frame).toBe(0);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({ "a-one": 1 });
});
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
act(() => {
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
updateControllerSnapshot({
probeByConnectionId: new Map(probeByConnectionId),
});
});
expect(counts).toEqual({
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
});
});
it("does not re-render for a deep-equal scripts patch", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
act(() => {
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
patchWorkspaceScripts(current, {
workspaceId: "a-main",
scripts: [{ ...runningScript }],
}),
);
});
expect(counts).toEqual({
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
});
});
it("isolates active selection updates to affected row and project boolean probes", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "a-one",
});
});
({ root, container } = await renderProbe(counts));
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "b-two",
});
});
expect(counts.frame).toBe(0);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({});
expect(counts.projectSelection).toEqual({
"project-a": 1,
"project-b": 1,
});
expect(counts.rowSelection).toEqual({
"a-one": 1,
"b-two": 1,
});
});
});
@@ -28,7 +28,6 @@ import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
import { import {
Archive, Archive,
ArrowUpRight,
CircleAlert, CircleAlert,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -56,6 +55,7 @@ import {
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry, type SidebarProjectEntry,
type SidebarWorkspaceEntry, type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list"; } from "@/hooks/use-sidebar-workspaces-list";
@@ -91,9 +91,17 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import {
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; useIsNavigationProjectActive,
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { import {
requireWorkspaceExecutionDirectory, requireWorkspaceExecutionDirectory,
@@ -114,7 +122,6 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
const EMPTY_WORKSPACES = new Map();
const WORKSPACE_STATUS_DOT_WIDTH = 14; const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7; const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9; const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -194,10 +201,24 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null; archiveShortcutKeys?: ShortcutKey[][] | null;
} }
function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
): SidebarWorkspaceEntry | null {
const projectWorkspaceEntry = useCallback(
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace }),
[serverId],
);
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
}
export function PrBadge({ hint }: { hint: PrHint }) { export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => { const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -222,11 +243,14 @@ export function PrBadge({ hint }: { hint: PrHint }) {
onHoverOut={() => setIsHovered(false)} onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]} style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
> >
<GitPullRequest size={12} color={activeColor} /> {isHovered ? (
<ExternalLink size={12} color={activeColor} />
) : (
<GitPullRequest size={12} color={iconColor} />
)}
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}> <Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
#{hint.number} #{hint.number}
</Text> </Text>
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
</Pressable> </Pressable>
); );
} }
@@ -797,17 +821,6 @@ function ProjectHeaderRow({
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast(); const toast = useToast();
useKeyboardActionHandler({
handlerId: `worktree-new-${project.projectKey}`,
actions: ["worktree.new"],
enabled: isProjectActive && canCreateWorktree && Boolean(serverId),
priority: 0,
handle: () => {
handleBeginWorkspaceSetup();
return true;
},
});
const interaction = useLongPressDragInteraction({ const interaction = useLongPressDragInteraction({
drag, drag,
menuController, menuController,
@@ -823,12 +836,7 @@ function ProjectHeaderRow({
const rowChildren = ( const rowChildren = (
<> <>
<View <View style={styles.projectRowLeft}>
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.projectRowLeft}
>
<ProjectLeadingVisual <ProjectLeadingVisual
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
@@ -905,7 +913,13 @@ function ProjectHeaderRow({
if (menuController) { if (menuController) {
return ( return (
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<ContextMenuTrigger <ContextMenuTrigger
enabledOnMobile={false} enabledOnMobile={false}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -928,7 +942,13 @@ function ProjectHeaderRow({
} }
return ( return (
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<Pressable <Pressable
style={({ pressed }) => [ style={({ pressed }) => [
styles.projectRow, styles.projectRow,
@@ -1003,6 +1023,9 @@ function WorkspaceRowInner({
return ( return (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}> <WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
<View <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowContainer} style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)} onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)} onPointerLeave={() => setIsHovered(false)}
@@ -1026,12 +1049,7 @@ function WorkspaceRowInner({
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
<View style={styles.workspaceRowMain}> <View style={styles.workspaceRowMain}>
<View <View style={styles.workspaceRowLeft}>
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator <WorkspaceStatusIndicator
bucket={workspace.statusBucket} bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind} workspaceKind={workspace.workspaceKind}
@@ -1126,8 +1144,8 @@ function WorkspaceRowInner({
</View> </View>
{prHint ? ( {prHint ? (
<View style={styles.workspacePrBadgeRow}> <View style={styles.workspacePrBadgeRow}>
<ChecksBadge checks={prHint.checks} />
<PrBadge hint={prHint} /> <PrBadge hint={prHint} />
<ChecksBadge checks={prHint.checks} />
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
@@ -1160,11 +1178,7 @@ function WorkspaceRowWithMenu({
isCreating?: boolean; isCreating?: boolean;
}) { }) {
const toast = useToast(); const toast = useToast();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({ const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory, workspaceDirectory: workspace.workspaceDirectory,
@@ -1181,21 +1195,11 @@ function WorkspaceRowWithMenu({
const isWorktree = workspace.workspaceKind === "worktree"; const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace; const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => { const redirectAfterArchive = useCallback(() => {
if ( redirectIfArchivingActiveWorkspace({
activeWorkspaceSelection?.serverId !== workspace.serverId || serverId: workspace.serverId,
activeWorkspaceSelection.workspaceId !== workspace.workspaceId workspaceId: workspace.workspaceId,
) { });
return; }, [workspace.serverId, workspace.workspaceId]);
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}),
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorktree = useCallback(() => {
if (isArchiving) { if (isArchiving) {
@@ -1388,27 +1392,13 @@ function NonGitProjectRowWithMenuContent({
}) { }) {
const toast = useToast(); const toast = useToast();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => { const redirectAfterArchive = useCallback(() => {
if ( redirectIfArchivingActiveWorkspace({
activeWorkspaceSelection?.serverId !== workspace.serverId || serverId: workspace.serverId,
activeWorkspaceSelection.workspaceId !== workspace.workspaceId workspaceId: workspace.workspaceId,
) { });
return; }, [workspace.serverId, workspace.workspaceId]);
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any,
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => { const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) { if (isArchivingWorkspace) {
@@ -1535,6 +1525,7 @@ function FlattenedProjectRow({
isProjectActive = false, isProjectActive = false,
onRemoveProject, onRemoveProject,
removeProjectStatus, removeProjectStatus,
selectionEnabled,
}: { }: {
project: SidebarProjectEntry; project: SidebarProjectEntry;
displayName: string; displayName: string;
@@ -1552,15 +1543,27 @@ function FlattenedProjectRow({
isProjectActive?: boolean; isProjectActive?: boolean;
onRemoveProject?: () => void; onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending"; removeProjectStatus?: "idle" | "pending";
selectionEnabled: boolean;
}) { }) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
if (!workspace) {
return null;
}
if (project.projectKind === "directory") { if (project.projectKind === "directory") {
return ( return (
<NonGitProjectRowWithMenu <NonGitProjectRowWithMenu
project={project} project={project}
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={rowModel.workspace} workspace={workspace}
selected={rowModel.selected} selected={selected}
onPress={onPress} onPress={onPress}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge} showShortcutBadge={showShortcutBadge}
@@ -1576,8 +1579,8 @@ function FlattenedProjectRow({
project={project} project={project}
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={rowModel.workspace} workspace={workspace}
selected={rowModel.selected} selected={selected}
chevron={rowModel.chevron} chevron={rowModel.chevron}
onPress={onPress} onPress={onPress}
serverId={serverId} serverId={serverId}
@@ -1599,7 +1602,6 @@ function FlattenedProjectRow({
function WorkspaceRow({ function WorkspaceRow({
workspace, workspace,
selected,
shortcutNumber, shortcutNumber,
showShortcutBadge, showShortcutBadge,
onPress, onPress,
@@ -1608,9 +1610,9 @@ function WorkspaceRow({
dragHandleProps, dragHandleProps,
canCopyBranchName, canCopyBranchName,
isCreating = false, isCreating = false,
selectionEnabled,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null; shortcutNumber: number | null;
showShortcutBadge: boolean; showShortcutBadge: boolean;
onPress: () => void; onPress: () => void;
@@ -1619,10 +1621,22 @@ function WorkspaceRow({
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean; canCopyBranchName: boolean;
isCreating?: boolean; isCreating?: boolean;
selectionEnabled: boolean;
}) { }) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
enabled: selectionEnabled,
});
if (!hydratedWorkspace) {
return null;
}
return ( return (
<WorkspaceRowWithMenu <WorkspaceRowWithMenu
workspace={workspace} workspace={hydratedWorkspace}
selected={selected} selected={selected}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge} showShortcutBadge={showShortcutBadge}
@@ -1642,7 +1656,7 @@ function ProjectBlock({
displayName, displayName,
iconDataUri, iconDataUri,
serverId, serverId,
activeWorkspaceSelection, selectionEnabled,
showShortcutBadges, showShortcutBadges,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
parentGestureRef, parentGestureRef,
@@ -1650,6 +1664,7 @@ function ProjectBlock({
onWorkspacePress, onWorkspacePress,
onWorkspaceReorder, onWorkspaceReorder,
onWorktreeCreated, onWorktreeCreated,
currentPathname,
drag, drag,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
@@ -1661,7 +1676,7 @@ function ProjectBlock({
displayName: string; displayName: string;
iconDataUri: string | null; iconDataUri: string | null;
serverId: string | null; serverId: string | null;
activeWorkspaceSelection: { serverId: string; workspaceId: string } | null; selectionEnabled: boolean;
showShortcutBadges: boolean; showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>; parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -1669,6 +1684,7 @@ function ProjectBlock({
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
onWorktreeCreated?: (workspaceId: string) => void; onWorktreeCreated?: (workspaceId: string) => void;
currentPathname: string | null;
drag: () => void; drag: () => void;
isDragging: boolean; isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
@@ -1680,18 +1696,19 @@ function ProjectBlock({
buildSidebarProjectRowModel({ buildSidebarProjectRowModel({
project, project,
collapsed, collapsed,
serverId,
activeWorkspaceSelection,
}), }),
[activeWorkspaceSelection, collapsed, project, serverId], [collapsed, project],
); );
const isProjectActive = useMemo(() => { const projectWorkspaceIds = useMemo(
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) { () => project.workspaces.map((workspace) => workspace.workspaceId),
return false; [project.workspaces],
} );
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId); const isProjectActive = useIsNavigationProjectActive({
}, [serverId, activeWorkspaceSelection, project.workspaces]); serverId,
workspaceIds: projectWorkspaceIds,
enabled: selectionEnabled,
});
const renderWorkspaceRow = useCallback( const renderWorkspaceRow = useCallback(
( (
@@ -1702,25 +1719,20 @@ function ProjectBlock({
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
}, },
) => { ) => {
const isSelected =
Boolean(serverId) &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === item.workspaceId;
return ( return (
<WorkspaceRow <WorkspaceRow
workspace={item} workspace={item}
selected={isSelected}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges} showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === "git"} canCopyBranchName={project.projectKind === "git"}
isCreating={creatingWorkspaceIds.has(item.workspaceId)} isCreating={creatingWorkspaceIds.has(item.workspaceId)}
selectionEnabled={selectionEnabled}
onPress={() => { onPress={() => {
if (!serverId) { if (!serverId) {
return; return;
} }
onWorkspacePress?.(); onWorkspacePress?.();
navigateToWorkspace(serverId, item.workspaceId); navigateToWorkspace(serverId, item.workspaceId, { currentPathname });
}} }}
drag={input?.drag ?? (() => {})} drag={input?.drag ?? (() => {})}
isDragging={input?.isDragging ?? false} isDragging={input?.isDragging ?? false}
@@ -1729,11 +1741,12 @@ function ProjectBlock({
); );
}, },
[ [
activeWorkspaceSelection,
project.projectKind, project.projectKind,
creatingWorkspaceIds, creatingWorkspaceIds,
currentPathname,
onWorkspacePress, onWorkspacePress,
serverId, serverId,
selectionEnabled,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
], ],
@@ -1820,7 +1833,9 @@ function ProjectBlock({
return; return;
} }
onWorkspacePress?.(); onWorkspacePress?.();
navigateToWorkspace(serverId, rowModel.workspace.workspaceId); navigateToWorkspace(serverId, rowModel.workspace.workspaceId, {
currentPathname,
});
}} }}
serverId={serverId} serverId={serverId}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
@@ -1833,6 +1848,7 @@ function ProjectBlock({
isProjectActive={isProjectActive} isProjectActive={isProjectActive}
onRemoveProject={handleRemoveProject} onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"} removeProjectStatus={isRemovingProject ? "pending" : "idle"}
selectionEnabled={selectionEnabled}
/> />
) : ( ) : (
<> <>
@@ -1891,9 +1907,7 @@ export function SidebarWorkspaceList({
listFooterComponent, listFooterComponent,
parentGestureRef, parentGestureRef,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor();
const pathname = usePathname(); const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set()); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>( const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(), new Map(),
@@ -1909,7 +1923,7 @@ export function SidebarWorkspaceList({
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
[pathname], [pathname],
); );
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null; const selectionEnabled = isWorkspaceRoute;
const projectIconRequests = useMemo(() => { const projectIconRequests = useMemo(() => {
if (!serverId) { if (!serverId) {
@@ -2114,7 +2128,7 @@ export function SidebarWorkspaceList({
displayName={item.projectName} displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId} serverId={serverId}
activeWorkspaceSelection={effectiveActiveWorkspaceSelection} selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef} parentGestureRef={parentGestureRef}
@@ -2122,6 +2136,7 @@ export function SidebarWorkspaceList({
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder} onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated} onWorktreeCreated={handleWorktreeCreated}
currentPathname={pathname}
drag={drag} drag={drag}
isDragging={isActive} isDragging={isActive}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
@@ -2132,13 +2147,14 @@ export function SidebarWorkspaceList({
}, },
[ [
collapsedProjectKeys, collapsedProjectKeys,
effectiveActiveWorkspaceSelection,
handleWorktreeCreated, handleWorktreeCreated,
handleWorkspaceReorder, handleWorkspaceReorder,
onWorkspacePress, onWorkspacePress,
onToggleProjectCollapsed, onToggleProjectCollapsed,
parentGestureRef, parentGestureRef,
pathname,
projectIconByProjectKey, projectIconByProjectKey,
selectionEnabled,
serverId, serverId,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
@@ -2251,6 +2267,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
userSelect: "none",
}, },
projectRowHovered: { projectRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surfaceSidebarHover,
@@ -2394,6 +2411,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "stretch", alignItems: "stretch",
justifyContent: "center", justifyContent: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
userSelect: "none",
}, },
workspaceRowMain: { workspaceRowMain: {
flexDirection: "row", flexDirection: "row",
@@ -2494,22 +2512,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
flexShrink: 0, flexShrink: 0,
}, },
diffStatRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
flexShrink: 0,
},
diffStatAdditions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition,
},
diffStatDeletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion,
},
kebabButton: { kebabButton: {
padding: 2, padding: 2,
borderRadius: 4, borderRadius: 4,
@@ -1,67 +0,0 @@
import { useEffect } from "react";
import { RefreshCw } from "lucide-react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
interface SpinningRefreshIconProps {
spinning: boolean;
size: number;
color: string;
}
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
const rotation = useSharedValue(0);
useEffect(() => {
if (spinning) {
rotation.value = 0;
rotation.value = withRepeat(
withTiming(360, {
duration: 1000,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(rotation);
const remainder = rotation.value % 360;
if (Math.abs(remainder) < 0.001) {
rotation.value = 0;
return;
}
rotation.value = withTiming(360, {
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
easing: Easing.linear,
});
}, [rotation, spinning]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View
style={[
{
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
},
animatedStyle,
]}
>
<RefreshCw size={size} color={color} />
</Animated.View>
);
}
@@ -76,6 +76,7 @@ interface SplitContainerProps {
workspaceKey: string; workspaceKey: string;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
isWorkspaceFocused: boolean;
uiTabs: WorkspaceTab[]; uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null; hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>; setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
@@ -93,7 +94,6 @@ interface SplitContainerProps {
onCreateTerminalTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel; }) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void; onFocusPane: (paneId: string) => void;
@@ -158,11 +158,11 @@ interface SplitPaneViewProps
interface MountedTabSlotProps { interface MountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor; tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean; isVisible: boolean;
isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
paneId: string; paneId: string;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel; }) => WorkspacePaneContentModel;
} }
@@ -170,6 +170,7 @@ interface MountedTabSlotProps {
const MountedTabSlot = memo(function MountedTabSlot({ const MountedTabSlot = memo(function MountedTabSlot({
tabDescriptor, tabDescriptor,
isVisible, isVisible,
isWorkspaceFocused,
isPaneFocused, isPaneFocused,
paneId, paneId,
buildPaneContentModel, buildPaneContentModel,
@@ -178,15 +179,18 @@ const MountedTabSlot = memo(function MountedTabSlot({
() => () =>
buildPaneContentModel({ buildPaneContentModel({
paneId, paneId,
isPaneFocused,
tab: tabDescriptor, tab: tabDescriptor,
}), }),
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor], [buildPaneContentModel, paneId, tabDescriptor],
); );
return ( return (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}> <View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} /> <WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View> </View>
); );
}); });
@@ -241,6 +245,7 @@ export function SplitContainer({
workspaceKey, workspaceKey,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isWorkspaceFocused,
uiTabs, uiTabs,
hoveredCloseTabKey, hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
@@ -511,6 +516,7 @@ export function SplitContainer({
focusedPaneId={layout.focusedPaneId} focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -633,6 +639,7 @@ function SplitNodeView({
focusedPaneId, focusedPaneId,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey, hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
@@ -668,6 +675,7 @@ function SplitNodeView({
isFocused={node.pane.id === focusedPaneId} isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -718,6 +726,7 @@ function SplitNodeView({
focusedPaneId={focusedPaneId} focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -767,6 +776,7 @@ function SplitPaneView({
isFocused, isFocused,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey, hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
@@ -916,6 +926,7 @@ function SplitPaneView({
key={tabId} key={tabId}
tabDescriptor={tabDescriptor} tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId} isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId} isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id} paneId={pane.id}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
@@ -0,0 +1,128 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
import { createWebStreamStrategy } from "./stream-strategy-web";
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
id: `message-${index}`,
text: `Message ${index}`,
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
};
}
function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
return {
renderHistoryVirtualizedRow: (item) => {
onRowRender();
return <div style={{ height: 24 }}>{item.id}</div>;
},
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
renderLiveHeadRow: (item) => <div>{item.id}</div>,
renderLiveAuxiliary: () => null,
};
}
describe("createWebStreamStrategy", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
let originalOffsetHeight: PropertyDescriptor | undefined;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
Object.defineProperty(globalThis, "ResizeObserver", {
value: class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
configurable: true,
});
originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn();
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
return 24;
},
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
if (originalScrollTo) {
HTMLElement.prototype.scrollTo = originalScrollTo;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
}
if (originalOffsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
vi.restoreAllMocks();
});
it("mounts virtualized history without recursive row measurement updates", () => {
const rowRenderCount = vi.fn();
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyVirtualized = Array.from({ length: 16 }, (_, index) => userMessage(index));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized,
historyMounted: [],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: true,
hasMountedHistory: false,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(rowRenderCount),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
});
@@ -1,4 +1,4 @@
import { import React, {
Fragment, Fragment,
type CSSProperties, type CSSProperties,
useCallback, useCallback,
@@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null); const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null); const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null); const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const showDesktopWebScrollbar = !isMobileBreakpoint; const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar, enabled: showDesktopWebScrollbar,
@@ -181,6 +182,38 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize(); const virtualTotalSize = rowVirtualizer.getTotalSize();
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
if (!node) {
rowVirtualizer.measureElement(null);
return;
}
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
const existingFrame = pendingFrames.get(node);
if (existingFrame !== undefined) {
window.cancelAnimationFrame(existingFrame);
}
const frame = window.requestAnimationFrame(() => {
pendingFrames.delete(node);
if (node.isConnected) {
rowVirtualizer.measureElement(node);
}
});
pendingFrames.set(node, frame);
},
[rowVirtualizer],
);
useEffect(() => {
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
return () => {
for (const frame of pendingFrames.values()) {
window.cancelAnimationFrame(frame);
}
pendingFrames.clear();
};
}, []);
const cancelPendingStickToBottom = useCallback(() => { const cancelPendingStickToBottom = useCallback(() => {
const pendingFrame = pendingAutoScrollFrameRef.current; const pendingFrame = pendingAutoScrollFrameRef.current;
if (pendingFrame !== null) { if (pendingFrame !== null) {
@@ -696,7 +729,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div <div
key={virtualRow.key} key={virtualRow.key}
data-index={virtualRow.index} data-index={virtualRow.index}
ref={rowVirtualizer.measureElement} ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)} style={renderVirtualRowStyle(virtualRow.start)}
> >
{renderHistoryVirtualizedRow( {renderHistoryVirtualizedRow(
+46 -42
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -27,7 +26,9 @@ interface TerminalPaneProps {
serverId: string; serverId: string;
cwd: string; cwd: string;
terminalId: string; terminalId: string;
isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
onOpenFileExplorer: () => void;
} }
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
@@ -82,15 +83,20 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`; return `${input.serverId}:${input.cwd}`;
} }
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) { export function TerminalPane({
const isScreenFocused = useIsFocused(); serverId,
cwd,
terminalId,
isWorkspaceFocused,
isPaneFocused,
onOpenFileExplorer,
}: TerminalPaneProps) {
const isAppVisible = useAppVisible(); const isAppVisible = useAppVisible();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const swipeGesturesEnabled = isMobile && mobileView === "agent"; const swipeGesturesEnabled = isMobile && mobileView === "agent";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding", mode: "padding",
@@ -131,7 +137,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) { if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null; lastAutoFocusKeyRef.current = null;
return; return;
} }
@@ -143,14 +149,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
lastAutoFocusKeyRef.current = nextFocusKey; lastAutoFocusKeyRef.current = nextFocusKey;
requestTerminalFocus(); requestTerminalFocus();
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]); }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => { useEffect(() => {
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) { if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
lastReportedSizeRef.current = null; lastReportedSizeRef.current = null;
requestTerminalReflow(); requestTerminalReflow();
} }
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]); }, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => { const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) { if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -187,29 +193,27 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
[pulseKeyboardRefits], [pulseKeyboardRefits],
); );
useFocusEffect( useEffect(() => {
useCallback(() => { if (!isWorkspaceFocused || !terminalId) {
if (!terminalId) { return;
return; }
} // Focus transitions can temporarily report stale dimensions.
// Navigation transitions can temporarily report stale dimensions. // Pulse forced refits so xterm fills the pane when returning to a terminal.
// Pulse forced refits so xterm fills the pane when returning to an agent. const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => setTimeout(() => {
setTimeout(() => { requestTerminalReflow();
requestTerminalReflow(); }, delayMs),
}, delayMs), );
);
return () => { return () => {
for (const handle of timeoutHandles) { for (const handle of timeoutHandles) {
clearTimeout(handle); clearTimeout(handle);
} }
}; };
}, [requestTerminalReflow, terminalId]), }, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
);
useEffect(() => { useEffect(() => {
if (!client || !isConnected || !isScreenFocused) { if (!client || !isConnected || !isWorkspaceFocused) {
return; return;
} }
@@ -232,7 +236,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
}); });
setModifiers({ ...EMPTY_MODIFIERS }); setModifiers({ ...EMPTY_MODIFIERS });
}); });
}, [client, isConnected, workspaceTerminalSession.snapshots]); }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
useEffect(() => { useEffect(() => {
lastReportedSizeRef.current = null; lastReportedSizeRef.current = null;
@@ -257,14 +261,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
client, client,
getPreferredSize: () => lastReportedSizeRef.current, getPreferredSize: () => lastReportedSizeRef.current,
onOutput: ({ terminalId, text }) => { onOutput: ({ terminalId, text }) => {
if (!isScreenFocused || terminalIdRef.current !== terminalId) { if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
return; return;
} }
emulatorRef.current?.writeOutput(text); emulatorRef.current?.writeOutput(text);
}, },
onSnapshot: ({ terminalId, state }) => { onSnapshot: ({ terminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId, state }); workspaceTerminalSession.snapshots.set({ terminalId, state });
if (!isScreenFocused || terminalIdRef.current !== terminalId) { if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
return; return;
} }
emulatorRef.current?.renderSnapshot(state); emulatorRef.current?.renderSnapshot(state);
@@ -274,7 +278,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
streamControllerRef.current = controller; streamControllerRef.current = controller;
controller.setTerminal({ controller.setTerminal({
terminalId: isScreenFocused ? terminalIdRef.current : null, terminalId: isWorkspaceFocused ? terminalIdRef.current : null,
}); });
return () => { return () => {
@@ -287,17 +291,17 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
client, client,
handleStreamControllerStatus, handleStreamControllerStatus,
isConnected, isConnected,
isScreenFocused, isWorkspaceFocused,
workspaceTerminalSession.snapshots, workspaceTerminalSession.snapshots,
]); ]);
useEffect(() => { useEffect(() => {
pendingTerminalInputRef.current = []; pendingTerminalInputRef.current = [];
const nextTerminalId = isScreenFocused ? terminalId : null; const nextTerminalId = isWorkspaceFocused ? terminalId : null;
streamControllerRef.current?.setTerminal({ streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId, terminalId: nextTerminalId,
}); });
}, [isScreenFocused, terminalId]); }, [isWorkspaceFocused, terminalId]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current; const queue = pendingTerminalInputRef.current;
@@ -477,7 +481,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
!client || !client ||
!terminalId || !terminalId ||
!isPaneFocused || !isPaneFocused ||
!isScreenFocused || !isWorkspaceFocused ||
!isAppVisible || !isAppVisible ||
rows <= 0 || rows <= 0 ||
cols <= 0 cols <= 0
@@ -550,7 +554,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
return ( return (
<Animated.View style={[styles.container, keyboardPaddingStyle]}> <Animated.View style={[styles.container, keyboardPaddingStyle]}>
<View style={styles.outputContainer}> <View style={styles.outputContainer}>
{isScreenFocused ? ( {isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}> <View style={styles.terminalGestureContainer}>
<TerminalEmulator <TerminalEmulator
ref={emulatorRef} ref={emulatorRef}
@@ -573,13 +577,13 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
if (!swipeGesturesEnabled) { if (!swipeGesturesEnabled) {
return; return;
} }
openAgentList(); showMobileAgentList();
}} }}
onSwipeLeft={() => { onSwipeLeft={() => {
if (!swipeGesturesEnabled) { if (!swipeGesturesEnabled) {
return; return;
} }
openFileExplorer(); onOpenFileExplorer();
}} }}
onInput={handleTerminalData} onInput={handleTerminalData}
onResize={handleTerminalResize} onResize={handleTerminalResize}
@@ -594,7 +598,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
<View style={styles.terminalGestureContainer} /> <View style={styles.terminalGestureContainer} />
)} )}
{isAttaching && isScreenFocused ? ( {isAttaching && isWorkspaceFocused ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading"> <View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View> </View>
+21 -22
View File
@@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
IsolatedBottomSheetModal,
type IsolatedBottomSheetModalRef,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
@@ -59,18 +62,26 @@ interface ToolCallSheetProviderProps {
} }
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
const bottomSheetRef = useRef<BottomSheetModal>(null); const { theme } = useUnistyles();
const bottomSheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const hasPresentedRef = useRef(false);
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null); const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
const snapPoints = useMemo(() => ["60%", "95%"], []); const snapPoints = useMemo(() => ["60%", "95%"], []);
const openToolCall = useCallback((data: ToolCallSheetData) => { const openToolCall = useCallback((data: ToolCallSheetData) => {
setSheetData(data); setSheetData(data);
if (hasPresentedRef.current) {
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
return;
}
hasPresentedRef.current = true;
bottomSheetRef.current?.present(); bottomSheetRef.current?.present();
}, []); }, []);
const closeToolCall = useCallback(() => { const closeToolCall = useCallback(() => {
bottomSheetRef.current?.dismiss(); bottomSheetRef.current?.close();
}, []); }, []);
const handleSheetChange = useCallback((index: number) => { const handleSheetChange = useCallback((index: number) => {
@@ -94,20 +105,19 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
return ( return (
<ToolCallSheetContext.Provider value={contextValue}> <ToolCallSheetContext.Provider value={contextValue}>
{children} {children}
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
stackBehavior="replace"
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundStyle={styles.sheetBackground} backgroundComponent={CustomSheetBackground}
handleIndicatorStyle={styles.handleIndicator} handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
> >
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />} {sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</BottomSheetModal> </IsolatedBottomSheetModal>
</ToolCallSheetContext.Provider> </ToolCallSheetContext.Provider>
); );
} }
@@ -120,6 +130,7 @@ interface ToolCallSheetContentProps {
} }
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { theme } = useUnistyles();
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data; const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
const IconComponent = resolveToolCallIcon(toolName, detail); const IconComponent = resolveToolCallIcon(toolName, detail);
@@ -129,13 +140,13 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
<IconComponent size={20} color={styles.headerIcon.color} /> <IconComponent size={20} color={theme.colors.foreground} />
<Text style={styles.headerTitle} numberOfLines={1}> <Text style={styles.headerTitle} numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
</View> </View>
<Pressable onPress={onClose} style={styles.closeButton}> <Pressable onPress={onClose} style={styles.closeButton}>
<X size={20} color={styles.closeIcon.color} /> <X size={20} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
@@ -155,12 +166,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
// ----- Styles ----- // ----- Styles -----
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
sheetBackground: {
backgroundColor: theme.colors.surface2,
},
handleIndicator: {
backgroundColor: theme.colors.palette.zinc[600],
},
container: { container: {
flex: 1, flex: 1,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -180,9 +185,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
flex: 1, flex: 1,
}, },
headerIcon: {
color: theme.colors.foreground,
},
headerTitle: { headerTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
@@ -192,9 +194,6 @@ const styles = StyleSheet.create((theme) => ({
closeButton: { closeButton: {
padding: theme.spacing[2], padding: theme.spacing[2],
}, },
closeIcon: {
color: theme.colors.foregroundMuted,
},
content: { content: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { File, Folder } from "lucide-react-native"; import { File, Folder } from "lucide-react-native";
import { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils"; import { getAutocompleteScrollOffset } from "./autocomplete-utils";
export interface AutocompleteOption { export interface AutocompleteOption {
@@ -88,6 +88,39 @@ describe("filterAndRankComboboxOptions", () => {
it("returns empty when nothing matches", () => { it("returns empty when nothing matches", () => {
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]); expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
}); });
it("ranks word-boundary matches above mid-word substring matches", () => {
const items = [
{ id: "happy", label: "happy" },
{ id: "a/py", label: "a/py" },
];
const result = filterAndRankComboboxOptions(items, "py");
expect(result.map((o) => o.id)).toEqual(["a/py", "happy"]);
});
it("interleaves mixed branches and PRs by match quality", () => {
const items = [
{ id: "branch:feat/api-login", label: "feat/api-login" },
{ id: "branch:feat/pi-direct-sdk", label: "feat/pi-direct-sdk" },
{ id: "github-pr:202", label: "#202 feat(server): replace Pi ACP with direct SDK provider" },
{ id: "github-pr:355", label: "#355 feat: add LaTeX math formula rendering" },
];
const result = filterAndRankComboboxOptions(items, "pi");
expect(result.map((o) => o.id)).toEqual([
"branch:feat/pi-direct-sdk",
"github-pr:202",
"branch:feat/api-login",
]);
});
it("ranks PR-number matches via word-boundary on #", () => {
const items = [
{ id: "github-pr:202", label: "#202 some title" },
{ id: "github-pr:1202", label: "#1202 another title" },
];
const result = filterAndRankComboboxOptions(items, "202");
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
});
}); });
describe("combobox above-search ordering", () => { describe("combobox above-search ordering", () => {
@@ -1,3 +1,5 @@
import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match";
export type ComboboxOptionKind = "directory" | "file"; export type ComboboxOptionKind = "directory" | "file";
export interface ComboboxOptionModel { export interface ComboboxOptionModel {
@@ -7,6 +9,23 @@ export interface ComboboxOptionModel {
kind?: ComboboxOptionKind; kind?: ComboboxOptionKind;
} }
const DESCRIPTION_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const labelScore = scoreMatch(search, opt.label);
const idScore = scoreMatch(search, opt.id);
let best: MatchScore | null = null;
if (labelScore) best = labelScore;
if (idScore && (!best || compareMatchScores(idScore, best) < 0)) {
best = idScore;
}
if (best) return best;
if (opt.description && opt.description.toLowerCase().includes(search)) {
return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 };
}
return null;
}
export interface BuildVisibleComboboxOptionsInput { export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[]; options: ComboboxOptionModel[];
searchQuery: string; searchQuery: string;
@@ -40,21 +59,17 @@ export function filterAndRankComboboxOptions(
search: string, search: string,
): ComboboxOptionModel[] { ): ComboboxOptionModel[] {
if (!search) return options; if (!search) return options;
return options const scored: { opt: ComboboxOptionModel; score: MatchScore }[] = [];
.filter( for (const opt of options) {
(opt) => const score = scoreOption(opt, search);
opt.label.toLowerCase().includes(search) || if (score) scored.push({ opt, score });
opt.id.toLowerCase().includes(search) || }
opt.description?.toLowerCase().includes(search), scored.sort((a, b) => {
) const cmp = compareMatchScores(a.score, b.score);
.sort((a, b) => { if (cmp !== 0) return cmp;
const aPrefix = return a.opt.label.localeCompare(b.opt.label);
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search); });
const bPrefix = return scored.map((entry) => entry.opt);
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
return 0;
});
} }
export function buildVisibleComboboxOptions( export function buildVisibleComboboxOptions(
+32 -49
View File
@@ -14,7 +14,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetTextInput, BottomSheetTextInput,
@@ -38,6 +37,10 @@ import {
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const IS_WEB = isWeb; const IS_WEB = isWeb;
@@ -66,8 +69,6 @@ export interface ComboboxProps {
title?: string; title?: string;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: "top-start" | "bottom-start";
/** /**
* Prevents an initial frame at 0,0 by hiding desktop content until floating * Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -101,7 +102,21 @@ function toNumericStyleValue(value: unknown): number | null {
} }
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />; const { theme } = useUnistyles();
return (
<Animated.View
pointerEvents="none"
style={[
style,
{
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
]}
/>
);
} }
export interface SearchInputProps { export interface SearchInputProps {
@@ -257,8 +272,6 @@ export function Combobox({
title = "Select", title = "Select",
open, open,
onOpenChange, onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start", desktopPlacement = "top-start",
desktopPreventInitialFlash = true, desktopPreventInitialFlash = true,
desktopMinWidth, desktopMinWidth,
@@ -268,12 +281,12 @@ export function Combobox({
anchorRef, anchorRef,
children, children,
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null, null,
@@ -459,32 +472,11 @@ export function Combobox({
} }
: floatingStyles; : floatingStyles;
useEffect(() => { const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
if (!isMobile) return; visible: isOpen,
if (isOpen) { isEnabled: isMobile,
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) { onClose: handleClose,
bottomSheetRef.current?.snapToIndex(0); });
} else {
hasPresentedBottomSheetRef.current = true;
bottomSheetRef.current?.present();
}
} else {
if (enableDismissOnClose === false) {
bottomSheetRef.current?.close();
} else {
bottomSheetRef.current?.dismiss();
}
}
}, [enableDismissOnClose, isOpen, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -703,7 +695,7 @@ export function Combobox({
if (isMobile) { if (isMobile) {
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
@@ -711,15 +703,15 @@ export function Combobox({
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground} backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle} handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={styles.comboboxTitle}>{title}</Text> <Text key={titleColor} style={[styles.comboboxTitle, { color: titleColor }]}>
{title}
</Text>
</View> </View>
{stickyHeader} {stickyHeader}
{!children && searchable ? searchInput : null} {!children && searchable ? searchInput : null}
@@ -730,7 +722,7 @@ export function Combobox({
> >
{content} {content}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -909,14 +901,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
bottomSheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
},
bottomSheetHeader: { bottomSheetHeader: {
paddingHorizontal: theme.spacing[6], paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
@@ -924,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({
comboboxTitle: { comboboxTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left", textAlign: "left",
}, },
comboboxScrollContent: { comboboxScrollContent: {
+21 -23
View File
@@ -30,8 +30,12 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -349,11 +353,11 @@ export function ContextMenuContent({
testID?: string; testID?: string;
}>): ReactElement | null { }>): ReactElement | null {
const context = useContextMenuContext("ContextMenuContent"); const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle(); const webScrollbarStyle = useWebScrollbarStyle();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet"; const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context; const { open, setOpen, triggerRef, anchorRect } = context;
const bottomSheetRef = useRef<BottomSheetModal>(null);
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []); const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -363,23 +367,11 @@ export function ContextMenuContent({
setOpen(false); setOpen(false);
}, [setOpen]); }, [setOpen]);
useEffect(() => { const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
if (!useMobileSheet) return; visible: open,
if (open) { isEnabled: useMobileSheet,
bottomSheetRef.current?.present(); onClose: handleClose,
return; });
}
bottomSheetRef.current?.dismiss();
}, [open, useMobileSheet]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const renderSheetBackdrop = useCallback( const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => ( (props: ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -467,7 +459,7 @@ export function ContextMenuContent({
if (useMobileSheet) { if (useMobileSheet) {
return ( return (
<ContextMenuContext.Provider value={context}> <ContextMenuContext.Provider value={context}>
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
index={0} index={0}
snapPoints={sheetSnapPoints} snapPoints={sheetSnapPoints}
@@ -475,8 +467,14 @@ export function ContextMenuContent({
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderSheetBackdrop} backdropComponent={renderSheetBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundStyle={styles.sheetBackground} backgroundStyle={[
handleIndicatorStyle={styles.sheetHandle} styles.sheetBackground,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
handleIndicatorStyle={[styles.sheetHandle, { backgroundColor: theme.colors.surface2 }]}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
@@ -488,7 +486,7 @@ export function ContextMenuContent({
> >
{children} {children}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
</ContextMenuContext.Provider> </ContextMenuContext.Provider>
); );
} }
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { cleanup, render } from "@testing-library/react";
import React from "react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const { modalMethods, modalProps } = vi.hoisted(() => ({
modalMethods: {
present: vi.fn(),
close: vi.fn(),
snapToIndex: vi.fn(),
dismiss: vi.fn(),
},
modalProps: vi.fn(),
}));
vi.mock("@gorhom/bottom-sheet", async () => {
const React = await import("react");
const MockBottomSheetModal = React.forwardRef(
(props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => {
modalProps(props);
React.useImperativeHandle(ref, () => modalMethods);
return React.createElement(
"div",
{ "data-testid": "bottom-sheet" },
props.children as ReactNode,
);
},
);
return {
BottomSheetModal: MockBottomSheetModal,
BottomSheetModalProvider: ({ children }: { children: ReactNode }) =>
React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children),
};
});
vi.mock("@gorhom/portal", async () => {
const React = await import("react");
return {
Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) =>
React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children),
};
});
function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
return (
<IsolatedBottomSheetModal
ref={sheetRef}
index={0}
snapPoints={["50%"]}
onChange={handleSheetChange}
>
<div>Sheet content</div>
</IsolatedBottomSheetModal>
);
}
describe("IsolatedBottomSheetModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("forces sheet isolation and keeps modal content mounted while hidden", () => {
const onClose = vi.fn();
const { getByTestId, rerender } = render(<Harness visible={false} onClose={onClose} />);
expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
expect(modalProps).toHaveBeenLastCalledWith(
expect.objectContaining({
enableDismissOnClose: false,
stackBehavior: "replace",
}),
);
expect(modalMethods.present).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
expect(modalMethods.close).toHaveBeenCalledTimes(1);
expect(modalMethods.dismiss).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
expect(modalMethods.snapToIndex).toHaveBeenCalledWith(0);
});
it("only reports a user close when the sheet was visible", () => {
const onClose = vi.fn();
const { rerender } = render(<Harness visible onClose={onClose} />);
const latestProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
latestProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
const closedProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
closedProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
const { getAllByTestId } = render(
<IsolatedBottomSheetModal index={0} snapPoints={["90%"]}>
<IsolatedBottomSheetModal index={0} snapPoints={["60%"]} onChange={() => {}}>
<div>Nested model picker</div>
</IsolatedBottomSheetModal>
</IsolatedBottomSheetModal>,
);
expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1);
expect(modalProps).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
stackBehavior: "replace",
}),
);
expect(modalProps).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
stackBehavior: "push",
}),
);
});
});
@@ -0,0 +1,106 @@
import {
BottomSheetModal as GorhomBottomSheetModal,
BottomSheetModalProvider,
type BottomSheetModalProps,
} from "@gorhom/bottom-sheet";
import { Portal } from "@gorhom/portal";
import React, { createContext, useContext } from "react";
import { forwardRef, useCallback, useEffect, useRef } from "react";
import type { ElementRef } from "react";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior"
>;
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
const IsolatedBottomSheetScopeContext = createContext(false);
export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) {
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext);
const { children, ...bottomSheetProps } = props;
const scopedChildren =
typeof children === "function" ? (
(input: { data?: unknown }) => (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children(input) as React.ReactNode}
</IsolatedBottomSheetScopeContext.Provider>
)
) : (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children}
</IsolatedBottomSheetScopeContext.Provider>
);
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={ref}
enableDismissOnClose={false}
stackBehavior={isNestedSheet ? "push" : "replace"}
>
{scopedChildren}
</GorhomBottomSheetModal>
);
if (isNestedSheet) {
return modal;
}
return (
<Portal hostName="root">
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
</Portal>
);
});
export function useIsolatedBottomSheetVisibility({
visible,
isEnabled,
onClose,
}: {
visible: boolean;
isEnabled?: boolean;
onClose: () => void;
}) {
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const hasPresentedRef = useRef(false);
useEffect(() => {
if (isEnabled === false) return;
if (visible) {
if (hasPresentedRef.current) {
sheetRef.current?.snapToIndex(0);
return;
}
hasPresentedRef.current = true;
sheetRef.current?.present();
return;
}
if (hasPresentedRef.current) {
sheetRef.current?.close();
}
}, [isEnabled, visible]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1 && visible) {
onClose();
}
},
[onClose, visible],
);
return {
sheetRef,
handleSheetChange,
};
}
@@ -0,0 +1,10 @@
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
interface LoadingSpinnerProps {
color: string;
size?: ActivityIndicatorProps["size"];
}
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
return <ActivityIndicator size={size} color={color} />;
}
@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
testID, testID,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd; const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd; const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md; const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return ( return (
<View style={[styles.container, style]} testID={testID}> <View style={[styles.container, containerSizeStyle, style]} testID={testID}>
{options.map((option) => { {options.map((option) => {
const isSelected = option.value === value; const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted; const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "stretch",
maxWidth: "100%", maxWidth: "100%",
gap: 4, backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
}, },
segment: { segment: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0, flexShrink: 0,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: "transparent",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
segmentSm: { segmentSm: {
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[4],
}, },
segmentMd: { segmentMd: {
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[6],
}, },
segmentSelected: { segmentSelected: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border, shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
}, },
segmentHover: { segmentHover: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
}, },
segmentPressed: { segmentPressed: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
}, },
segmentDisabled: { segmentDisabled: {
opacity: theme.opacity[50], opacity: theme.opacity[50],
@@ -135,10 +143,10 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
labelSm: { labelSm: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
}, },
labelMd: { labelMd: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
}, },
labelSelected: { labelSelected: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -0,0 +1,116 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { Pressable, Text } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Tooltip, TooltipTrigger } from "./tooltip";
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@gorhom/portal", () => ({
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@gorhom/bottom-sheet", () => ({
useBottomSheetModalInternal: () => null,
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
FadeIn: {},
FadeOut: {},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) => styles,
},
}));
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 renderTrigger({
childDisabled,
onPress,
}: {
childDisabled: boolean;
onPress: () => void;
}): void {
act(() => {
root?.render(
<Tooltip>
<TooltipTrigger asChild>
<Pressable disabled={childDisabled} onPress={onPress} testID="trigger">
<Text>Send</Text>
</Pressable>
</TooltipTrigger>
</Tooltip>,
);
});
}
function pressTrigger(): void {
const trigger = container?.querySelector('[data-testid="trigger"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
describe("TooltipTrigger", () => {
it("keeps an asChild trigger disabled when the child is disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: true, onPress });
pressTrigger();
expect(onPress).not.toHaveBeenCalled();
});
it("keeps an asChild trigger interactive when the child is not disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: false, onPress });
pressTrigger();
expect(onPress).toHaveBeenCalledTimes(1);
});
});
@@ -60,6 +60,28 @@ function useTooltipContext(componentName: string): TooltipContextValue {
return ctx; return ctx;
} }
// Tooltips should open on hover or keyboard focus, not when focus is restored
// programmatically (e.g. when a Modal closes and returns focus to its opener).
// Track the last input modality on web so TooltipTrigger can ignore focus
// events that weren't keyboard-driven. Native has no equivalent scenario.
let lastInputWasKeyboard = false;
if (isWeb && typeof window !== "undefined") {
const markKeyboard = () => {
lastInputWasKeyboard = true;
};
const markPointer = () => {
lastInputWasKeyboard = false;
};
window.addEventListener("keydown", markKeyboard, true);
window.addEventListener("mousedown", markPointer, true);
window.addEventListener("pointerdown", markPointer, true);
window.addEventListener("touchstart", markPointer, true);
}
function shouldOpenOnFocus(): boolean {
return !isWeb || lastInputWasKeyboard;
}
function composeEventHandlers<E>( function composeEventHandlers<E>(
original?: (event: E) => void, original?: (event: E) => void,
injected?: (event: E) => void, injected?: (event: E) => void,
@@ -304,6 +326,7 @@ export function TooltipTrigger({
(e: any) => { (e: any) => {
onFocus?.(e); onFocus?.(e);
if (!ctx.enabled || disabled) return; if (!ctx.enabled || disabled) return;
if (!shouldOpenOnFocus()) return;
clearOpenTimer(); clearOpenTimer();
ctx.setOpen(true); ctx.setOpen(true);
}, },
@@ -363,6 +386,7 @@ export function TooltipTrigger({
const mergedProps = { const mergedProps = {
...childProps, ...childProps,
...triggerProps, ...triggerProps,
disabled: childProps.disabled || disabled,
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn), onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut), onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus, handleFocus), onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
+97 -93
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, ScrollView } from "react-native"; import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
@@ -21,8 +21,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
const ThemedScrollView = withUnistyles(ScrollView);
type WelcomeAction = { type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link"; key: "scan-qr" | "direct-connection" | "paste-pairing-link";
label: string; label: string;
@@ -33,13 +31,15 @@ type WelcomeAction = {
}; };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
scrollView: { root: {
flex: 1, flex: 1,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
scrollView: {
flex: 1,
},
container: { container: {
flexGrow: 1, flexGrow: 1,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[6], padding: theme.spacing[6],
paddingBottom: 0, paddingBottom: 0,
alignItems: "center", alignItems: "center",
@@ -309,97 +309,101 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const showHostList = hosts.length > 0 && !anyOnlineServerId; const showHostList = hosts.length > 0 && !anyOnlineServerId;
return ( return (
<ThemedScrollView <View style={styles.root}>
style={styles.scrollView} <ScrollView
contentContainerStyle={[ style={styles.scrollView}
styles.container, contentContainerStyle={[
{ paddingBottom: theme.spacing[6] + insets.bottom }, styles.container,
]} { paddingBottom: theme.spacing[6] + insets.bottom },
showsVerticalScrollIndicator={false} ]}
testID="welcome-screen" showsVerticalScrollIndicator={false}
> testID="welcome-screen"
<View style={styles.content}> >
<PaseoLogo size={96} /> <View style={styles.content}>
<View style={styles.copyBlock}> <PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text> <View style={styles.copyBlock}>
{showHostList ? ( <Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connecting to your hosts…</Text> {showHostList ? (
) : ( <Text style={styles.subtitle}>Connecting to your hosts…</Text>
<> ) : (
<Text style={styles.subtitle}>Connect your computer to get started</Text> <>
{isNative ? ( <Text style={styles.subtitle}>Connect your computer to get started</Text>
<Pressable {isNative ? (
style={styles.setupLink} <Pressable
onPress={() => openExternalUrl("https://paseo.sh")} style={styles.setupLink}
> onPress={() => openExternalUrl("https://paseo.sh")}
<Text style={styles.setupLinkText}>paseo.sh</Text> >
<ExternalLink size={14} color={theme.colors.accent} /> <Text style={styles.setupLinkText}>paseo.sh</Text>
</Pressable> <ExternalLink size={14} color={theme.colors.accent} />
) : null} </Pressable>
</> ) : null}
)} </>
</View> )}
<View style={styles.actions}>
{actions.map((action) => {
const Icon = action.icon;
return (
<Pressable
key={action.key}
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
onPress={action.onPress}
testID={action.testID}
>
<Icon
size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
{action.label}
</Text>
</Pressable>
);
})}
</View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View> </View>
)}
<Button <View style={styles.actions}>
variant="ghost" {actions.map((action) => {
size="sm" const Icon = action.icon;
leftIcon={Settings} return (
onPress={() => router.push("/settings")} <Pressable
style={styles.settingsButton} key={action.key}
testID="welcome-open-settings" style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
> onPress={action.onPress}
Settings testID={action.testID}
</Button> >
</View> <Icon
<Text style={styles.versionLabel}>{appVersionText}</Text> size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text
style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}
>
{action.label}
</Text>
</Pressable>
);
})}
</View>
<AddHostModal {showHostList && (
visible={isDirectOpen} <View style={styles.hostList}>
onClose={() => setIsDirectOpen(false)} {hosts.map((host) => (
onSaved={({ profile, serverId }) => { <HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
onHostAdded?.(profile); ))}
finishOnboarding(serverId); </View>
}} )}
/>
<PairLinkModal <Button
visible={isPasteLinkOpen} variant="ghost"
onClose={() => setIsPasteLinkOpen(false)} size="sm"
onSaved={({ profile, serverId }) => { leftIcon={Settings}
onHostAdded?.(profile); onPress={() => router.push("/settings")}
finishOnboarding(serverId); style={styles.settingsButton}
}} testID="welcome-open-settings"
/> >
</ThemedScrollView> Settings
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
/>
</ScrollView>
</View>
); );
} }
@@ -19,6 +19,7 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"
import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list"; import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
interface Rect { interface Rect {
x: number; x: number;
@@ -99,10 +100,10 @@ function WorkspaceHoverCardDesktop({
children, children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement { }: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
const triggerRef = useRef<View>(null); const triggerRef = useRef<View>(null);
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerHoveredRef = useRef(false); const triggerHoveredRef = useRef(false);
const contentHoveredRef = useRef(false);
const hasContent = prHint !== null || !!workspace.diffStat; const hasContent = prHint !== null || !!workspace.diffStat;
@@ -114,14 +115,12 @@ function WorkspaceHoverCardDesktop({
}, []); }, []);
const scheduleClose = useCallback(() => { const scheduleClose = useCallback(() => {
clearGraceTimer(); if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => { graceTimerRef.current = setTimeout(() => {
if (!triggerHoveredRef.current && !contentHoveredRef.current) {
setOpen(false);
}
graceTimerRef.current = null; graceTimerRef.current = null;
setOpen(false);
}, HOVER_GRACE_MS); }, HOVER_GRACE_MS);
}, [clearGraceTimer]); }, []);
const handleTriggerEnter = useCallback(() => { const handleTriggerEnter = useCallback(() => {
triggerHoveredRef.current = true; triggerHoveredRef.current = true;
@@ -133,18 +132,18 @@ function WorkspaceHoverCardDesktop({
const handleTriggerLeave = useCallback(() => { const handleTriggerLeave = useCallback(() => {
triggerHoveredRef.current = false; triggerHoveredRef.current = false;
scheduleClose(); }, []);
}, [scheduleClose]);
const handleContentEnter = useCallback(() => { // While open, the safe zone covers trigger + content + the bridge between
contentHoveredRef.current = true; // them. Close only fires when the pointer leaves the safe zone; re-entering
clearGraceTimer(); // it (including the bridge) cancels the pending close.
}, [clearGraceTimer]); useHoverSafeZone({
enabled: open,
const handleContentLeave = useCallback(() => { triggerRef,
contentHoveredRef.current = false; contentRef,
scheduleClose(); onEnterSafeZone: clearGraceTimer,
}, [scheduleClose]); onLeaveSafeZone: scheduleClose,
});
// Close when drag starts // Close when drag starts
useEffect(() => { useEffect(() => {
@@ -182,8 +181,7 @@ function WorkspaceHoverCardDesktop({
workspace={workspace} workspace={workspace}
prHint={prHint} prHint={prHint}
triggerRef={triggerRef} triggerRef={triggerRef}
onContentEnter={handleContentEnter} contentRef={contentRef}
onContentLeave={handleContentLeave}
/> />
) : null} ) : null}
</View> </View>
@@ -194,14 +192,12 @@ function WorkspaceHoverCardContent({
workspace, workspace,
prHint, prHint,
triggerRef, triggerRef,
onContentEnter, contentRef,
onContentLeave,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
prHint: PrHint | null; prHint: PrHint | null;
triggerRef: React.RefObject<View | null>; triggerRef: React.RefObject<View | null>;
onContentEnter: () => void; contentRef: React.RefObject<View | null>;
onContentLeave: () => void;
}): ReactElement | null { }): ReactElement | null {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const bottomSheetInternal = useBottomSheetModalInternal(true); const bottomSheetInternal = useBottomSheetModalInternal(true);
@@ -250,12 +246,11 @@ function WorkspaceHoverCardContent({
<Portal hostName={bottomSheetInternal?.hostName}> <Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}> <View pointerEvents="box-none" style={styles.portalOverlay}>
<Animated.View <Animated.View
ref={contentRef}
entering={FadeIn.duration(80)} entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)} exiting={FadeOut.duration(80)}
collapsable={false} collapsable={false}
onLayout={handleLayout} onLayout={handleLayout}
onPointerEnter={onContentEnter}
onPointerLeave={onContentLeave}
accessibilityRole="menu" accessibilityRole="menu"
accessibilityLabel="Workspace scripts" accessibilityLabel="Workspace scripts"
testID="workspace-hover-card" testID="workspace-hover-card"
@@ -311,14 +306,24 @@ function WorkspaceHoverCardContent({
badgeLabel = `${checks.length} passed`; badgeLabel = `${checks.length} passed`;
} }
const iconColor = hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return ( return (
<> <>
{hovered ? ( {hovered ? (
<ExternalLink size={12} color={theme.colors.foregroundMuted} /> <ExternalLink size={12} color={iconColor} />
) : ( ) : (
<GitHubIcon size={12} color={theme.colors.foregroundMuted} /> <GitHubIcon size={12} color={iconColor} />
)} )}
<Text style={styles.checksSummaryLabel}>Checks</Text> <Text
style={[
styles.checksSummaryLabel,
hovered && styles.checksSummaryLabelHovered,
]}
>
Checks
</Text>
<View style={styles.checksSummaryCounts}> <View style={styles.checksSummaryCounts}>
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} /> <View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
<Text style={[styles.checksStatusText, { color: badgeColor }]}> <Text style={[styles.checksStatusText, { color: badgeColor }]}>
@@ -400,6 +405,9 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
checksSummaryLabelHovered: {
color: theme.colors.foreground,
},
checksSummaryCounts: { checksSummaryCounts: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
if (!composerState) { if (!composerState) {
throw new Error("Workspace setup composer state is required"); throw new Error("Workspace setup composer state is required");
} }
if (!composerState.selectedProvider) {
throw new Error("Select a model");
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments); const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(wirePayload.images); const encodedImages = await encodeImages(wirePayload.images);
@@ -0,0 +1,108 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
function workspaceDescriptor(input: {
id: string;
name?: string;
projectId?: string;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo/main",
workspaceDirectory: `/repo/main/${input.id}`,
projectKind: "git",
workspaceKind: "worktree",
name: input.name ?? input.id,
status: "done",
diffStat: null,
scripts: [],
};
}
describe("WorkspaceShortcutTargetsSubscriber", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useKeyboardShortcutsStore.setState({
sidebarShortcutWorkspaceTargets: [],
});
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(),
});
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
act(() => {
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
"srv",
new Map([
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
["ws-2", workspaceDescriptor({ id: "ws-2", name: "Workspace 2" })],
]),
);
useSessionStore.getState().setHasHydratedWorkspaces("srv", true);
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
act(() => {
useSessionStore.getState().clearSession("srv");
});
});
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "srv", workspaceId: "ws-1" },
{ serverId: "srv", workspaceId: "ws-2" },
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
});
});
@@ -0,0 +1,47 @@
import { useEffect, useMemo } from "react";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({
enabled,
serverId,
}: {
enabled: boolean;
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets,
);
const shortcutModel = useMemo(
() =>
buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
}),
[collapsedProjectKeys, projects],
);
useEffect(() => {
if (!enabled || !serverId) {
setSidebarShortcutWorkspaceTargets([]);
return;
}
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
}, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
useEffect(() => {
return () => {
setSidebarShortcutWorkspaceTargets([]);
};
}, [setSidebarShortcutWorkspaceTargets]);
return null;
}
+4
View File
@@ -10,6 +10,7 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
// Default is cross-platform. Gate only when you must: // Default is cross-platform. Gate only when you must:
// isWeb → DOM APIs (document, window, <div>, addEventListener) // isWeb → DOM APIs (document, window, <div>, addEventListener)
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera) // isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
// isDev → Development-only diagnostics and instrumentation
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates) // isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
// //
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts. // For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
@@ -22,6 +23,9 @@ export const isWeb = Platform.OS === "web";
/** iOS or Android — the JS runtime is React Native. */ /** iOS or Android — the JS runtime is React Native. */
export const isNative = Platform.OS !== "web"; export const isNative = Platform.OS !== "web";
/** Development build/runtime — true in Metro dev bundles, false in production. */
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Electron detection (cached — only caches `true`, keeps checking if false // Electron detection (cached — only caches `true`, keeps checking if false
// because the desktop bridge may load after initial module evaluation) // because the desktop bridge may load after initial module evaluation)
@@ -1,9 +1,17 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import { useWindowDimensions } from "react-native"; import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { import {
getRightSidebarAnimationTargets, getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation, shouldSyncSidebarAnimation,
@@ -30,11 +38,9 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) { export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions(); const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) =>
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0 // Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth }); const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
@@ -93,7 +99,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
backdropOpacity.value = targets.backdropOpacity; backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = () => { const animateToOpen = useCallback(() => {
"worklet"; "worklet";
translateX.value = withTiming(0, { translateX.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
@@ -103,9 +109,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}; }, [translateX, backdropOpacity]);
const animateToClose = () => { const animateToClose = useCallback(() => {
"worklet"; "worklet";
translateX.value = withTiming(windowWidth, { translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
@@ -115,22 +121,25 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}; }, [translateX, backdropOpacity, windowWidth]);
const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing],
);
return ( return (
<ExplorerSidebarAnimationContext.Provider <ExplorerSidebarAnimationContext.Provider value={value}>
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}}
>
{children} {children}
</ExplorerSidebarAnimationContext.Provider> </ExplorerSidebarAnimationContext.Provider>
); );
@@ -26,9 +26,9 @@ function workspace(input: {
const runningScript: WorkspaceScriptPayload = { const runningScript: WorkspaceScriptPayload = {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "main.web.localhost", hostname: "web.paseo.localhost",
port: 3000, port: 3000,
proxyUrl: "http://main.web.localhost:6767", proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
+10 -12
View File
@@ -34,7 +34,6 @@ import {
type Agent, type Agent,
type SessionState, type SessionState,
type WorkspaceDescriptor, type WorkspaceDescriptor,
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor, normalizeWorkspaceDescriptor,
} from "@/stores/session-store"; } from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
@@ -57,6 +56,8 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
// Re-export types from session-store and draft-store for backward compatibility // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -258,6 +259,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const voiceAudioEngine = useVoiceAudioEngineOptional(); const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
// Zustand store actions // Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession); const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -345,7 +347,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
const workspaces = new Map<string, WorkspaceDescriptor>(); const workspaces = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null; let cursor: string | null = null;
let includeSubscribe = options?.subscribe ?? false; let includeSubscribe = options?.subscribe ?? false;
@@ -361,13 +362,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) { for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry); const workspace = normalizeWorkspaceDescriptor(entry);
workspaces.set( workspaces.set(workspace.id, workspace);
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
} }
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
@@ -1769,9 +1764,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
void client.setAgentMode(agentId, modeId).catch((error) => { void client.setAgentMode(agentId, modeId).catch((error) => {
console.error("[Session] Failed to set agent mode:", error); console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
}); });
}, },
[client], [client, toast],
); );
const setAgentModel = useCallback( const setAgentModel = useCallback(
@@ -1782,9 +1778,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
void client.setAgentModel(agentId, modelId).catch((error) => { void client.setAgentModel(agentId, modelId).catch((error) => {
console.error("[Session] Failed to set agent model:", error); console.error("[Session] Failed to set agent model:", error);
toast.error(toErrorMessage(error));
}); });
}, },
[client], [client, toast],
); );
const setAgentThinkingOption = useCallback( const setAgentThinkingOption = useCallback(
@@ -1795,9 +1792,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error); console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
}); });
}, },
[client], [client, toast],
); );
const respondToPermission = useCallback( const respondToPermission = useCallback(
@@ -1,3 +1,4 @@
import equal from "fast-deep-equal";
import type { ScriptStatusUpdateMessage } from "@server/shared/messages"; import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
@@ -19,6 +20,10 @@ export function patchWorkspaceScripts(
return workspaces; return workspaces;
} }
if (equal(existing.scripts, update.scripts)) {
return workspaces;
}
const next = new Map(workspaces); const next = new Map(workspaces);
next.set(workspaceKey, { next.set(workspaceKey, {
...existing, ...existing,
@@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { import {
getLeftSidebarAnimationTargets, getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation, shouldSyncSidebarAnimation,
@@ -37,11 +37,9 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) { export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions(); const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) =>
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state // Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth }); const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn<() => Promise<unknown>>(async () => 12_000),
}));
vi.mock("@/desktop/electron/invoke", () => ({
invokeDesktopCommand: invokeDesktopCommandMock,
}));
import { getDesktopSystemIdleTimeMs } from "./idle";
describe("getDesktopSystemIdleTimeMs", () => {
afterEach(() => {
invokeDesktopCommandMock.mockReset();
vi.restoreAllMocks();
});
it("invokes the desktop idle command and returns the millisecond value", async () => {
invokeDesktopCommandMock.mockResolvedValueOnce(4_200);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time");
expect(idleTimeMs).toBe(4_200);
});
it("returns null and logs once when IPC rejects", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const error = new Error("ipc failed");
invokeDesktopCommandMock.mockRejectedValueOnce(error);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error);
});
it("returns null and logs once when IPC returns null", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
invokeDesktopCommandMock.mockResolvedValueOnce(null);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null);
});
it("returns null and logs once when IPC returns NaN", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN);
});
it("returns null and logs once when IPC returns a negative value", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
invokeDesktopCommandMock.mockResolvedValueOnce(-1);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1);
});
it("returns 0 when IPC returns zero", async () => {
invokeDesktopCommandMock.mockResolvedValueOnce(0);
const idleTimeMs = await getDesktopSystemIdleTimeMs();
expect(idleTimeMs).toBe(0);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time";
function isValidIdleTimeMs(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
export async function getDesktopSystemIdleTimeMs(): Promise<number | null> {
try {
const idleTimeMs = await invokeDesktopCommand<unknown>(DESKTOP_SYSTEM_IDLE_COMMAND);
if (!isValidIdleTimeMs(idleTimeMs)) {
console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs);
return null;
}
return idleTimeMs;
} catch (error) {
console.warn("[DesktopIdle] Failed to read system idle time", error);
return null;
}
}
@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
message: string; message: string;
} }
export type DesktopReleaseChannel = "stable" | "beta";
export interface LocalDaemonUpdateResult { export interface LocalDaemonUpdateResult {
exitCode: number; exitCode: number;
stdout: string; stdout: string;
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
return parseLocalDaemonVersionResult(result); return parseLocalDaemonVersionResult(result);
} }
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> { export async function checkDesktopAppUpdate({
const result = await invokeDesktopCommand<unknown>("check_app_update"); releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
if (!isRecord(result)) { if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates."); throw new Error("Unexpected response while checking desktop updates.");
} }
@@ -85,8 +91,12 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
}; };
} }
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> { export async function installDesktopAppUpdate({
const result = await invokeDesktopCommand<unknown>("install_app_update"); releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
if (!isRecord(result)) { if (!isRecord(result)) {
throw new Error("Unexpected response while installing desktop update."); throw new Error("Unexpected response while installing desktop update.");
} }
@@ -7,6 +7,7 @@ import {
type DesktopAppUpdateCheckResult, type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult, type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
import { useAppSettings } from "@/hooks/use-settings";
export type DesktopAppUpdateStatus = export type DesktopAppUpdateStatus =
| "idle" | "idle"
@@ -83,6 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection(); const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const requestVersionRef = useRef(0); const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle"); const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null); const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await checkDesktopAppUpdate(); const result = await checkDesktopAppUpdate({ releaseChannel });
if (requestVersion !== requestVersionRef.current) { if (requestVersion !== requestVersionRef.current) {
return result; return result;
} }
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null; return null;
} }
}, },
[isDesktopApp], [isDesktopApp, releaseChannel],
); );
useEffect(() => {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => { useEffect(() => {
if (!isDesktopApp || status !== "pending") { if (!isDesktopApp || status !== "pending") {
return undefined; return undefined;
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await installDesktopAppUpdate(); const result = await installDesktopAppUpdate({ releaseChannel });
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
if (result.installed) { if (result.installed) {
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message); setErrorMessage(message);
return null; return null;
} }
}, [isDesktopApp]); }, [isDesktopApp, releaseChannel]);
return { return {
isDesktopApp, isDesktopApp,
@@ -1,24 +0,0 @@
export type CheckoutStatusRevalidationParams = {
serverId: string;
cwd: string;
isOpen: boolean;
explorerTab: string;
};
export function checkoutStatusRevalidationKey(
params: CheckoutStatusRevalidationParams,
): string | null {
if (!params.cwd) return null;
if (!params.isOpen) return null;
if (params.explorerTab !== "changes") return null;
return `${params.serverId}:${params.cwd}`;
}
export function nextCheckoutStatusRefetchDecision(
prevKey: string | null,
nextKey: string | null,
): { nextSeenKey: string | null; shouldRefetch: boolean } {
if (!nextKey) return { nextSeenKey: null, shouldRefetch: false };
if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false };
return { nextSeenKey: nextKey, shouldRefetch: true };
}
@@ -0,0 +1,15 @@
import { useMemo } from "react";
import { usePathname } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { resolveActiveHost } from "@/utils/active-host";
export function useActiveHost() {
const pathname = usePathname();
const hosts = useHosts();
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
}
export function useActiveServerId(): string | null {
return useActiveHost()?.serverId ?? null;
}
@@ -0,0 +1,58 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
export function useActiveWorktreeNewAction() {
const selection = useNavigationActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const workingDir = useSessionStore((state) => {
if (!serverId || !workspaceId) {
return null;
}
const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId);
if (!workspace || workspace.projectKind !== "git") {
return null;
}
return workspace.projectRootPath;
});
const displayName = useSessionStore((state) => {
if (!serverId || !workspaceId) {
return null;
}
const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId);
if (!workspace || workspace.projectKind !== "git") {
return null;
}
return workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId);
});
const handle = useCallback(() => {
if (!serverId || !workingDir) {
return false;
}
router.navigate(
buildHostNewWorkspaceRoute(serverId, workingDir, {
displayName: displayName ?? undefined,
}) as never,
);
return true;
}, [serverId, workingDir, displayName]);
useKeyboardActionHandler({
handlerId: "worktree-new-active",
actions: WORKTREE_NEW_ACTIONS,
enabled: serverId !== null && workingDir !== null,
priority: 0,
handle,
});
}
@@ -0,0 +1,209 @@
import { renderHook, waitFor } from "@testing-library/react";
import { JSDOM } from "jsdom";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { FormPreferences } from "./use-form-preferences";
import { useAgentFormState } from "./use-agent-form-state";
const { mocks } = vi.hoisted(() => ({
mocks: {
preferences: {} as FormPreferences,
isPreferencesLoading: false,
snapshotEntries: [] as ProviderSnapshotEntry[],
updatePreferences: vi.fn(),
refreshSnapshot: vi.fn(),
refetchSnapshotIfStale: vi.fn(),
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "host-1" }],
}));
vi.mock("./use-form-preferences", async (importOriginal) => {
const actual = await importOriginal<typeof import("./use-form-preferences")>();
return {
...actual,
useFormPreferences: () => ({
preferences: mocks.preferences,
isLoading: mocks.isPreferencesLoading,
updatePreferences: mocks.updatePreferences,
}),
};
});
vi.mock("./use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries: mocks.snapshotEntries,
isLoading: false,
isFetching: false,
isRefreshing: false,
error: null,
supportsSnapshot: true,
refresh: mocks.refreshSnapshot,
refetchIfStale: mocks.refetchSnapshotIfStale,
}),
}));
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
writable: true,
});
});
describe("useAgentFormState live preference hydration", () => {
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: dom.window,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
mocks.preferences = {};
mocks.isPreferencesLoading = false;
mocks.snapshotEntries = [
{
provider: "codex",
status: "ready",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{
id: "full-access",
label: "Full Access",
icon: "ShieldAlert",
colorTier: "dangerous",
},
],
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "low",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "xhigh", label: "XHigh" },
],
},
],
},
{
provider: "claude",
status: "ready",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
models: [
{
provider: "claude",
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
isDefault: true,
},
],
},
];
mocks.updatePreferences.mockReset();
mocks.refreshSnapshot.mockReset();
mocks.refetchSnapshotIfStale.mockReset();
});
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
mocks.preferences = {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
},
};
const { result, rerender } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4": "low",
},
},
},
};
rerender();
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
});
it("uses the latest preferences when a separate composer hydrates later", async () => {
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
},
};
const { result } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
expect(result.current.selectedMode).toBe("default");
});
});
});
@@ -146,6 +146,112 @@ describe("useAgentFormState", () => {
}); });
}); });
describe("__private__.mergeSelectedComposerPreferences", () => {
it("stores the selected model for the selected provider", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
},
},
});
});
it("preserves existing provider preferences when the selected model changes", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "claude",
providerPreferences: {
codex: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
});
});
it("stores mode and thinking preferences without dropping the selected model", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
},
},
},
},
provider: "codex",
updates: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "low",
"gpt-5.4": "xhigh",
},
},
},
});
});
});
describe("__private__.resolveFormState", () => { describe("__private__.resolveFormState", () => {
const codexModels: AgentModelDefinition[] = [ const codexModels: AgentModelDefinition[] = [
{ {
@@ -161,6 +267,37 @@ describe("useAgentFormState", () => {
}, },
]; ];
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("does not auto-select a model on fresh drafts without preferences", () => { it("does not auto-select a model on fresh drafts without preferences", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
@@ -374,7 +511,7 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("low"); expect(resolved.thinkingOptionId).toBe("low");
}); });
it("resolves provider only from allowed provider map", () => { it("clears an invalid provider instead of falling back to the first allowed provider", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
{ provider: "codex" }, { provider: "codex" },
@@ -399,7 +536,121 @@ describe("useAgentFormState", () => {
claudeProviderMap, claudeProviderMap,
); );
expect(resolved.provider).toBe("claude"); expect(resolved.provider).toBeNull();
});
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
const loadingEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "loading",
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(loadingEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: loadingEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: true,
model: true,
thinkingOptionId: true,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("full-access");
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: unavailableEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
}); });
it("does not force fallback provider when allowed provider map is empty", () => { it("does not force fallback provider when allowed provider map is empty", () => {

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