Compare commits

...

65 Commits

Author SHA1 Message Date
Mohamed Boudra
a1821d5863 chore(release): cut 0.1.106 2026-07-12 10:42:56 +02:00
Mohamed Boudra
5db070a4d9 docs: promote 0.1.106 release notes 2026-07-12 10:40:19 +02:00
Matt Cowger
461be47c91 fix(server): await OpenCode event stream shutdown (#2015)
* fix(server): await OpenCode event stream shutdown

* Update opencode-agent.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-12 16:20:13 +08:00
Mohamed Boudra
92d7066601 fix(app): prevent white flashes between workspace routes
Nested native stacks fell back to the navigation library's light background during cross-stack swaps. Share the active app surface color across every stack without subscribing route layouts to all theme runtime changes.
2026-07-12 00:27:40 +02:00
Mohamed Boudra
d28e174b38 ops(relay): require manual deployment 2026-07-12 00:09:15 +02:00
paseo-ai[bot]
a257a1c5f6 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-11 18:59:45 +00:00
Mohamed Boudra
144f951a79 ops(relay): merge production bridge to Fly so release deploys preserve it 2026-07-11 20:55:56 +02:00
Mohamed Boudra
51fea4b7e0 docs: clarify mobile beta release note 2026-07-11 20:47:48 +02:00
Mohamed Boudra
e98ba1beb1 chore(release): cut 0.1.106-beta.1 2026-07-11 20:45:30 +02:00
Mohamed Boudra
3bbbfbc880 docs: add 0.1.106 beta release notes 2026-07-11 20:43:14 +02:00
Mohamed Boudra
f86226a196 chore: update ACP provider catalog 2026-07-11 20:42:47 +02:00
Mohamed Boudra
120842264e fix(app): do not promote archive as primary for regular Git checkouts 2026-07-11 18:58:24 +02:00
Mohamed Boudra
a017ddfc29 fix(app): hide header Git actions for non-Git workspaces 2026-07-11 18:34:26 +02:00
Mohamed Boudra
03dcda7c41 Archive any workspace from the app (#2003)
* feat(app): expose archive for every workspace

Workspace actions now archive one workspace by id. The daemon remains responsible for backing-specific cleanup, including removing a managed worktree only after its final workspace is archived.

* refactor(app): clarify archive descriptor name

* fix(app): preserve workspace archive safety

* fix(app): allow archive without upstream

* refactor(app): remove stale archive callback

* test(app): cover workspace archive ownership
2026-07-11 18:22:31 +02:00
Matt Cowger
ada0955334 fix(server): preserve Pi MCP config during injection (#1990) 2026-07-11 23:56:42 +08:00
Mohamed Boudra
ca5c786ccf Stop reconnects from reviving agents during shutdown (#1977)
* fix(server): stop reconnects reviving agents during shutdown

Shutdown previously took its agent snapshot while reconnects and in-flight registrations could still add or repersist agents. Gate ingress and registration first so closed agents cannot return during teardown.

* fix(server): drain late sessions during shutdown
2026-07-11 23:48:56 +08:00
Mohamed Boudra
5ae53c7e55 ops(relay): bridge production traffic to Fly 2026-07-11 16:49:15 +02:00
Mohamed Boudra
c800c6a3f1 Allow Codex MCP approval prompts (#2001)
* fix(codex): surface MCP approval prompts

* fix(codex): return accepted elicitation content

* fix(codex): reject unsupported MCP URL elicitations

* fix(codex): clear resolved MCP approvals

* fix(codex): decline unsupported MCP forms

* fix(codex): cancel resolved MCP prompts

* fix(codex): ignore resolved MCP approvals

* fix(codex): request MCP form elicitations
2026-07-11 22:15:17 +08:00
Mohamed Boudra
adc9b3b43c Preserve loader behavior while reducing hidden work (#1996)
* fix(app): preserve loader behavior in retained panels

Keep the running footer mounted across panel switches. Inactive loaders stop their animation work, then realign to the original wall-clock phase before they become visible.

* perf(app): remove inactive loader wrapper worklet

* perf(app): pause hidden turn elapsed timers

* perf(app): reduce loader animation work

Keep one wall-clock-aligned clock for visible loaders and detach retained
hidden loaders without unmounting them. Preserve the 950 ms sequence and
reduced-motion behavior.
2026-07-11 13:25:24 +02:00
Mohamed Boudra
5d18edd31e Verify real agent workflows through the hosted relay (#1998)
* test(relay): prove live daemon client workflow

* test(relay): reduce live acceptance variance

* test(relay): run acceptance through real codex
2026-07-11 12:00:42 +02:00
Mohamed Boudra
6c764f211a fix(app): stop updates after chat teardown (#1997)
Plain chat views started the history virtualizer even without virtualized rows. Its delayed scroll callback could outlive the mounted view and make the app test job fail during environment teardown.
2026-07-11 09:12:28 +00:00
Mohamed Boudra
28a27b02d3 Prevent Android chats from freezing or going blank (#1989)
* fix(app): stabilize streamed chat rendering

Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths.

* fix(app): stabilize retained native panels

Keep retained workspace roots in a stable native order and make active
panels present in the selection commit. Hidden panels now stop expensive
subscriptions and animations without losing mounted state.

* fix(app): harden retained panel activity
2026-07-11 15:39:14 +08:00
paseo-ai[bot]
860fcb2e35 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-10 11:03:47 +00:00
Mohamed Boudra
4c72bf0209 chore(release): cut 0.1.105 2026-07-10 12:58:31 +02:00
Mohamed Boudra
8c639fd796 fix(app): stabilize project picker E2E selectors
Project picker tests had depended on a placeholder changed for fuzzy search. Use a dedicated input test ID so product copy no longer breaks the selectors.
2026-07-10 12:39:40 +02:00
Mohamed Boudra
aa656772ef docs: add 0.1.105 changelog 2026-07-10 12:24:58 +02:00
Mohamed Boudra
4dd783c36a docs: date Pi stats compatibility shim 2026-07-10 12:24:45 +02:00
Mohamed Boudra
e6a316456d chore: refresh ACP provider catalog 2026-07-10 12:24:35 +02:00
Mohamed Boudra
3c2339f841 fix(app): clarify fuzzy project search 2026-07-10 12:01:50 +02:00
Mohamed Boudra
61a9117a6b Restore fuzzy project folder search and desktop browsing (#1968)
* fix(projects): restore folder search and browsing

* fix(projects): preserve fuzzy search edge cases

* test(projects): move picker cleanup into fixture

* fix(projects): preserve absolute path descendants

* test(server): compare canonical search paths

* fix(projects): anchor root path suggestions

* fix(projects): anchor Windows root suggestions

* fix(projects): anchor directory search paths

* test(server): canonicalize directory search root

* fix(app): anchor rooted basename suggestions

* refactor(search): unify directory suggestions

Use one parameterized daemon search engine for project paths and workspace entries. Keep local recommendations as a small client overlay and remove unnecessary response metadata.

* fix(search): preserve filesystem root semantics

Accept absolute queries through configured root aliases while keeping traversal and output canonical. Keep blank suffix browsing matcher-independent and avoid metadata-keyed listing caches where Windows cannot invalidate them reliably.
2026-07-10 17:52:55 +08:00
Mohamed Boudra
1f0285c06b fix(app): restore mobile left sidebar swipe (#1976)
Keep the shared sidebar model outside the root gesture detector so Gesture Handler attaches to the native surface on Android/Fabric.
2026-07-10 11:34:47 +02:00
Mohamed Boudra
a689ccb0af Fix Codex status and streaming during sub-agent work (#1967)
* fix(codex): keep parent turns active during sub-agent work

Codex multi-agent activity is thread-scoped. Treating child events as root
activity could end the foreground turn early and splice child output into
parent messages.

Route legacy and current activity shapes through stable child identities and
preserve message boundaries across live and resumed turns.

* fix(codex): preserve sub-agent details across stream chunks

Keep empty activity paths explicit and normalize the synthetic assistant
boundary for every chunk in the active message.

* fix(codex): route legacy child events by thread

* fix(codex): stabilize sub-agent activity lifecycles

* fix(codex): retain child tool activity and terminal state
2026-07-10 09:41:32 +02:00
Mohamed Boudra
cac71ee8f5 Hide keyboard shortcut badges in workspace menu on native (#1964)
* Hide keyboard shortcut badges in workspace menu on native

The archive item's shortcut badge (e.g. Ctrl+Shift+⌫) rendered in the
workspace kebab/context menu on mobile, where there's no physical
keyboard to invoke it. Gate the trailing badge behind !isNative in both
kebab menu variants.

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

* fix(app): hide native shortcuts in every workspace menu

Workspace menus were duplicated across project and status views, so the status path missed the native gate. Centralize the menu so every sidebar view shares the same shortcut policy.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:42:36 +02:00
Mohamed Boudra
a743a22496 Keep large sidebars responsive (#1966)
* perf(app): keep large sidebars responsive

Sidebar rows rescanned every agent for each session update, multiplying work with both workspace and agent count. Build workspace activity once and share the derived sidebar model so updates scale with the underlying collections.

* test(app): use valid agent fixture for workspace restore

* refactor(app): keep sidebar model surface focused
2026-07-10 06:12:56 +08:00
Mohamed Boudra
b2c6476817 Choose non-fast variants for ACP models (#1952)
* fix(providers): expose ACP fast-mode selection

Provider wrappers now forward draft feature lookups to inner ACP clients, and ACP adapters can add initialize metadata for model parameterization. Fast-mode config options flow through the existing provider feature path.

* test(providers): cover wrapped ACP command lookup

* fix(providers): submit Cursor non-fast defaults

* fix(app): preserve parameterized model preferences

* fix(providers): preserve Cursor ACP feature params

* fix(providers): keep Cursor legacy model fallbacks

* fix(providers): preserve Cursor model parameter switches

* refactor(providers): simplify ACP model feature negotiation
2026-07-10 05:16:17 +08:00
Mohamed Boudra
6af1379357 Stop mobile sidebars getting stuck out of sync (#1953)
* fix(app): keep mobile sidebars synced with store

Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

* fix(app): keep mobile sidebars stable through interruptions

Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
2026-07-09 23:08:56 +02:00
Octopus
15d3091104 Update MiniMax structured generation fallback (#1955)
Prefer MiniMax M3 for metadata generation and align the related docs/tests with the newer provider model.

Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-09 18:26:12 +00:00
Mohamed Boudra
ebaecace2b Keep Pi text-only sessions from bricking on images (#1960)
* fix(pi): avoid raw images for text-only models

Pi text-only models reject image content, and Pi persists the rejected raw image in session history. Materialize those image prompts to local files and send path hints while preserving raw images for vision-capable models.

* fix(pi): require explicit vision support

Default unknown Pi model capabilities to the text-hint path so raw image forwarding only happens for models that declare image input. Also keep materialized image files in a private per-user temp directory with restrictive file modes.

* fix(pi): harden materialized image temp dirs

* test(server): accept per-user image attachment dirs

* fix(server): use private temp dirs for materialized images

* fix(server): refresh missing image temp cache

* test(server): clean image temp dirs
2026-07-10 01:34:34 +08:00
Matt Cowger
d96d599466 Show reasoning output automatically when enabled (#1943)
* feat(app): add auto-expand reasoning setting

* chore: add cross-env mise tool

* fix(app): stabilize web export and settings detail placement

* Revert "chore: add cross-env mise tool"

This reverts commit f56a367e0c.

* perf(app): narrow settings reads in agent stream

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-08 21:39:03 +00:00
Mohamed Boudra
29a3261dd6 Restore compact Changes diff controls (#1945)
* feat(app): restore compact Changes diff controls

Keep the folder tree behind a view toggle while preserving the flat file list as the default.

Move secondary diff options into the overflow menu so the Changes toolbar stays compact.

* fix(app): mirror whitespace diff option copy

* fix(app): polish diff controls review follow-up
2026-07-08 21:20:51 +00:00
Slava Goltser
c7406a415d fix(app): handle CancelledError from cancelled provider usage queries (#1885)
Move canFetch guard before first await so disabled refresh bails out synchronously instead of entering async path. Add .catch() swallow for fire-and-forget call to prevent unhandled promise rejections when react-query cancels pending queries during rapid tooltip open/close cycles.
2026-07-09 05:00:57 +08:00
Slava Goltser
202c417347 fix(pi): add get_session_stats fallback from get_state for old OMP binaries (#1886)
* fix(pi): add get_session_stats fallback from get_state for old OMP binaries

Older Oh My Pi binaries don't support the get_session_stats RPC command,
leaving session usage stats blank in Paseo. This adds a compat layer in
cli-runtime.ts where getSessionStats() tries get_session_stats first, then
falls back to extracting context window usage from get_state.

The fallback provides contextUsage.tokens and contextWindow even when the
binary lacks full token/cost reporting — enough to keep the context meter
working. Token counts and cost still require the newer RPC.

Added three tests:
- Fallback triggers when get_session_stats is unsupported (throws)
- No fallback when get_session_stats returns valid data
- Returns empty object when both commands fail

* fix(pi): use nullish check instead of truthy for cost field in getSessionStats
2026-07-09 05:00:17 +08:00
Christoph Leiter
3259a69466 feat(app): folder tree in the Changes view (#1918)
Group changed files in the Changes view into a collapsible directory
tree instead of a flat list, on every form factor — consistent with the
Files explorer, which already renders a directory tree on mobile.
Folders are collapsible and expanded by default, single-child directory
chains are compressed into one row (packages/app/src), and folder rows
show aggregate +/- stats.

Client-only, built entirely from the existing ParsedDiffFile paths:

- diff-tree.ts: pure build/compress/flatten of the directory hierarchy,
  keyed by full uncompressed dir path so collapse state survives
  compression changes as the diff mutates.
- diff-flat-items.ts: pure buildDiffFlatItems + sumHeightsBefore. The
  function retains a flat mode (showTree=false) as a tested seam for a
  possible future flat/tree toggle; sticky header indices are
  file-headers-only, derived from the post-collapse list.
- panel-store: diffCollapsedFoldersByWorkspace slice (collapsed-set, so
  folders default to expanded), persisted via version 12. The set is
  reconciled against the current diff so folders that leave and reappear
  default to expanded and the stored set stays bounded.

Presentation primitives (chevron, indent guides, indent constant) are
extracted into components/tree-primitives and shared with the Files
explorer, which drops its private copies (and a banned useUnistyles
call site). The two trees now render identical rows; only the
tree-building stays separate (server listings vs. diff paths).

The toolbar's expand-all stays file-diffs-only (renamed
allFileDiffsExpanded); a separate control collapses/expands all folders.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:55:30 +08:00
Mohamed Boudra
cab0e4b3f8 Show a recovery screen for app render errors (#1924)
* fix(app): show recovery screen for render failures

Render a retryable fallback with useful error details when the routed app tree throws during render instead of leaving the app blank.

* test(app): cover root error details without mounting

Move caught-value formatting into a pure module, cover AggregateError fields without duplicate output, and remove the component-mounted test.

* fix(app): keep empty error details caught

* fix(app): preserve nullable aggregate errors

* fix(app): guard malformed error details

* fix(app): handle recursive error causes

* fix(app): guard error detail formatting failures

* fix(app): localize root error fallback

* fix(app): keep root error fallback on existing i18n
2026-07-09 00:06:29 +08:00
Mohamed Boudra
6ca1f4dac5 docs: browser is desktop-only, drop phone mention 2026-07-08 13:59:27 +02:00
Mohamed Boudra
6f3ab98986 docs: restructure browser IA, add 'when to use' page 2026-07-08 13:56:57 +02:00
Mohamed Boudra
251d88a37a Stop Android audio interruptions crashing voice mode (#1941)
* fix(android): handle denied voice audio focus

Android can deny voice-mode audio focus while another system audio owner, such as an incoming call, is active. Treat that as an interruption so resume does not crash the app and JS stops voice mode coherently.

* fix(voice): keep interruption state consistent

Make native interruption handling payload-aware so iOS resume events do not stop the shared JS runtime, avoid ending voice mode for duck-only Android focus changes, and roll host voice mode back if capture fails after startup enabled it.

* fix(voice): avoid duplicate interruption handling

Keep exactly one Android audio-focus request active at a time and leave JS capture state untouched for non-terminal interruption events.

* fix(android): abandon blocked audio focus requests

When Android denies or blocks voice audio focus, abandon the pending focus request so delayed focus gain cannot arrive after voice mode has already stopped.

* fix(voice): handle interrupted dictation capture

Stop Android resume from replaying after recording restart fails, and propagate native blocked interruptions into dictation so users do not remain in a stale recording state.
2026-07-08 19:45:02 +08:00
Mohamed Boudra
c72bcb49e5 docs: restructure schedules IA (app-first), add Orchestration category 2026-07-08 13:43:59 +02:00
paseo-ai[bot]
1a03171f0c fix: update lockfile signatures and Nix hash [skip ci] 2026-07-08 10:52:05 +00:00
Mohamed Boudra
f3fdeab131 chore(release): cut 0.1.104 2026-07-08 12:47:07 +02:00
Mohamed Boudra
fdc6a4a57d docs: changelog for v0.1.104 2026-07-08 12:44:08 +02:00
Mohamed Boudra
ed67e6e0f1 fix(app): preserve renamed host labels (#1940)
Daemon-advertised hostnames still name new/default hosts, but connection refreshes and re-pairing no longer overwrite labels that users already changed.
2026-07-08 10:20:06 +00:00
Mohamed Boudra
5ec7ef9771 perf(agent-tools): shrink MCP tool catalog context cost (#1939)
Drop advertised MCP output schemas (no supported harness forwards them to the model) and remove the redundant wait_for_agent tool. Core agent tool catalog drops ~20.5k -> ~5.6k estimated tokens; ~24.9k -> ~9k with browser tools. Runtime structuredContent is preserved via server-side text serialization. Also corrects the agent-wait timeout guidance for blocking callers.
2026-07-08 11:39:39 +02:00
Mohamed Boudra
4fb70668f2 Manage the built-in daemon from one place in settings (#1938)
* feat(settings): consolidate daemon management into the host page

The app-level Daemon section and the localhost host page rendered the
same daemon card in two places. The host page is now the single home:
the Host sidebar group leads with Overview, and a desktop-only "Enable
built-in daemon" option in the host switcher recovers a removed
localhost connection. Old settings/daemon deep links redirect
(COMPAT-tagged).

* fix(settings): hide daemon recovery option when the server id probe fails

Review feedback: !query.isLoading treated an errored IPC probe as
resolved, so the recovery affordance could show without knowing the
local server id. Replace the resolved-boolean hook with a single
discriminated state hook (loading | error | resolved) — errors hide the
affordance and fall back to the settings root in the redirect. Also
collapses the two query subscriptions per caller into one.

* fix(settings): keep daemon recovery reachable when no hosts remain

Review feedback: removing localhost as the only host takes the settings
sidebar down its no-host branch, which never mounts the host picker —
so the "Enable built-in daemon" option was unreachable in the most
common recovery case. The sidebar now owns the single hook call, passes
it to the picker, and renders the same action as a sidebar row next to
"Add host" when no hosts exist.

* test(e2e): update settings host header to Overview

Test drift: the host section's sidebar/header label was intentionally
renamed to "Overview"; the spec still asserted the old "Host" title.
Also assert the retired app-level Daemon row stays absent.
2026-07-08 10:20:30 +02:00
paseo-ai[bot]
06c3161008 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-07 21:06:43 +00:00
Mohamed Boudra
a85139c3f3 chore(release): cut 0.1.104-beta.5 2026-07-07 23:01:34 +02:00
Mohamed Boudra
be756c9004 docs: changelog for v0.1.104-beta.5 2026-07-07 22:58:17 +02:00
Mohamed Boudra
8d0b3e69e8 chore(providers): update ACP catalog pins 2026-07-07 22:57:49 +02:00
Mohamed Boudra
d9b65e269f Scheduled runs each get their own workspace in the sidebar (#1934)
* feat(schedules): per-run workspaces, isolation and archive controls

Scheduled agents never appeared in the sidebar: each schedule reused one
stamped workspace and archived the run's agent on completion. Now every run
mints its own workspace — the entity the sidebar shows — with per-schedule
controls for isolation (local or worktree) and whether to archive that
workspace when the run finishes.

The schedule form is rebuilt on a non-React form model (open/commands/close)
to end the flicker, edit-into-create contamination, and multi-host hydration
bugs that came from effect-choreographed shared state. That lands the reusable
foundations it needed: a form kit over a single control-geometry owner, a
data-access taxonomy (replica/snapshot/fetch) with real load-state semantics,
and lint/boundary guardrails so new screens inherit the paved road.

Also fixes desktop combobox popovers truncating options when the trigger is
narrower than the content. New schedule protocol fields are optional on both
create and update, so old and new clients/daemons stay compatible.

* test(app): complete mock themes for control-geometry tokens

switch and terminal-profile-edit-modal now style through
createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50],
and theme.colors.borderAccent. The hand-rolled mock themes in these two
suites lacked those tokens, so the eager StyleSheet.create mock threw at
import (collection error), not on any assertion. Add the missing tokens.

* fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration

- Server: archive the run's workspace even when agent creation fails, so a
  misconfigured provider no longer orphans a sidebar workspace/worktree.
- Edit form: keep a stored worktree isolation while worktree eligibility is
  still resolving, instead of silently downgrading a saved schedule to local.
- Schedules screen: show the load error/retry UI when every host fails, rather
  than spinning forever behind the loading branch.
- Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the
  sheet closes instead of re-opening.
- Form model: apply late-loading saved preferences to untouched create-mode
  fields (never to edited schedules or user-modified fields).

* fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety

- Push-router: re-send terminal and checkout-diff subscriptions after a
  reconnect; the old per-hook effect resubscribed on isConnected and the
  extracted router did not, so daemon restarts silently stopped terminals_changed
  pushes (root cause of the terminal-activity e2e failures).
- Server: pass the run's source repo root when archiving worktree runs so the
  worktree is unregistered (git worktree remove/prune), not just deleted; also
  archive the workspace when agent creation fails even with archive-off (an
  agentless workspace has nothing to inspect).
- Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the
  Android native path too; replace the JSDOM component test with a real
  Playwright dismiss spec and inline the trivial dismiss decision.
- Form model: editing an interval schedule no longer rewrites its cadence to
  cron unless the cadence UI is touched (90-minute intervals have no cron
  equivalent); switching projects clears stale provider/model state and gates
  submit until the new host's snapshot resolves.

* chore(lint): drop custom lint wrapper and boundary script, plain oxlint

The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports).
The receiver-sensitive checks the custom script enforced are not worth a
parallel lint pipeline; if they come back it will be as oxlint rules.

* fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations

- Persist the run's workspace/agent ids on the running-run record so a daemon
  crash mid-run can be recovered: restart archives the interrupted run's
  workspace under the same policy as normal cleanup (agentless always archives,
  otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
  hosts without workspaceMultiplicity — an old daemon ignores the fields, so
  offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
  a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
  model-less providers can pick their advertised modes.
2026-07-07 22:13:42 +02:00
Mohamed Boudra
d3e1fe48f6 Scheduled and loop agents get workspaces; schedules UI speaks the user's timezone (#1909)
* fix(app): schedules UI speaks the user's timezone

* refactor(server): require explicit agent placement and bind create-agent command

* fix(server): scheduled agents get a workspace and show in the sidebar

* fix(server): loop agents get a workspace and show in the sidebar

* fix(server): preserve scheduled and loop run semantics through the create command

* fix(server): serialize all schedule record writes through the per-schedule queue

* fix(server): tighten create-agent dependency contracts

* fix(server): handle schedule restamps and loop cancellations

* fix(server): preserve scheduled and loop create semantics

* refactor(server): schedule store owns atomic mutations

* fix(server): keep schedule stamp reuse deterministic

* fix(server): handle loop workspace stamp rejection

* refactor(server): schedule store owns typed identity upsert

* fix(server): keep schedule workspace stamps server-owned

* fix(server): preserve scheduled run semantics

* refactor: required nullable workspaceId replaces placement union; pure cadence policy

* fix(server): tighten scheduled and loop agent edges

* fix(server): fail canceled scheduled runs

* fix(server): close schedule and loop cwd edges

* fix(server): refresh schedule workspace stamps

* fix(server): preserve schedule timezone on replace

* fix(server): close loop stop and cadence reset edges
2026-07-07 17:13:28 +08:00
Mohamed Boudra
783f43c580 Mark bundled-CLI daemons as desktop managed (#1919) 2026-07-06 18:08:18 +02:00
Mohamed Boudra
503a5a9e1e Fix desktop-managed daemon restart from settings (#1915)
* fix(settings): restart managed desktop daemon fully

* fix(settings): respect paused desktop daemon management

* test(settings): cover restart routing through public helper

* fix(settings): wait for daemon restart disconnect

* fix(settings): handle restart review edge cases
2026-07-06 17:50:49 +02:00
Mohamed Boudra
f21c997b17 Include desktop app logs in diagnostics (#1914)
* fix(diagnostics): include desktop app logs

Settings diagnostics now collect the Electron main-process log separately from daemon.log so desktop startup and environment-probe failures are included in app reports.

* refactor(desktop): share log tail reader

* fix(diagnostics): surface desktop app log read errors

* refactor(diagnostics): tighten desktop report API
2026-07-06 17:50:45 +02:00
Mohamed Boudra
7c1da0077b Fix desktop PATH resolution when shell startup fails (#1916)
* fix(desktop): retry login shell env resolution

Finder-launched desktop sessions can inherit launchd's minimal PATH if the login shell probe times out or never prints its marker. Retry once with a non-interactive login shell so profile PATH still has a chance to apply.

* fix(desktop): cap login shell probe retry budget

* fix(desktop): repair csh login env retry

* test(desktop): pin login shell env retry platform

* refactor(desktop): use object params in shell env helpers
2026-07-06 17:50:42 +02:00
yz
d318629121 Fix default bundled web UI path in packaged desktop CLI (#1899)
* Fix packaged web UI dist dir resolution

* Fix bundled web UI path typecheck

* Fix packaged web UI path matcher

* Remove duplicate packaged web UI clause

* Merge packaged web UI path branch

* Use bundled web UI artifact existence

* Inline bundled web UI existence checks

* Restore original web UI fallback branch

* Reuse app-dist path in web UI resolver

* Format web UI resolver tests and config

* Tidy web UI resolver follow-up

* fix(server): tighten bundled web UI path resolution

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-06 16:24:02 +02:00
371 changed files with 26746 additions and 9816 deletions

View File

@@ -239,8 +239,13 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: playwright (shard ${{ matrix.shard }}/4)
include:
- { label: "shard 1/4", shard: 1, desktop: false }
- { label: "shard 2/4", shard: 2, desktop: false }
- { label: "shard 3/4", shard: 3, desktop: false }
- { label: "shard 4/4", shard: 4, desktop: false }
- { label: "desktop overlay", shard: "desktop", desktop: true }
name: playwright (${{ matrix.label }})
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -276,13 +281,19 @@ jobs:
run: npm run build:server
- name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }}
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: ${{ !matrix.desktop }}
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }}
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()

View File

@@ -1,11 +1,8 @@
name: Deploy Relay
on:
push:
branches: [main]
paths:
- "packages/relay/**"
- ".github/workflows/deploy-relay.yml"
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
# A release or main push must not redeploy the temporary Cloudflare bridge.
workflow_dispatch:
jobs:

View File

@@ -84,6 +84,198 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
},
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
{
"files": [
"packages/app/src/data/**/*.{ts,tsx}",
"packages/app/src/**/*.test.{ts,tsx}",
"packages/app/src/**/*.spec.{ts,tsx}"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
{
"files": [
"packages/app/src/assistant-file-links/use-file-link.ts",
"packages/app/src/components/message.tsx",
"packages/app/src/components/worktree-setup-callout-source.tsx",
"packages/app/src/desktop/hooks/use-daemon-status.ts",
"packages/app/src/desktop/hooks/use-install-status.ts",
"packages/app/src/desktop/settings/desktop-settings.ts",
"packages/app/src/git/pull-request-panel/use-data.ts",
"packages/app/src/git/use-github-search-query.ts",
"packages/app/src/git/use-pr-status-query.ts",
"packages/app/src/git/use-status-query.ts",
"packages/app/src/hooks/use-archive-agent.ts",
"packages/app/src/hooks/use-agent-autocomplete.ts",
"packages/app/src/hooks/use-agent-commands-query.ts",
"packages/app/src/hooks/use-agent-history.ts",
"packages/app/src/hooks/use-branch-switcher.ts",
"packages/app/src/hooks/use-changes-preferences/index.ts",
"packages/app/src/hooks/use-draft-agent-features.ts",
"packages/app/src/hooks/use-form-preferences.ts",
"packages/app/src/hooks/use-is-local-daemon.ts",
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
"packages/app/src/hooks/use-preferred-editor.ts",
"packages/app/src/hooks/use-project-icon-query.ts",
"packages/app/src/hooks/use-settings/index.ts",
"packages/app/src/panels/terminal-panel.tsx",
"packages/app/src/projects/project-icons.ts",
"packages/app/src/provider-usage/use-provider-usage.ts",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
"packages/app/src/workspace/desktop-open-targets.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/browser-pane.electron.tsx",
"packages/app/src/components/browser-pane.tsx",
"packages/app/src/components/browser-pane.web.tsx",
"packages/app/src/components/command-center.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/explorer-sidebar.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
"packages/app/src/components/quitting-overlay.tsx",
"packages/app/src/components/realtime-voice-overlay.tsx",
"packages/app/src/components/resize-handle.tsx",
"packages/app/src/components/rewind/rewind-menu.tsx",
"packages/app/src/components/settings-textarea.tsx",
"packages/app/src/components/sidebar-callout.tsx",
"packages/app/src/components/split-container.tsx",
"packages/app/src/components/split-drop-zone.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/ui/alert.tsx",
"packages/app/src/components/ui/autocomplete.tsx",
"packages/app/src/components/ui/combobox.tsx",
"packages/app/src/components/ui/context-menu.tsx",
"packages/app/src/components/ui/dropdown-menu.tsx",
"packages/app/src/components/ui/external-link.tsx",
"packages/app/src/components/volume-meter.tsx",
"packages/app/src/components/web-desktop-scrollbar.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/composer/agent-controls/index.tsx",
"packages/app/src/composer/agent-controls/mode-control.tsx",
"packages/app/src/constants/layout.ts",
"packages/app/src/desktop/components/desktop-permission-row.tsx",
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
"packages/app/src/desktop/components/desktop-updates-section.tsx",
"packages/app/src/desktop/components/integrations-section.tsx",
"packages/app/src/desktop/updates/update-callout-source.tsx",
"packages/app/src/git/actions-split-button.tsx",
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
"packages/app/src/hosts/host-chooser.tsx",
"packages/app/src/screens/open-project-screen.tsx",
"packages/app/src/screens/projects-screen.tsx",
"packages/app/src/screens/sessions-screen.tsx",
"packages/app/src/screens/settings-screen.tsx",
"packages/app/src/screens/settings/host-page.tsx",
"packages/app/src/screens/settings/providers-section.tsx",
"packages/app/src/screens/settings/settings-group.tsx",
"packages/app/src/screens/startup-splash-screen.tsx",
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
{
"files": [
"packages/app/src/components/file-explorer-pane.tsx",
"packages/app/src/components/file-pane.tsx",
"packages/app/src/components/import-session-sheet.tsx",
"packages/app/src/components/project-picker-modal.tsx",
"packages/app/src/desktop/components/pair-device-section.tsx",
"packages/app/src/screens/new-workspace-screen.tsx"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["**/e2e/fixtures.ts"],
"rules": {

View File

@@ -1,6 +1,48 @@
# Changelog
## 0.1.104-beta.4 - 2026-07-06
## 0.1.106 - 2026-07-12
### Added
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
### Improved
- ACP provider catalog updated to the latest registry versions
### Fixed
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
- Switching between workspaces no longer flashes a white screen
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
## 0.1.105 - 2026-07-10
### Added
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
### Improved
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
### Fixed
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
## 0.1.104 - 2026-07-08
### Added
@@ -10,17 +52,27 @@
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er))
### Improved
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
### Fixed
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
- Web UI loads when the daemon is started from the bundled CLI ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim))
- Worktree setup scripts keep your PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908))
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))

View File

@@ -30,6 +30,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
@@ -44,6 +45,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |

View File

@@ -185,6 +185,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
correlated daemon response. The legacy `directories` response field remains a projection of the
typed `entries` list.
**Binary frames (terminal stream protocol):**
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:

View File

@@ -84,6 +84,10 @@ For testing rules, see [testing.md](testing.md).
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
- Never define components inside other components. Module-scope only.
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
- Use stable ids for `key`, never array index for reorderable/filterable lists.
- Context for stable values (theme, auth). Store with selectors for state that changes.

View File

@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
| `MiniMax-M3` | MiniMax |
**Additional models (pay-as-you-go):**
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`

View File

@@ -4,6 +4,10 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
## Store Surface Rules
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
---
## Directory layout

View File

@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.

View File

@@ -173,6 +173,19 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Agent Tool Catalog Measurement
Measure the MCP `tools/list` payload that Paseo injects into agents with:
```bash
npm run measure:agent-tools --workspace=@getpaseo/server
```
The command reports compact JSON bytes, estimated tokens, field totals, largest
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
@@ -290,6 +303,8 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
For tighter loops, you can rebuild a single workspace:
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.

View File

@@ -96,6 +96,18 @@ Keep workspace identity and retention outside native-stack `getId` and
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
Expo Router stack. React Navigation otherwise paints each native stack screen
with its light default background. A screen-level wrapper can hide that surface
while settled, but Android may expose it for one frame when navigation crosses
from a nested stack to its parent stack. This is especially visible when an
app-wide route such as `/new` opens from a dark workspace.
Do not read the active theme with `useUnistyles()` in a layout to build
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
a small `withUnistyles` boundary without subscribing the route tree itself to
every Unistyles runtime update.
## Regression Shape
Pure helper tests are useful but not enough. The failure mode here is native

View File

@@ -20,6 +20,29 @@ input focused while its scrollable list lives in a Portal. There is no shared
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
until then prefer copying the closest file and trimming.
## Popover width contract
Combobox desktop popovers are never narrower than their trigger, and they grow
with content up to a ceiling that is never below the trigger:
```ts
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
```
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
trigger still wins when it is wider. Changing this default requires re-verifying
every consumer listed here.
Consumers: `composer/agent-controls/mode-control.tsx`,
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders

98
docs/forms.md Normal file
View File

@@ -0,0 +1,98 @@
# Forms
The paved road for building forms in the app. The schedule form is the golden
example; when building or fixing any form, copy its shape, not the shape of
whatever screen you happen to be near.
Golden example files:
- `packages/app/src/schedules/schedule-form-model.ts` (+ `.test.ts`) — the model
- `packages/app/src/schedules/use-schedule-form-model.ts` — model lifetime adapter
- `packages/app/src/schedules/use-schedule-form-provider-snapshot.ts` — async input adapter
- `packages/app/src/components/schedules/schedule-form-sheet.tsx` — render + intent dispatch
- `packages/app/src/schedules/aggregated-schedules.ts` / `hooks/use-schedules.ts` — load-state gating
- `packages/app/e2e/schedules-*.spec.ts` — the behavioral contract
## The form model
Every non-trivial form gets a **plain TypeScript model** — zero React imports:
- `openXxxForm(snapshot)` **constructs** a fresh instance from declared inputs
(mode, the record being edited, hosts, defaults). Edit mode seeds every value
AND display from the snapshot — never from a previous instance.
- **Commands** mutate (`setHost`, `setProject(value, display)`, `setModel`, …).
Derived state (disclosure, canSubmit, displays) is recomputed inside the
model on every publish.
- `close()` destroys the instance. `subscribe`/`getState` feed one
`useSyncExternalStore` in the component.
The component renders state and dispatches intent. That is all it does.
### Lifecycle rules (each one killed a real shipped bug)
1. **Fresh mount per open.** The sheet returns `null` when not visible and
mounts the open form with a `key` derived from mode + record identity.
A long-lived component instance shared across create/edit is how edit
contaminated create.
2. **Construct the model ONCE per mount**`useState(() => openXxxForm(snapshot))`.
NEVER `useMemo(() => open(...), [snapshot])`: the snapshot's identity depends
on live data (projects, hosts, preferences), and any background churn — e.g.
a scheduled run creating a workspace — would reconstruct the model and wipe
the user's in-progress input.
3. **Late data is an explicit model input, not a reconstruction.**
`applyProviderSnapshot(serverId, …)`, `applyProjectTargets(…)`,
`applyHosts(…)`. Adapters pipe identity changes into these with mechanical
effects. Input plumbing is fine; orchestration effects are not — the sheet
itself has zero `useEffect`/`useRef`, and that is the target for every form.
4. **Resolution is explicit model state, per host** (`idle | pending |
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
state you can render, not an effect race.
5. **Displays are owned state.** The selected option's label is captured at
selection/seed time (`setProject(value, display)`), never re-derived from a
live options list — list churn must not flicker or blank a selection.
6. **Disclosure is derived in the model** from user intent
(host → project → model → thinking/mode), so fields cannot pop in from
cache timing.
## Form kit
- Compose `Field` / `SelectField` / `FormTextInput` / `SegmentedControl` /
`Switch` from `components/ui/`. Geometry (heights, padding, radii, focus/hover
states) is owned by `components/ui/control-geometry.ts` — controls never
declare their own, and screens never nudge global component styles to align
a row.
- The form declares one size for all fields: `sm` on desktop, `md` compact
(`useIsCompactFormFactor`).
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
(isolation on a non-git project — same gating as New Workspace), not rendered
disabled with an explanation. Disabled-with-a-reason `hint` is only for
transient states the user can resolve.
- Copy is opt-in and rare. No hint/subtext unless the maintainer approved the
exact string; validation errors are the exception. State a fact (like the
timezone) once — never in a preview line AND a helper line.
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
## Data gating
Aggregate hooks return a discriminated load state:
```ts
type AggregateLoadState<T> =
| { status: "connecting" } // an answer may still be pending
| { status: "loading" }
| { status: "loaded"; data: T[] };
```
Empty states are only typeable inside `loaded` — a fetch that "succeeded"
before hosts connected is `connecting`, not empty. Query keys carry real fetch
inputs (host set, connection statuses), never synthetic version counters.
## Anti-patterns (reject in review on sight)
- `useEffect` choreography impersonating construct/hydrate/resolve/destroy.
- One mounted form instance serving create and edit.
- `useMemo`-keyed model construction on live-data identity.
- Selected labels derived from live query lists.
- `isLoading`/`isEmpty` boolean bags where a load-state union belongs.
- Conditional mounting of hint/error rows that shifts layout (subtext renders
only when present, but the pattern for that lives in `Field`, not ad hoc).

View File

@@ -4,6 +4,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".

104
docs/mobile-panels.md Normal file
View File

@@ -0,0 +1,104 @@
# Mobile panels
Compact layouts have three mutually exclusive destinations:
- `agent-list` on the left
- `agent` in the center
- `file-explorer` on the right
They are one interaction, not two independent drawers. The implementation lives in
`packages/app/src/mobile-panels/`.
## Ownership
React/Zustand owns the durable intent:
```ts
interface MobilePanelSelection {
target: "agent-list" | "agent" | "file-explorer";
revision: number;
}
```
Every semantic target change increments `revision`. Repeating the current target is idempotent.
Compact panel selection is not persisted; a cold start begins at `agent`.
The UI worklet owns transient motion:
- one normalized position (`-1` left, `0` center, `1` right)
- the current motion target
- the active gesture's starting revision
- the last settled target
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
receive pointer events. Worklets never own `display` or `pointerEvents`.
## Why one position
Both transforms and both backdrop opacities are derived from the same normalized position. Window
width is only a projection input. Rotation changes the projection, not the panel state.
This makes these invalid states unrepresentable:
- a panel and its backdrop disagreeing
- left and right drawers both claiming to be open
- a width-sync effect resetting an active drag
- one animation context settling a transition owned by the other
Do not add another panel translate shared value, backdrop shared value, or width synchronization
effect.
## Ordering and interruption
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
its target and revision still match the canonical command.
Manual gesture arbitration has two phases:
1. Before activation, determine whether horizontal intent may begin.
2. After activation, stop running begin checks and let the active revision own updates.
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
definition, no longer eligible to begin.
## Integration rules
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
so its injected `collapsable={false}` reaches Android/Fabric.
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
styles in sidebar components.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
prop commit from resurrecting a closed overlay.
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
pointer events, and the active signal consumed by `useRetainedPanelActive`.
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
same active signal without adding another layout root. Persistent animations, timers, polling, and
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
while its retained panel is active so hidden animated styles remain mounted without receiving clock
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
- The newly active panel must be included in the same render that changes selection. Adding it from an
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
## Tests
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
sequence there whenever ownership or ordering changes.

View File

@@ -198,6 +198,19 @@ for i in $(seq 1 "$ITERATIONS"); do
done
```
### Android audio focus interruptions
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
```bash
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing.
```
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
## Unistyles + Reanimated
### The crash

View File

@@ -26,7 +26,9 @@ Pi is a process-backed provider. Paseo requires the user to have the `pi` binary
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.

View File

@@ -52,6 +52,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".

View File

@@ -115,6 +115,8 @@ App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
### Test setup
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.

View File

@@ -90,6 +90,19 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
```tsx
// Wrong: sharedStyles.row already carries Unistyles metadata.
const styles = StyleSheet.create({ row: sharedStyles.row });
<View style={styles.row} />;
// Right: one registered style identity reaches the native view.
<View style={sharedStyles.row} />;
```
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.

View File

@@ -1 +1 @@
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
sha256-bUcqRcbaOGK2TgXJ/H/eLg6Y5VcZeVwvu3OWn+nnmCw=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36170,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36421,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36678,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37806,7 +37806,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@getpaseo/client": "0.1.106",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38582,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

View File

@@ -37,6 +37,91 @@ test.describe("Agent stream UI", () => {
}
});
test("keeps the active Markdown root mounted across streamed text updates", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "stream-markdown-root-",
model: "one-minute-stream",
prompt: "Stream for Markdown root stability test.",
});
try {
const assistantMessage = page.getByTestId("assistant-message").last();
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
const activeBlock = assistantMessage.locator(":scope > *").last();
const initialText = (await activeBlock.textContent()) ?? "";
const activeBlockHandle = await activeBlock.elementHandle();
if (!activeBlockHandle) {
throw new Error("Expected the active assistant message to contain a block");
}
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
if (!markdownRoot) {
throw new Error("Expected the active assistant block to contain a Markdown root");
}
await page.evaluate((block) => {
const evidence = {
addedNodes: 0,
characterDataMutations: 0,
removedNodes: 0,
};
const observer = new MutationObserver((records) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") {
evidence.characterDataMutations += 1;
}
}
});
observer.observe(block, { characterData: true, childList: true, subtree: true });
Object.assign(window, {
__markdownRootEvidence: evidence,
__markdownRootObserver: observer,
});
}, activeBlockHandle);
await expect
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
.toBeGreaterThan(initialText.length + 80);
const evidence = await page.evaluate((root) => {
const state = window as typeof window & {
__markdownRootEvidence?: {
addedNodes: number;
characterDataMutations: number;
removedNodes: number;
};
__markdownRootObserver?: MutationObserver;
};
state.__markdownRootObserver?.disconnect();
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
const message = messages.item(messages.length - 1);
const block = message?.lastElementChild;
return {
...state.__markdownRootEvidence,
connected: root.isConnected,
sameRoot: block?.firstElementChild === root,
};
}, markdownRoot);
await testInfo.attach("markdown-root-stability", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
expect(evidence.connected).toBe(true);
expect(evidence.sameRoot).toBe(true);
expect(
evidence.removedNodes,
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
).toBe(0);
} finally {
await agent.cleanup();
}
});
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({

View File

@@ -218,6 +218,38 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectFlatFileList(page);
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
await page.getByTestId("changes-toggle-whitespace").click();
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-view-mode").click();
await expectFlatFileList(page);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -266,13 +298,24 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
await expect
.poll(async () => {

View File

@@ -1,6 +1,8 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
@@ -10,7 +12,7 @@ function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -20,10 +22,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
// user-confirmed archive proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
@@ -50,7 +48,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPath);
await page.keyboard.press("Enter");
@@ -74,6 +72,26 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
}
test.describe("Project picker search", () => {
test("opens a project from a fuzzy directory-name search", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPickerFixture.fuzzyQuery);
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
await expect(suggestion).toBeVisible({ timeout: 30_000 });
await suggestion.click();
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
});
test("shows a loading state after typing while directory suggestions are pending", async ({
page,
}) => {
@@ -81,7 +99,7 @@ test.describe("Project picker search", () => {
await waitForSidebarProjectListReady(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill("paseo-loading-state-no-match");
@@ -138,17 +156,21 @@ test.describe("Project with no workspaces persists", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
await expect(workspaceRow).toBeVisible({
timeout: 30_000,
});
await workspaceRow.click();
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
// The workspace row goes away, but its project parent stays and exposes a
// child row for creating the next workspace.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
expect(existsSync(workspace.repoPath)).toBe(true);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");

View File

@@ -1,14 +1,30 @@
import { test as base, expect, type Page } from "@playwright/test";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import {
createProjectPickerFixture,
removeProjectPickerFixture,
type ProjectPickerFixture,
} from "./helpers/project-picker-fixture";
import { connectSeedClient } from "./helpers/seed-client";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
rememberProjectId: (projectId: string) => void;
}
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
// reliably for every test that uses this `test` object.
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
const test = base.extend<{
paseoE2ESetup: void;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -57,7 +73,7 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -73,12 +89,20 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
const rawExtraHosts = localStorage.getItem(extraHostsKey);
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
{
daemon: testDaemon,
preferences: createAgentPreferences,
seedNonce,
extraHostsKey: EXTRA_HOSTS_KEY,
},
);
await provide();
@@ -92,6 +116,30 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
{ auto: true },
],
projectPickerFixture: async ({}, provide) => {
const resource = await createProjectPickerFixture();
const { fixture } = resource;
let projectId: string | null = null;
try {
await provide({
...fixture,
rememberProjectId: (openedProjectId) => {
projectId = openedProjectId;
},
});
} finally {
try {
const client = await connectSeedClient();
try {
await removeProjectPickerFixture(client, fixture, projectId);
} finally {
await client.close();
}
} finally {
await resource.removeDirectory();
}
}
},
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);

View File

@@ -71,6 +71,7 @@ export interface DesktopBridgeConfig {
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
dialogOpenResult?: string | string[] | null;
editorTargets?: DesktopEditorTargetConfig[];
editorRecordPath?: string;
}
@@ -96,6 +97,7 @@ export interface ConfirmDialogCall {
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
}
}
@@ -157,6 +159,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
@@ -249,6 +252,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
};
return cfg.confirmShouldAccept ?? false;
},
open: async (options?: Record<string, unknown>) => {
window.__capturedDialogOpenCalls.push(options);
return cfg.dialogOpenResult ?? null;
},
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
@@ -263,10 +270,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
};
}
window.__capturedDialogOpenCalls = [];
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
}, config);
}
export async function waitForDirectoryDialog(
page: Page,
): Promise<Record<string, unknown> | undefined> {
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);

View File

@@ -0,0 +1,61 @@
import { expect, type Locator } from "@playwright/test";
interface TruncatedLabel {
text: string;
scrollWidth: number;
clientWidth: number;
}
export async function expectNoTruncation(locator: Locator): Promise<void> {
await expect(locator.first()).toBeVisible({ timeout: 30_000 });
const truncated = await locator.evaluateAll((elements): TruncatedLabel[] => {
function isVisible(element: HTMLElement): boolean {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 0 &&
rect.height > 0
);
}
function directText(element: HTMLElement): string {
return Array.from(element.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent ?? "")
.join("")
.trim();
}
const checked = new Set<HTMLElement>();
const failures: TruncatedLabel[] = [];
for (const root of elements) {
if (!(root instanceof HTMLElement) || !isVisible(root)) continue;
const candidates = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
for (const candidate of candidates) {
if (checked.has(candidate) || !isVisible(candidate) || candidate.clientWidth <= 0) {
continue;
}
checked.add(candidate);
const text = directText(candidate);
if (!text) continue;
if (candidate.scrollWidth <= candidate.clientWidth) continue;
failures.push({
text,
scrollWidth: candidate.scrollWidth,
clientWidth: candidate.clientWidth,
});
}
}
return failures;
});
expect(truncated, "option labels should not be horizontally truncated").toEqual([]);
}

View File

@@ -0,0 +1,48 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type { SeedDaemonClient } from "./seed-client";
export interface ProjectPickerFixture {
projectPath: string;
projectName: string;
fuzzyQuery: string;
}
interface ProjectPickerFixtureResource {
fixture: ProjectPickerFixture;
removeDirectory: () => Promise<void>;
}
export async function createProjectPickerFixture(): Promise<ProjectPickerFixtureResource> {
const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-"));
const nonce = randomUUID().replaceAll("-", "").slice(0, 8);
const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`);
await mkdir(projectPath, { recursive: true });
return {
fixture: {
projectPath,
projectName: path.basename(projectPath),
fuzzyQuery: `psodfzt${nonce}`,
},
removeDirectory: () => rm(root, { recursive: true, force: true }),
};
}
export async function removeProjectPickerFixture(
client: SeedDaemonClient,
fixture: ProjectPickerFixture,
knownProjectId: string | null = null,
): Promise<void> {
let projectId = knownProjectId;
if (!projectId) {
const lookup = await client.addProject(fixture.projectPath);
projectId = lookup.project?.projectId ?? null;
if (!projectId) {
throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup");
}
}
await client.removeProject(projectId);
}

View File

@@ -0,0 +1,13 @@
import { expect, type Page } from "@playwright/test";
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectName })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
}

View File

@@ -5,10 +5,10 @@ import { type SeededWorkspace } from "./seed-client";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const FAKE_HOST_MODEL_ID = "fake-host-model";
const FAKE_HOST_MODEL_LABEL = "Fake host model";
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export const FAKE_HOST_MODEL_ID = "fake-host-model";
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
type WebSocketMessage = string | Buffer;
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
@@ -20,6 +20,31 @@ export interface FakeScheduleHostWorkspace {
workspace: Record<string, unknown>;
}
interface FakeScheduleSummary {
id: string;
name: string | null;
prompt: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title?: string;
};
};
status: "active";
createdAt: string;
updatedAt: string;
nextRunAt: string | null;
lastRunAt: string | null;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
}
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -114,6 +139,7 @@ export async function installFakeScheduleHost(input: {
port: string;
serverId: string;
workspace: Record<string, unknown>;
schedules?: FakeScheduleSummary[];
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
ws.onMessage((message) => {
@@ -201,7 +227,7 @@ export async function installFakeScheduleHost(input: {
ws.send(
buildSessionMessage("schedule/list/response", {
requestId,
schedules: [],
schedules: input.schedules ?? [],
error: null,
}),
);
@@ -232,15 +258,24 @@ export async function addFakeScheduleHostAndReload(input: {
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
localStorage.setItem(keys.disableSeedOnce, nonce);
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
nextRegistry.push(seededHost);
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
? JSON.parse(rawExtraHosts)
: [];
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
nextExtraHosts.push(seededHost);
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
},
{
seededHost: host,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
extraHosts: EXTRA_HOSTS_KEY,
},
},
);

View File

@@ -371,6 +371,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();

View File

@@ -0,0 +1,86 @@
import { expect, type Locator } from "@playwright/test";
interface SettledOptions {
timeout?: number;
durationMs?: number;
intervalMs?: number;
heightTolerance?: number;
}
interface Sample {
text: string;
height: number;
}
const DEFAULT_DURATION_MS = 1_500;
const DEFAULT_INTERVAL_MS = 100;
const DEFAULT_HEIGHT_TOLERANCE = 1;
async function sample(locator: Locator): Promise<Sample> {
const [text, box] = await Promise.all([locator.textContent(), locator.boundingBox()]);
if (!box) {
throw new Error("Expected locator to keep a bounding box while sampling settledness.");
}
return { text: text ?? "", height: box.height };
}
async function collectSamples(locator: Locator, options?: SettledOptions): Promise<Sample[]> {
await expect(locator).toBeVisible({ timeout: options?.timeout ?? 30_000 });
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
const samples: Sample[] = [];
const deadline = Date.now() + durationMs;
while (Date.now() <= deadline || samples.length < 2) {
samples.push(await sample(locator));
await locator.page().waitForTimeout(intervalMs);
}
return samples;
}
function assertStableHeight(samples: Sample[], tolerance: number): void {
const heights = samples.map((entry) => entry.height);
const minHeight = Math.min(...heights);
const maxHeight = Math.max(...heights);
expect(maxHeight - minHeight, `height changed by more than ${tolerance}px`).toBeLessThanOrEqual(
tolerance,
);
}
function assertSettledText(samples: Sample[]): void {
let stableText: string | null = null;
let previousText: string | null = null;
for (const entry of samples) {
if (stableText !== null && entry.text !== stableText) {
throw new Error(
`Text changed after settling. Expected ${JSON.stringify(stableText)}, received ${JSON.stringify(entry.text)}.`,
);
}
if (previousText === entry.text) {
stableText = entry.text;
}
previousText = entry.text;
}
if (stableText === null) {
throw new Error("Text did not reach a stable value during the settledness window.");
}
}
export async function expectSettled(locator: Locator, options?: SettledOptions): Promise<void> {
const samples = await collectSamples(locator, options);
assertSettledText(samples);
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
}
export async function expectStableHeight(
locator: Locator,
options?: SettledOptions,
): Promise<void> {
const samples = await collectSamples(locator, options);
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
}

View File

@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
await archiveItem.click();
}
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean worktree archives with no prompt; if the host reports unsynced work the app
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
// either way.
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean workspace archives with no prompt. Managed worktree backing may raise
// a browser confirm for unsynced work, so accept it when present.
page.once("dialog", (dialog) => void dialog.accept());
await clickArchiveWorkspaceMenuItem(page, workspaceId);
}

View File

@@ -7,6 +7,7 @@ import {
selectNewWorkspaceProject,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { expectNoTruncation } from "./helpers/no-truncation";
import { escapeRegex } from "./helpers/regex";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
@@ -91,6 +92,10 @@ async function selectMode(page: Page, label: string): Promise<void> {
await expect(modeControl).toBeVisible({ timeout: 30_000 });
await modeControl.click();
const popup = page.getByTestId("combobox-desktop-container").last();
await expect(popup).toBeVisible({ timeout: 10_000 });
await expectNoTruncation(popup);
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
@@ -105,6 +110,19 @@ async function selectMode(page: Page, label: string): Promise<void> {
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
}
async function expectThinkingOptionsFit(page: Page): Promise<void> {
const thinkingControl = page.getByTestId("agent-thinking-selector").first();
await expect(thinkingControl).toBeVisible({ timeout: 30_000 });
await thinkingControl.click();
const popup = page.getByTestId("combobox-desktop-container").last();
await expect(popup).toBeVisible({ timeout: 10_000 });
await expectNoTruncation(popup);
await page.keyboard.press("Escape");
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
@@ -158,6 +176,7 @@ test.describe("New workspace Codex mode preferences", () => {
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await expectThinkingOptionsFit(page);
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");

View File

@@ -10,8 +10,8 @@ import {
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
selectWorkspaceIsolation,
} from "./helpers/new-workspace";
import { expectNoTruncation } from "./helpers/no-truncation";
import { createTempGitRepo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
@@ -63,7 +63,11 @@ test.describe("New workspace isolation memory", () => {
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await selectWorkspaceIsolation(page, "worktree");
await page.getByTestId("workspace-create-isolation-trigger").click();
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(isolationPopup);
await page.getByTestId("workspace-create-isolation-worktree").click();
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);

View File

@@ -0,0 +1,60 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { getServerId } from "./helpers/server-id";
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
test("Browse opens the folder selected by the desktop dialog", async ({
page,
projectPickerFixture,
}) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
manageBuiltInDaemon: false,
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await page.getByTestId("sidebar-add-project").click();
const browse = page.getByRole("button", { name: "Browse…" });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
});
test("Browse owns Enter without opening the active typed path", async ({
page,
projectPickerFixture,
}) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
manageBuiltInDaemon: false,
dialogOpenResult: null,
});
await gotoAppShell(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPickerFixture.projectPath);
const browse = page.getByRole("button", { name: "Browse…" });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.press("Enter");
const dialogOptions = await waitForDirectoryDialog(page);
expect(dialogOptions).toEqual({
directory: true,
multiple: false,
});
await expect(input).toBeVisible();
await expect(
page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectPickerFixture.projectName }),
).toHaveCount(0);
});

View File

@@ -136,7 +136,7 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPath);
await page.keyboard.press("Enter");

View File

@@ -1,5 +1,15 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addFakeScheduleHostAndReload,
buildFakeScheduleHostWorkspace,
FAKE_HOST_MODEL_ID,
FAKE_HOST_MODEL_LABEL,
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectSettled, expectStableHeight } from "./helpers/settled";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
interface ScheduleSeedClient {
@@ -56,6 +66,42 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
.catch(ignoreScheduleDeleteError);
}
type FakeScheduleHostSchedule = NonNullable<
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
>[number];
function buildFakeHostSchedule(input: {
id: string;
name: string;
cwd: string;
}): FakeScheduleHostSchedule {
const now = "2026-07-01T00:00:00.000Z";
return {
id: input.id,
name: input.name,
prompt: "Run on the secondary host.",
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: input.cwd,
model: FAKE_HOST_MODEL_ID,
modeId: "load-test",
title: input.name,
},
},
status: "active",
createdAt: now,
updatedAt: now,
nextRunAt: now,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
};
}
test.describe("Schedules", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
@@ -79,14 +125,114 @@ test.describe("Schedules", () => {
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await row.click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
workspace.projectDisplayName,
{ timeout: 30_000 },
);
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
timeout: 30_000,
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
const hostTrigger = page.getByTestId("schedule-host-trigger");
const projectTrigger = page.getByTestId("schedule-project-trigger");
const modelTrigger = page.getByTestId("schedule-model-trigger");
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
await expect(hostTrigger).toBeDisabled();
await expectSettled(hostTrigger);
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
await expect(page.getByText(/Times are in/)).toHaveCount(0);
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
});
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
cleanupTasks.push(() => workspace.cleanup());
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
const scheduleId = "fake-host-schedule";
const scheduleName = "Secondary host schedule";
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
schedules: [
buildFakeHostSchedule({
id: scheduleId,
name: scheduleName,
cwd: String(fakeHost.workspace.workspaceDirectory),
}),
],
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
});
await page.reload();
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
const hostTrigger = page.getByTestId("schedule-host-trigger");
const projectTrigger = page.getByTestId("schedule-project-trigger");
const modelTrigger = page.getByTestId("schedule-model-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
await expect(hostTrigger).toBeDisabled();
await expectSettled(hostTrigger);
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
});
test("create opens pristine after closing an edit form", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Pristine create ${Date.now()}`;
const scheduleId = await seedMockSchedule(workspace, scheduleName);
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
await page.goto(buildSchedulesRoute());
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("schedules-new").click();
await expect(formSheet).toBeVisible({ timeout: 10_000 });
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(/select project/i);
await expectSettled(projectTrigger);
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
});
});

View File

@@ -6,13 +6,27 @@ import {
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { escapeRegex } from "./helpers/regex";
import { expectNoTruncation } from "./helpers/no-truncation";
import { expectSettled, expectStableHeight } from "./helpers/settled";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 };
interface ScheduleListItem {
id: string;
name: string | null;
target: { type: string; config?: { cwd?: string } };
cadence?: { type: "cron"; expression: string };
target: {
type: string;
config?: {
cwd?: string;
archiveOnFinish?: boolean;
isolation?: "local" | "worktree";
};
};
}
interface ScheduleSeedClient {
@@ -24,7 +38,12 @@ async function selectModelByLabel(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: /select model/i }).click();
const popup = page.getByTestId("combobox-desktop-container");
await expect(popup).toBeVisible({ timeout: 30_000 });
await popup.getByText(label, { exact: true }).click();
const searchInput = page.getByTestId("model-search-input").first();
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await searchInput.fill(label);
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(popup).toHaveCount(0, { timeout: 30_000 });
}
@@ -40,6 +59,33 @@ async function deleteScheduleByName(workspace: SeededWorkspace, name: string): P
async function expectScheduleCreatedForProject(input: {
workspace: SeededWorkspace;
name: string;
cadenceExpression: string;
}): Promise<void> {
const client = input.workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
expect(schedule).toEqual(
expect.objectContaining({
name: input.name,
cadence: expect.objectContaining({
type: "cron",
expression: input.cadenceExpression,
}),
target: expect.objectContaining({
type: "new-agent",
config: expect.objectContaining({
cwd: input.workspace.repoPath,
}),
}),
}),
);
}
async function expectScheduleKnobs(input: {
workspace: SeededWorkspace;
name: string;
archiveOnFinish: boolean;
isolation: "local" | "worktree";
}): Promise<void> {
const client = input.workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
@@ -50,13 +96,54 @@ async function expectScheduleCreatedForProject(input: {
target: expect.objectContaining({
type: "new-agent",
config: expect.objectContaining({
cwd: input.workspace.repoPath,
archiveOnFinish: input.archiveOnFinish,
isolation: input.isolation,
}),
}),
}),
);
}
async function openNewScheduleSheet(page: Page): Promise<void> {
await page.getByTestId("schedules-empty-new").click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
async function dismissScheduleSheetWithBackdrop(page: Page): Promise<void> {
const backdrop = bottomSheetBackdrop(page);
await expect(backdrop).toBeVisible({ timeout: 10_000 });
await expect(async () => {
const box = await backdrop.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + 24);
}
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}
async function expectScheduleSheetClosedAndStable(page: Page): Promise<void> {
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
await page.waitForTimeout(500);
await expect(formSheet).toHaveCount(0, { timeout: 1_000 });
}
async function findScheduleIdByName(workspace: SeededWorkspace, name: string): Promise<string> {
const client = workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === name);
if (!schedule) {
throw new Error(`Expected schedule named ${name} to exist`);
}
return schedule.id;
}
test.describe("Schedules project target", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
@@ -67,10 +154,30 @@ test.describe("Schedules project target", () => {
cleanupTasks.length = 0;
});
test("dismisses the new schedule sheet without reopening", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-sheet-dismiss-", git: false });
cleanupTasks.push(() => workspace.cleanup());
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await page.setViewportSize(MOBILE_SHEET_VIEWPORT);
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
await openNewScheduleSheet(page);
await dismissScheduleSheetWithBackdrop(page);
await expectScheduleSheetClosedAndStable(page);
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
await openNewScheduleSheet(page);
await page.getByRole("button", { name: "Cancel" }).click();
await expectScheduleSheetClosedAndStable(page);
});
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-", git: false });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Project schedule ${Date.now()}`;
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
@@ -84,29 +191,84 @@ test.describe("Schedules project target", () => {
await expect(page.getByTestId("schedules-empty")).toBeVisible();
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await expect(page.getByTestId("schedule-host-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-project-trigger")).toBeVisible();
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
await expect(page.getByText(/Times are in/)).toHaveCount(0);
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
await selectModelByLabel(page, "Ten second stream");
const modelTrigger = page.getByTestId("schedule-model-trigger");
await expect(modelTrigger).toContainText("Ten second stream");
await expectSettled(modelTrigger);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
await expectSettled(page.getByTestId("schedule-mode-trigger"));
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
await expect(page.getByText("Worktree isolation is available for git projects.")).toHaveCount(
0,
);
await page.getByTestId("schedule-cadence-preset-trigger").click();
await page.getByTestId("schedule-cadence-preset-daily-9").click();
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
await expect(page.getByTestId("cadence-cron-expression")).toHaveValue("0 9 * * *");
await page.getByLabel("Schedule name").fill(scheduleName);
await page.getByLabel("Prompt").fill("Summarize the project status.");
await page.getByRole("button", { name: "Cron" }).click();
await page.getByRole("button", { name: "Create schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
await expectScheduleCreatedForProject({
workspace,
name: scheduleName,
cadenceExpression: "0 9 * * *",
});
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
});
await addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
});
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
await expect(hostFilterTrigger).toBeVisible({ timeout: 30_000 });
await hostFilterTrigger.click();
const hostFilterPopup = page.getByTestId("combobox-desktop-container").last();
await expect(hostFilterPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(hostFilterPopup);
await page.keyboard.press("Escape");
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, {
timeout: 5_000,
});
});
test("clears the selected model when the chosen project moves to another host", async ({
page,
}) => {
test("clears the selected project and model when the host changes", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
cleanupTasks.push(() => workspace.cleanup());
const serverId = getServerId();
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
@@ -128,26 +290,126 @@ test.describe("Schedules project target", () => {
});
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await page.getByRole("button", { name: /select project/i }).click();
const hostTrigger = page.getByTestId("schedule-host-trigger");
const projectTrigger = page.getByTestId("schedule-project-trigger");
const modelTrigger = page.getByTestId("schedule-model-trigger");
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
await expect(projectTrigger).toHaveCount(0);
await expect(modelTrigger).toHaveCount(0);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
await hostTrigger.click();
await page.getByTestId(`schedule-host-option-${serverId}`).click();
await expect(projectTrigger).toBeVisible();
await expect(modelTrigger).toHaveCount(0);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
await expectSettled(hostTrigger);
await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
await selectModelByLabel(page, "Ten second stream");
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
await expect(modelTrigger).toContainText("Ten second stream");
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toBeVisible();
await expectSettled(modeTrigger);
await page.getByRole("button", { name: /select project/i }).click();
await hostTrigger.click();
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
await expect(hostTrigger).toContainText("Fake host");
await expect(projectTrigger).toContainText(/select project/i);
await expect(modelTrigger).toHaveCount(0);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
await expectSettled(hostTrigger);
await expectSettled(projectTrigger);
await projectTrigger.click();
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
fakeHost.projectDisplayName,
);
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i);
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
await page.getByLabel("Prompt").fill("Run on the fake host project.");
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
});
test("creates and edits schedule isolation and archive cleanup knobs", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-knobs-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Knob schedule ${Date.now()}`;
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await selectModelByLabel(page, "Ten second stream");
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
).toHaveCount(0);
await page.getByTestId("schedule-isolation-trigger").click();
await page.getByTestId("schedule-isolation-worktree").click();
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
await page.getByTestId("schedule-archive-on-finish-switch").click();
await page.getByLabel("Schedule name").fill(scheduleName);
await page.getByLabel("Prompt").fill("Run with custom workspace cleanup.");
await page.getByTestId("schedule-cadence-preset-trigger").click();
await page.getByTestId("schedule-cadence-preset-every-hour").click();
await page.getByRole("button", { name: "Create schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleKnobs({
workspace,
name: scheduleName,
archiveOnFinish: false,
isolation: "worktree",
});
const scheduleId = await findScheduleIdByName(workspace, scheduleName);
await page.getByTestId(`schedule-row-${scheduleId}`).click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
await expect(page.getByTestId("schedule-archive-on-finish-switch")).toHaveAttribute(
"aria-checked",
"false",
);
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
).toHaveCount(0);
await page.getByTestId("schedule-isolation-trigger").click();
await page.getByTestId("schedule-isolation-local").click();
await page.getByTestId("schedule-archive-on-finish-switch").click();
await page.getByRole("button", { name: "Save changes" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleKnobs({
workspace,
name: scheduleName,
archiveOnFinish: true,
isolation: "local",
});
});
});

View File

@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Host");
await expectSettingsHeader(page, "Overview");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
});

View File

@@ -0,0 +1,26 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
test.describe("Workspace archive shortcut", () => {
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Shift+Backspace`);
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
expect(existsSync(workspace.repoPath)).toBe(true);
} finally {
await workspace.cleanup();
}
});
});

View File

@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
return warning;
}
test.describe("Worktree archive risk warning", () => {
test.describe("Workspace archive risk warning for worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const serverId = getServerId();

View File

@@ -8,11 +8,11 @@ import {
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Worktree archive", () => {
test.describe("Workspace archive with worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
page,
}) => {
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const first = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `shared-archive-${Date.now()}`,
});
createdWorktreeDirectories.add(first.workspaceDirectory);
const siblingPayload = await client.createWorkspace({
source: {
kind: "directory",
path: first.workspaceDirectory,
},
title: "Second workspace",
});
if (!siblingPayload.workspace) {
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
}
const sibling = siblingPayload.workspace;
expect(sibling.workspaceKind).toBe("worktree");
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, first.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
expect(existsSync(first.workspaceDirectory)).toBe(true);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, sibling.id);
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
});
});

View File

@@ -14,6 +14,12 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
// crawler behavior depends on the host Watchman build/capabilities, while the
// node crawler is the path used when Watchman is absent.
config.resolver.useWatchman = false;
const escapedAppSrcRoot = appSrcRoot
.split(path.sep)
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.104-beta.4",
"version": "0.1.106",
"private": true,
"main": "index.ts",
"scripts": {
@@ -22,6 +22,7 @@
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",

View File

@@ -152,6 +152,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/message";
import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
@@ -98,6 +99,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
}: {
inFlightTurnStartedAt?: Date | null;
}) {
const active = useRetainedPanelActive();
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingLoader}>
@@ -106,6 +108,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
active={active}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>

View File

@@ -2,7 +2,6 @@ import React, {
forwardRef,
memo,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
@@ -52,6 +51,7 @@ import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { useSettings } from "@/hooks/use-settings";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
@@ -87,7 +87,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { MountedTabActiveContext } from "@/components/split-container";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { generateDraftId } from "@/stores/draft-keys";
import {
buildDraftWorkspaceAttachmentScopeKey,
@@ -318,6 +318,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
) {
const { t } = useTranslation();
const router = useRouter();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -507,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// cell-window renders on every 48ms flush from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useContext(MountedTabActiveContext);
const isActive = useRetainedPanelActive();
const frozenStreamItemsRef = useRef(streamItems);
const frozenStreamHeadRef = useRef(streamHead);
if (isActive) {
@@ -633,10 +634,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={layoutItem.isLastInToolSequence}
defaultExpanded={autoExpandReasoning}
forceInline={autoExpandReasoning}
/>
);
},
[setInlineDetailsExpanded],
[autoExpandReasoning, setInlineDetailsExpanded],
);
const renderToolCallItem = useCallback(

View File

@@ -4,13 +4,7 @@ import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
type ReactNode,
@@ -23,9 +17,8 @@ import {
useSyncExternalStore,
} from "react";
import { View } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
@@ -34,25 +27,19 @@ import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import {
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
@@ -65,6 +52,7 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -81,10 +69,12 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider } from "@/mobile-panels/provider";
import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import { queryClient } from "@/data/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
@@ -95,17 +85,11 @@ import {
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { usePanelStore } from "@/stores/panel-store";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import {
@@ -404,22 +388,16 @@ function QueryProvider({ children }: { children: ReactNode }) {
const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const;
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
interface AppContainerProps {
children: ReactNode;
selectedAgentId?: string;
chromeEnabled?: boolean;
}
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
function AppContainer({
children,
selectedAgentId,
chromeEnabled: chromeEnabledOverride,
}: AppContainerProps) {
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
@@ -474,28 +452,31 @@ function AppContainer({
useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction();
const sidebarChrome = (
<SidebarChrome
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
/>
);
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{!isCompactLayout ? sidebarChrome : null}
{isCompactLayout && chromeEnabled ? (
<ExplorerSidebarAnimationProvider>
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
</CompactExplorerSidebarHost>
</ExplorerSidebarAnimationProvider>
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
</CompactExplorerSidebarHost>
) : (
<View style={flexStyle}>{children}</View>
)}
</View>
);
const content = (
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{isCompactLayout ? sidebarChrome : null}
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
@@ -504,18 +485,38 @@ function AppContainer({
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
</View>
);
if (!isCompactLayout) {
return content;
}
const content = isCompactLayout ? (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
) : (
surface
);
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
return content;
}
function SidebarChrome({
showSidebar,
keyboardShortcutsEnabled,
}: {
showSidebar: boolean;
keyboardShortcutsEnabled: boolean;
}) {
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
return (
<SidebarModelProvider active={showSidebar && isOpen}>
{showSidebar ? <LeftSidebar /> : null}
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
</SidebarModelProvider>
);
}
function MobileGestureWrapper({
@@ -525,131 +526,13 @@ function MobileGestureWrapper({
children: ReactNode;
chromeEnabled: boolean;
}) {
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
setOverlayPeek,
isGesturing,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const openGestureEnabled = chromeEnabled;
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
showMobileAgentList();
}, [showMobileAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
.failOffsetY([-10, 10])
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) return;
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
}
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
stateManager.fail();
return;
}
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
if (deltaX > 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
// The overlay is display:none while closed; reveal it for the drag.
runOnJS(setOverlayPeek)(true);
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
runOnJS(setOverlayPeek)(false);
}),
[
openGestureEnabled,
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
setOverlayPeek,
handleGestureOpen,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
touchStartY,
],
);
const openGesture = useOpenAgentListGesture(chromeEnabled);
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
{children}
<View collapsable={false} style={layoutStyles.surfaceFill}>
{children}
</View>
</GestureDetector>
);
}
@@ -881,7 +764,6 @@ function OpenProjectListener() {
function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
const storeReady = useStoreReady();
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
@@ -895,30 +777,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/schedules" ||
routeHasKnownHost);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (workspaceServerId && openIntent?.kind === "agent") {
const agentId = openIntent.agentId.trim();
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
}
const match = parseHostAgentRouteFromPathname(pathname);
return match ? `${match.serverId}:${match.agentId}` : undefined;
}, [params.open, pathname]);
return (
<AppContainer
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
chromeEnabled={shouldShowAppChrome}
>
{children}
</AppContainer>
);
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
}
function FaviconStatusSync() {
@@ -926,21 +785,15 @@ function FaviconStatusSync() {
return null;
}
const ROOT_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
};
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
const stackScreenOptions = useMemo(
() => ({
headerShown: false,
animation: "none" as const,
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}),
[theme.colors.surface0],
);
return (
<Stack screenOptions={stackScreenOptions}>
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
@@ -957,7 +810,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack>
</ThemedStack>
);
}
@@ -973,7 +826,7 @@ function WorkspaceRouteNavigationBridge() {
function AppShell() {
return (
<SidebarAnimationProvider>
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
@@ -981,7 +834,7 @@ function AppShell() {
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</MobilePanelsProvider>
);
}
@@ -998,31 +851,27 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
);
}
// PortalProvider must stay inside normal app-wide context providers here.
// PortalProvider must stay inside normal app-wide context providers.
// `@gorhom/portal` renders portaled children at the host's location in the
// tree, so any context a portaled sheet might consume (QueryClient, theme,
// auth, settings, ) must wrap PortalProvider not be wrapped by it.
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) {
return (
<QueryProvider>
<I18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>
</QueryProvider>
<SafeAreaProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
);
}
export default function RootLayout() {
function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
@@ -1036,6 +885,18 @@ export default function RootLayout() {
);
}
export default function RootLayout() {
return (
<QueryProvider>
<I18nProvider>
<RootErrorBoundary>
<RootAppTree />
</RootErrorBoundary>
</I18nProvider>
</QueryProvider>
);
}
const layoutStyles = StyleSheet.create((theme) => ({
surfaceFill: {
flex: 1,

View File

@@ -2,6 +2,7 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteProvider } from "@/navigation/host-route-context";
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
import { ThemedStack } from "@/navigation/themed-stack";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
const HOST_STACK_SCREEN_OPTIONS = {
@@ -33,14 +34,14 @@ function KnownHostRoute() {
}
const stack = (
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Screen name="workspace/[workspaceId]/index" />
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
</Stack>
</ThemedStack>
);
if (!routeServerId) {

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
type ActiveWorkspaceSelection,
useActiveWorkspaceSelection,
@@ -15,6 +16,7 @@ import {
areWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
getWorkspaceSelectionKey,
orderWorkspaceSelectionsForStableRender,
pruneMountedWorkspaceSelections,
shouldKeepWorkspaceDeckEntryMounted,
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
@@ -185,22 +187,25 @@ function WorkspaceDeck() {
);
}, []);
useEffect(() => {
if (!activeSelection) {
return;
}
setMountedSelections((current) => {
const next = pruneMountedWorkspaceSelections({
currentSelections: current,
const nextMountedSelections = useMemo(
() =>
pruneMountedWorkspaceSelections({
currentSelections: mountedSelections,
activeSelection,
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
});
if (areWorkspaceSelectionListsEqual(current, next)) {
return current;
}
return next;
});
}, [activeSelection]);
}),
[activeSelection, mountedSelections],
);
const renderedSelections = useMemo(
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
[nextMountedSelections],
);
useLayoutEffect(() => {
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
setMountedSelections(nextMountedSelections);
}
}, [mountedSelections, nextMountedSelections]);
if (!activeSelection) {
return null;
@@ -208,7 +213,7 @@ function WorkspaceDeck() {
return (
<View style={styles.deck}>
{mountedSelections.map((selection) => {
{renderedSelections.map((selection) => {
return (
<WorkspaceDeckEntry
key={getWorkspaceSelectionKey(selection)}
@@ -251,8 +256,8 @@ function WorkspaceDeckEntry({
}
return (
<View
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
<RetainedPanel
active={isActive}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
@@ -260,7 +265,7 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
</RetainedPanel>
);
}
@@ -268,11 +273,4 @@ const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});

View File

@@ -1,7 +1,38 @@
import { useLocalSearchParams } from "expo-router";
import { Redirect, useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
buildSettingsHostSectionRoute,
buildSettingsRoute,
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
function SettingsDaemonRedirect() {
const hosts = useHosts();
const localDaemon = useLocalDaemonServerIdState();
const bootstrapState = useHostRuntimeBootstrapState();
if (localDaemon.status === "loading") {
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}
if (
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId)
) {
return <Redirect href={buildSettingsHostSectionRoute(localDaemon.serverId, "host")} />;
}
return <Redirect href={buildSettingsRoute()} />;
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
@@ -10,5 +41,14 @@ export default function SettingsSectionRoute() {
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
if (rawSection === "daemon") {
return (
<HostRouteBootstrapBoundary>
<SettingsDaemonRedirect />
</HostRouteBootstrapBoundary>
);
}
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
}

View File

@@ -1,8 +1,8 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -20,6 +20,7 @@ import {
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { createControlGeometry } from "@/components/ui/control-geometry";
import { isNative, isWeb } from "@/constants/platform";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -220,7 +221,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
...createControlGeometry(theme).controlFocusRingColor,
},
adaptiveInputText: {
color: theme.colors.foreground,
@@ -231,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
const WEB_EXIT_DURATION_MS = 160;
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
@@ -448,6 +450,7 @@ export interface AdaptiveModalSheetProps {
header: SheetHeader;
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
children: ReactNode;
/** Sticky footer rendered below the scrollable content. */
footer?: ReactNode;
@@ -468,6 +471,7 @@ export function AdaptiveModalSheet({
header,
visible,
onClose,
onDismiss,
children,
footer,
snapPoints,
@@ -533,6 +537,20 @@ export function AdaptiveModalSheet({
isEnabled: isMobile,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
const nativeModalDismissNotifiedRef = useRef(!visible);
const handleDismiss = useCallback(() => {
handleSheetDismiss();
onDismiss?.();
}, [handleSheetDismiss, onDismiss]);
const notifyNativeModalDismiss = useCallback(() => {
if (nativeModalDismissNotifiedRef.current) {
return;
}
nativeModalDismissNotifiedRef.current = true;
onDismiss?.();
}, [onDismiss]);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -545,12 +563,53 @@ export function AdaptiveModalSheet({
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
);
const desktopOverlayStyle = useMemo(
() => [
styles.desktopOverlay,
isWeb && {
opacity: isWebClosing ? 0 : 1,
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
transitionProperty: "opacity",
transitionTimingFunction: "ease",
},
],
[isWebClosing],
);
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
}, [visible, isMobile, onClose]);
useEffect(() => {
if (visible) {
nativeModalDismissNotifiedRef.current = false;
}
}, [visible]);
useEffect(() => {
if (!isWeb || isMobile) return;
if (visible) {
setShouldRenderWeb(true);
setIsWebClosing(false);
return;
}
if (!shouldRenderWeb) return;
setIsWebClosing(true);
const timeout = window.setTimeout(() => {
setShouldRenderWeb(false);
setIsWebClosing(false);
onDismiss?.();
}, WEB_EXIT_DURATION_MS);
return () => window.clearTimeout(timeout);
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
useEffect(() => {
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
const timeout = setTimeout(notifyNativeModalDismiss, 0);
return () => clearTimeout(timeout);
}, [visible, isMobile, notifyNativeModalDismiss]);
if (isMobile) {
return (
<IsolatedBottomSheetModal
@@ -559,7 +618,7 @@ export function AdaptiveModalSheet({
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
@@ -614,7 +673,7 @@ export function AdaptiveModalSheet({
);
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<View style={desktopOverlayStyle} testID={testID}>
<Pressable
accessibilityLabel={t("common.actions.dismiss")}
style={ABSOLUTE_FILL_STYLE}
@@ -626,7 +685,7 @@ export function AdaptiveModalSheet({
// On web, use portal to overlay root for consistent stacking with toasts
if (isWeb && typeof document !== "undefined") {
if (!visible) return null;
if (!shouldRenderWeb) return null;
return createPortal(desktopContent, getOverlayRoot());
}
@@ -636,6 +695,7 @@ export function AdaptiveModalSheet({
animationType="fade"
visible={visible}
onRequestClose={onClose}
onDismiss={notifyNativeModalDismiss}
hardwareAccelerated
>
{desktopContent}

View File

@@ -9,7 +9,6 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import {
formatAppDiagnosticHeader,
formatDiagnosticSection,
@@ -17,6 +16,7 @@ import {
formatServerInfoSection,
redactAppDiagnosticReport,
} from "@/diagnostics/app-diagnostic-report";
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
@@ -261,35 +261,6 @@ export function AppDiagnosticSheet({
);
}
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
try {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return {
status: "done",
sections: [
formatDiagnosticSection("Desktop", [
{ label: "Daemon status", value: status.status },
{ label: "Desktop managed", value: String(status.desktopManaged) },
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
{ label: "Daemon version", value: status.version ?? "unknown" },
{ label: "Daemon home", value: status.home || "unknown" },
{ label: "Log path", value: logs.logPath || "unknown" },
{ label: "Error", value: status.error ?? "none" },
]),
[
"Desktop daemon log tail",
logs.contents ? indentBlock(logs.contents) : " No log lines found",
].join("\n"),
],
};
} catch (error) {
return {
status: "failed",
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
};
}
}
async function collectHostDiagnosticSections(
host: HostProfile,
snapshot: HostRuntimeSnapshot | null,
@@ -338,14 +309,6 @@ async function collectHostDiagnosticSections(
}
}
function indentBlock(value: string): string {
return value
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n");
}
function toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -4,24 +4,39 @@ import {
View,
Text,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { Button } from "@/components/ui/button";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
Combobox,
ComboboxItem,
type ComboboxOption,
type ComboboxProps,
} from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
function noop() {}
@@ -47,22 +62,67 @@ function drillDownRowStyle({
pressed && styles.drillDownRowPressed,
];
}
import { getProviderIcon } from "@/components/provider-icons";
import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
const DESKTOP_MODEL_ROW_HEIGHT = 40;
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedSearch = withUnistyles(Search);
const ThemedSettings = withUnistyles(Settings);
const ThemedStar = withUnistyles(Star);
const foregroundMutedMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
});
const favoriteStarMapping =
(isFavorite: boolean, hovered: boolean) =>
(theme: Theme): { color: string; fill: string } => {
const favoriteColor = theme.colors.palette.amber[500];
if (isFavorite) {
return { color: favoriteColor, fill: favoriteColor };
}
return {
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
fill: "transparent",
};
};
type ProviderGlyphTone = "muted" | "foreground";
function ProviderGlyph({
provider,
size,
tone = "muted",
}: {
provider: string;
size: number;
tone?: ProviderGlyphTone;
}) {
const Icon = getProviderIcon(provider);
const color =
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
return <Icon size={size} color={color} />;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
}
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
return <ThemedStar size={ICON_SIZE.md} uniProps={uniProps} />;
}
type SelectorView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
@@ -89,6 +149,8 @@ interface CombinedModelSelectorProps {
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
desktopPlacement?: ComboboxProps["desktopPlacement"];
desktopMinWidth?: number;
/**
* Render the custom trigger as a full-width form field: the outer Pressable
* becomes a transparent passthrough that stretches its child edge-to-edge and
@@ -149,9 +211,7 @@ function ModelRow({
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProviderIcon = getProviderIcon(row.provider);
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
@@ -162,8 +222,8 @@ function ModelRow({
);
const leadingSlot = useMemo(
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
[row.provider],
);
const trailingSlot = useMemo(
() =>
@@ -178,32 +238,10 @@ function ModelRow({
}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => {
let starColor: string;
if (isFavorite) starColor = theme.colors.palette.amber[500];
else if (hovered) starColor = theme.colors.foregroundMuted;
else starColor = theme.colors.border;
return (
<Star
size={16}
color={starColor}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
);
}}
{({ hovered }) => <FavoriteStar isFavorite={isFavorite} hovered={Boolean(hovered)} />}
</Pressable>
) : null,
[
onToggleFavorite,
handleToggleFavorite,
isFavorite,
row.provider,
row.modelId,
theme.colors.palette.amber,
theme.colors.foregroundMuted,
theme.colors.border,
t,
],
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
);
return (
@@ -305,9 +343,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
}
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProvIcon = getProviderIcon(provider.id);
const selection = provider.modelSelection;
const handlePress = useCallback(() => {
@@ -327,18 +363,16 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
} else if (selection.kind === "loading") {
stateNode = (
<View style={styles.rowStateInline}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<View style={styles.rowSpinner}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</View>
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
</View>
);
} else {
stateNode = (
<View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
</View>
);
@@ -346,11 +380,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />
<Text style={styles.drillDownText}>{provider.label}</Text>
<View style={styles.drillDownTrailing}>
{stateNode}
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</View>
</Pressable>
);
@@ -446,14 +480,13 @@ function ProviderErrorEmptyState({
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider: boolean;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleRetry = useCallback(() => {
onRetryProvider?.(providerId);
}, [onRetryProvider, providerId]);
return (
<View style={styles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
<Text style={styles.emptyStateText}>{message}</Text>
{onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
@@ -477,7 +510,6 @@ function SelectorContent({
onRetryProvider,
isRetryingProvider,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const selectedViewProvider = useMemo(
@@ -501,7 +533,7 @@ function SelectorContent({
const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<ThemedSearch size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
</View>
);
@@ -514,11 +546,9 @@ function SelectorContent({
if (drillSelection.kind === "loading") {
return (
<View style={styles.emptyState}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<View style={styles.rowSpinner}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</View>
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
</View>
);
@@ -585,9 +615,10 @@ export function CombinedModelSelector({
isRetryingProvider = false,
disabled = false,
serverId = null,
desktopPlacement,
desktopMinWidth,
triggerFill = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
@@ -643,7 +674,6 @@ export function CombinedModelSelector({
);
const hasSelectedProvider = selectedProvider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => {
return resolveSelectedModelLabel({
@@ -750,7 +780,6 @@ export function CombinedModelSelector({
if (view.kind === "all") {
return { title: t("modelSelector.title") };
}
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
<Pressable
onPress={openProviderSettings}
@@ -763,17 +792,12 @@ export function CombinedModelSelector({
})}
testID={`selector-header-settings-${view.providerId}`}
>
<Settings
size={theme.iconSize.sm}
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
/>
<HeaderSettingsIcon disabled={!serverId} />
</Pressable>
);
return {
title: view.providerLabel,
leading: ProviderIconForView ? (
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
) : undefined,
leading: <ProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />,
back: singleProviderView ? undefined : { onPress: handleBackToAll },
actions: headerActions,
search: {
@@ -789,15 +813,10 @@ export function CombinedModelSelector({
singleProviderView,
serverId,
openProviderSettings,
theme.colors.border,
theme.colors.foregroundMuted,
handleBackToAll,
handleSearchQueryChange,
searchResetKey,
t,
theme.iconSize.md,
theme.iconSize.sm,
theme.colors.foreground,
]);
return (
@@ -835,8 +854,8 @@ export function CombinedModelSelector({
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
{hasSelectedProvider ? (
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
) : null}
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel}
@@ -850,8 +869,8 @@ export function CombinedModelSelector({
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={360}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
desktopFixedHeight={desktopFixedHeight}
header={sheetHeader}
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
@@ -872,7 +891,7 @@ export function CombinedModelSelector({
/>
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
</View>
)}
@@ -1044,4 +1063,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
providerIconMuted: {
color: theme.colors.foregroundMuted,
},
providerIconForeground: {
color: theme.colors.foreground,
},
}));

View File

@@ -4,7 +4,7 @@ import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -29,7 +29,7 @@ function CompactExplorerOpenGestureSurface({
enabled,
onOpenExplorer,
}: CompactExplorerOpenGestureSurfaceProps) {
const explorerOpenGesture = useExplorerOpenGesture({
const explorerOpenGesture = useOpenFileExplorerGesture({
enabled,
onOpen: onOpenExplorer,
});

View File

@@ -117,7 +117,7 @@ export function ContextWindowMeter({
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
if (nextOpen) {
void refreshProviderUsage();
void refreshProviderUsage().catch(() => {});
}
},
[refreshProviderUsage],

View File

@@ -8,7 +8,7 @@ import {
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
interface DiffScrollProps {
children: React.ReactNode;
@@ -30,9 +30,7 @@ export function DiffScroll({
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar)
const animation = useExplorerSidebarAnimationOptional();
const closeGestureRef = animation?.closeGestureRef;
const closeGestureRef = useFileExplorerCloseGestureRef();
// Register/unregister scroll offset tracking
useEffect(() => {

View File

@@ -29,16 +29,16 @@ import {
MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { isWeb } from "@/constants/platform";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
@@ -91,25 +91,11 @@ export function CompactExplorerSidebar({
workspaceRoot,
isGit,
});
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: true,
});
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useExplorerSidebarAnimation();
const { gesture: closeGesture } = useCloseFileExplorerGesture();
const handleClose = useCallback(
(reason: string) => {
@@ -122,184 +108,40 @@ export function CompactExplorerSidebar({
[isOpen, showMobileAgent],
);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
mobilePanelGestureAnimatingRef.current = true;
showMobileAgent();
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(true)
// Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
// Fail quickly on clear leftward or vertical intent so child views keep control.
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional rightward movement.
if (deltaX >= 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: swipe right to close (positive translationX)
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
translateX.value = newTranslateX;
const progress = 1 - newTranslateX / windowWidth;
backdropOpacity.value = Math.max(0, Math.min(1, progress));
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
windowWidth,
});
if (shouldClose) {
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
handleCloseFromGesture,
isGesturing,
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
}));
const backdropCombinedStyle = useMemo(
() => [
explorerStaticStyles.backdrop,
backdropAnimatedStyle,
// pointerEvents is React-owned, not worklet-owned: Reanimated never
// touches it, so a stale animated-prop revert can't wedge an invisible
// tap-eating backdrop.
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
],
[backdropAnimatedStyle, isOpen],
);
const mobileSidebarStyle = useMemo(
() => [
explorerStaticStyles.mobileSidebar,
{
width: windowWidth,
paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar,
},
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
],
[
windowWidth,
insets.top,
theme.colors.surfaceSidebar,
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
],
[insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle],
);
// display is React-owned on the plain wrapper View (no animated styles), so
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
// reverts the panel transform to after a heavy commit (reanimated#9635).
const overlayStyle = useMemo(
() => [
StyleSheet.absoluteFillObject,
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
],
[overlayVisible],
);
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropCombinedStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
<RetainedPanelActivity active={isOpen}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
panelStyle={mobileSidebarStyle}
>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
</RetainedPanelActivity>
);
}
@@ -604,17 +446,6 @@ function PrTabContent({
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
backdrop: {
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
mobileSidebar: {
position: "absolute" as const,
top: 0,
right: 0,
bottom: 0,
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative" as const,
},

View File

@@ -19,7 +19,6 @@ import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
Eye,
@@ -28,6 +27,7 @@ import {
RotateCw,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
@@ -55,8 +55,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "size" },
];
const INDENT_PER_LEVEL = 16;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -129,7 +127,7 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected, theme.spacing],
@@ -143,11 +141,6 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const chevronStyle = useMemo(
() => [styles.chevron, isExpanded && styles.chevronExpanded],
[isExpanded],
);
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
@@ -159,7 +152,7 @@ function TreeRowItem({
return (
<Pressable onPress={handlePress} style={pressableStyle}>
{depth > 0 && Array.from({ length: depth }, (_, i) => <IndentGuide key={i} index={i} />)}
<TreeIndentGuides depth={depth} />
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
@@ -167,11 +160,7 @@ function TreeRowItem({
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
}
if (loading) return <ActivityIndicator size="small" />;
return (
<View style={chevronStyle}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
);
return <TreeChevron expanded={isExpanded} />;
})()}
</View>
<Text style={styles.entryName} numberOfLines={1}>
@@ -1149,13 +1138,6 @@ const styles = StyleSheet.create((theme) => ({
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
entryInfo: {
flex: 1,
flexDirection: "row",
@@ -1163,16 +1145,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minWidth: 0,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
entryIcon: {
flexShrink: 0,
},
@@ -1302,16 +1274,3 @@ const styles = StyleSheet.create((theme) => ({
}));
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
interface IndentGuideProps {
index: number;
}
function IndentGuide({ index }: IndentGuideProps) {
const { theme } = useUnistyles();
const guideStyle = useMemo(
() => [styles.indentGuide, { left: theme.spacing[3] + index * INDENT_PER_LEVEL + 4 }],
[index, theme.spacing],
);
return <View style={guideStyle} />;
}

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useMemo, useRef } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import {
@@ -29,7 +29,7 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { MountedTabActiveContext } from "@/components/split-container";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
@@ -414,7 +414,7 @@ export function FilePane({
// Re-read the file when this pane becomes visible again (#445). `isActive`
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
// gate itself lives in isFileQueryEnabled.
const isActive = useContext(MountedTabActiveContext);
const isActive = useRetainedPanelActive();
const isAppVisible = useAppVisible();
const query = useQuery({

View File

@@ -1,5 +1,6 @@
export const ADD_HOST_OPTION_ID = "__add_host__";
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
export function getHostPickerLabel(
hosts: Array<{ label: string; serverId: string }>,

View File

@@ -11,10 +11,16 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
export {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
getHostPickerLabel,
};
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
@@ -115,6 +121,12 @@ export function HostPickerOption({
);
}
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
add: "Add host",
all: "All hosts",
enableBuiltInDaemon: "Enable built-in daemon",
};
function SystemHostPickerOption({
active,
selected,
@@ -125,12 +137,12 @@ function SystemHostPickerOption({
active: boolean;
selected?: boolean;
onPress: () => void;
kind: "add" | "all";
kind: "add" | "all" | "enableBuiltInDaemon";
testID?: string;
}): ReactElement {
const { theme } = useUnistyles();
const Icon = kind === "add" ? Plus : Server;
const label = kind === "add" ? "Add host" : "All hosts";
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
const leadingSlot = useMemo(
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
@@ -158,11 +170,13 @@ export interface HostPickerProps {
includeAllHost?: boolean;
includeAddHost?: boolean;
onAddHost?: () => void;
includeEnableBuiltInDaemon?: boolean;
onEnableBuiltInDaemon?: () => void;
showActiveConnection?: boolean;
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
title?: string;
desktopPlacement?: "top-start" | "bottom-start";
desktopPlacement?: ComboboxProps["desktopPlacement"];
desktopMinWidth?: number;
addHostTestID?: string;
hostOptionTestID?: (serverId: string) => string;
@@ -179,11 +193,13 @@ export function HostPicker({
includeAllHost,
includeAddHost,
onAddHost,
includeEnableBuiltInDaemon,
onEnableBuiltInDaemon,
showActiveConnection,
onOpenHostSettings,
searchable,
title,
desktopPlacement = "top-start",
desktopPlacement = "bottom-start",
desktopMinWidth,
addHostTestID,
hostOptionTestID,
@@ -199,8 +215,13 @@ export function HostPicker({
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
if (includeEnableBuiltInDaemon)
hostOptions.push({
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
label: "Enable built-in daemon",
});
return hostOptions;
}, [orderedHosts, includeAllHost, includeAddHost]);
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
@@ -208,12 +229,14 @@ export function HostPicker({
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost?.();
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
onEnableBuiltInDaemon?.();
} else {
onSelect(id);
}
onOpenChange(false);
},
[onAddHost, onOpenChange, onSelect],
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
);
const handleOpenHostSettings = useCallback(
@@ -246,6 +269,11 @@ export function HostPicker({
/>
);
}
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
return (
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
);
}
return (
<HostPickerOption
serverId={option.id}

View File

@@ -21,13 +21,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -38,19 +32,18 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
useSidebarWorkspacesList,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -62,7 +55,8 @@ import {
usePanelStore,
} from "@/stores/panel-store";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
@@ -79,25 +73,21 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const MIN_CHAT_WIDTH = 400;
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
interface LeftSidebarProps {
selectedAgentId?: string;
}
interface SidebarSharedProps {
theme: SidebarTheme;
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
statusGroups: StatusGroup[];
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
groupMode: SidebarGroupMode;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
collapsedProjectKeys: ReadonlySet<string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
toggleProjectCollapsed: (projectKey: string) => void;
handleRefresh: () => void;
handleOpenProject: () => void;
handleHome: () => void;
@@ -123,7 +113,6 @@ interface SidebarLabels {
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
@@ -136,11 +125,7 @@ interface DesktopSidebarProps extends SidebarSharedProps {
handleViewSchedules: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({
selectedAgentId: _selectedAgentId,
}: LeftSidebarProps) {
void _selectedAgentId;
export const LeftSidebar = memo(function LeftSidebar() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -151,22 +136,19 @@ export const LeftSidebar = memo(function LeftSidebar({
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const {
workspacePlacements,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarWorkspacesList({
enabled: isCompactLayout || isOpen,
});
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects });
const groupMode = useSidebarViewStore((state) => state.groupMode);
statusGroups,
collapsedProjectKeys,
toggleProjectCollapsed,
groupMode,
shortcutModel,
} = useSidebarModel();
const { shortcutIndexByWorkspaceKey } = shortcutModel;
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -257,8 +239,9 @@ export const LeftSidebar = memo(function LeftSidebar({
const sharedProps = {
theme,
statusWorkspacePlacements,
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
@@ -274,36 +257,39 @@ export const LeftSidebar = memo(function LeftSidebar({
if (isCompactLayout) {
return (
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
<RetainedPanelActivity active={isOpen}>
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
}
return (
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
<RetainedPanelActivity active={isOpen}>
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
});
@@ -385,6 +371,7 @@ function SidebarHostPicker({
showActiveConnection
onOpenHostSettings={onOpenHostSettings}
searchable
desktopPlacement="top-start"
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
@@ -555,8 +542,9 @@ function SidebarFooter({
function MobileSidebar({
theme,
statusWorkspacePlacements,
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
@@ -575,7 +563,6 @@ function MobileSidebar({
handleOpenHostSettings,
insetsTop,
insetsBottom,
isOpen,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
@@ -583,269 +570,121 @@ function MobileSidebar({
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
isGesturing,
mobilePanelState,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeSidebar();
}, [closeSidebar, gestureAnimatingRef]);
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const handleViewMore = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar();
handleViewMoreNavigate();
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
}, [closeSidebar, handleViewMoreNavigate]);
const handleViewSchedules = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar();
handleViewSchedulesNavigate();
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
}, [closeSidebar, handleViewSchedulesNavigate]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
}, [closeSidebar]);
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(true)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
if (deltaX >= 10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
isGesturing,
mobilePanelState,
windowWidth,
translateX,
backdropOpacity,
animateToClose,
animateToOpen,
handleCloseFromGesture,
],
);
const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
}));
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
const backdropStyle = useMemo(
() => [
staticStyles.backdrop,
backdropAnimatedStyle,
// pointerEvents is React-owned, not worklet-owned: Reanimated never
// touches it, so a stale animated-prop revert can't wedge an invisible
// tap-eating backdrop.
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
],
[backdropAnimatedStyle, isOpen],
);
const mobileSidebarStyle = useMemo(
() => [
staticStyles.mobileSidebar,
mobileSidebarInsetStyle,
sidebarAnimatedStyle,
{ backgroundColor: theme.colors.surfaceSidebar },
],
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
);
// display is React-owned on the plain wrapper View (no animated styles), so
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
// reverts the panel transform to after a heavy commit (reanimated#9635).
const overlayStyle = useMemo(
() => [
StyleSheet.absoluteFillObject,
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
],
[overlayVisible],
() => ({
paddingTop: insetsTop,
paddingBottom: insetsBottom,
backgroundColor: theme.colors.surfaceSidebar,
}),
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
);
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeaderGroup}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
<WorkspacesSectionHeader />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={theme.iconSize.md}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
<MobilePanelOverlay
panel="agent-list"
closeGesture={closeGesture}
panelStyle={mobileSidebarInsetStyle}
>
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeaderGroup}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
<WorkspacesSectionHeader />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={theme.iconSize.md}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</View>
</Animated.View>
</GestureDetector>
</View>
)}
</Pressable>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusGroups={statusGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</MobilePanelOverlay>
);
}
function DesktopSidebar({
theme,
statusWorkspacePlacements,
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
@@ -969,8 +808,9 @@ function DesktopSidebar({
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
statusGroups={statusGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -1058,17 +898,6 @@ function WorkspacesSectionHeader() {
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const staticStyles = RNStyleSheet.create({
backdrop: {
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
mobileSidebar: {
position: "absolute" as const,
top: 0,
left: 0,
bottom: 0,
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative" as const,
},

View File

@@ -692,33 +692,39 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
interface LiveElapsedProps {
startedAt: Date;
active?: boolean;
style?: StyleProp<TextStyle>;
testID?: string;
}
/**
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
* Ticks every second to render an elapsed duration. Isolated from parents so
* only this component re-renders on each tick.
*/
export const LiveElapsed = memo(function LiveElapsed({
startedAt,
active = true,
style,
testID,
}: LiveElapsedProps) {
const startedAtMs = startedAt.getTime();
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
useEffect(() => {
if (!active) {
return;
}
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
const handle = setInterval(() => {
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
}, 100);
}, 1000);
return () => clearInterval(handle);
}, [startedAtMs]);
}, [active, startedAtMs]);
return (
<Text style={style} testID={testID}>
{formatDuration(elapsedMs)}
{formatDuration(visibleElapsedMs)}
</Text>
);
});
@@ -3038,6 +3044,8 @@ interface ToolCallProps {
onInlineDetailsHoverChange?: (hovered: boolean) => void;
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
onOpenFilePath?: (filePath: string) => void;
defaultExpanded?: boolean;
forceInline?: boolean;
}
export const ToolCall = memo(function ToolCall({
@@ -3054,11 +3062,14 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
defaultExpanded,
forceInline = false,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
const isMobile = useIsCompactFormFactor();
const shouldRenderInline = !isMobile || forceInline;
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {
@@ -3096,7 +3107,7 @@ export const ToolCall = memo(function ToolCall({
}, [presentation.openFilePath, onOpenFilePath]);
const handleToggle = useCallback(() => {
if (isMobile) {
if (!shouldRenderInline) {
openToolCall({
displayName: presentation.displayName,
summary: presentation.summary,
@@ -3109,7 +3120,7 @@ export const ToolCall = memo(function ToolCall({
setIsExpanded((prev) => !prev);
}
}, [
isMobile,
shouldRenderInline,
openToolCall,
presentation.displayName,
presentation.summary,
@@ -3120,22 +3131,22 @@ export const ToolCall = memo(function ToolCall({
]);
useEffect(() => {
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
return;
}
onInlineDetailsHoverChange(false);
}, [isExpanded, isMobile, onInlineDetailsHoverChange]);
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
useEffect(() => {
if (!onInlineDetailsExpandedChange) {
return;
}
if (isMobile) {
if (!shouldRenderInline) {
onInlineDetailsExpandedChange(false);
return;
}
onInlineDetailsExpandedChange(isExpanded);
}, [isExpanded, isMobile, onInlineDetailsExpandedChange]);
}, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]);
useEffect(() => {
if (!onInlineDetailsExpandedChange) {
@@ -3148,7 +3159,7 @@ export const ToolCall = memo(function ToolCall({
// Render inline details for desktop
const renderDetails = useCallback(() => {
if (isMobile) return null;
if (!shouldRenderInline) return null;
return (
<ToolCallDetailsContent
detail={effectiveDetail}
@@ -3157,7 +3168,7 @@ export const ToolCall = memo(function ToolCall({
showLoadingSkeleton={presentation.isLoadingDetails}
/>
);
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
}, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
if (presentation.isPlan && effectiveDetail?.type === "plan") {
return (
@@ -3175,10 +3186,10 @@ export const ToolCall = memo(function ToolCall({
label={presentation.displayName}
secondaryLabel={presentation.summary}
icon={presentation.icon}
isExpanded={!isMobile && isExpanded}
isExpanded={shouldRenderInline && isExpanded}
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
onOpenFile={handleOpenFile}
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
renderDetails={presentation.canOpenDetails && shouldRenderInline ? renderDetails : undefined}
isLoading={status === "running" || status === "executing"}
isError={status === "failed"}
isLastInSequence={isLastInSequence}
@@ -3200,5 +3211,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.isLastInSequence !== next.isLastInSequence) return false;
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
if (previous.defaultExpanded !== next.defaultExpanded) return false;
if (previous.forceInline !== next.forceInline) return false;
return true;
}

View File

@@ -9,22 +9,16 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
function MarkdownInlineText({
textKey,
inheritedStyle,
ruleStyle,
children,
}: {
textKey: string;
inheritedStyle: StyleProp<TextStyle>;
ruleStyle: StyleProp<TextStyle>;
children: ReactNode;
}) {
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
return (
<Text key={textKey} style={style}>
{children}
</Text>
);
return <Text style={style}>{children}</Text>;
}
function MarkdownListItemContent({
@@ -39,12 +33,10 @@ function MarkdownListItemContent({
}
function MarkdownParagraph({
textKey,
paragraphStyle,
isLastChild,
children,
}: {
textKey: string;
paragraphStyle: StyleProp<ViewStyle>;
isLastChild: boolean;
children: ReactNode;
@@ -53,11 +45,7 @@ function MarkdownParagraph({
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
[paragraphStyle, isLastChild],
);
return (
<View key={textKey} style={style}>
{children}
</View>
);
return <View style={style}>{children}</View>;
}
function createPlanMarkdownRules() {
@@ -69,11 +57,7 @@ function createPlanMarkdownRules() {
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.text}
>
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
{node.content}
</MarkdownInlineText>
),
@@ -85,7 +69,7 @@ function createPlanMarkdownRules() {
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
key={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.textgroup}
>
@@ -100,7 +84,7 @@ function createPlanMarkdownRules() {
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
key={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.code_block}
>
@@ -114,11 +98,7 @@ function createPlanMarkdownRules() {
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.fence}
>
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
{node.content}
</MarkdownInlineText>
),
@@ -130,7 +110,7 @@ function createPlanMarkdownRules() {
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
key={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.code_inline}
>
@@ -183,7 +163,7 @@ function createPlanMarkdownRules() {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<MarkdownParagraph
textKey={node.key}
key={node.key}
paragraphStyle={styles.paragraph}
isLastChild={isLastChild}
>

View File

@@ -0,0 +1,45 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { pickDirectory } from "@/desktop/pick-directory";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton({
serverId,
disabled,
onSelect,
onError,
}: ProjectPickerBrowseButtonProps) {
const { t } = useTranslation();
const isLocalDaemon = useIsLocalDaemon(serverId);
const handlePress = useCallback(() => {
void (async () => {
try {
const path = await pickDirectory();
if (path) {
onSelect(path);
}
} catch {
onError();
}
})();
}, [onError, onSelect]);
if (!isLocalDaemon) {
return null;
}
return (
<Button
variant="outline"
size="sm"
leftIcon={FolderOpen}
disabled={disabled}
onPress={handlePress}
>
{t("projectPicker.browse")}
</Button>
);
}

View File

@@ -0,0 +1,5 @@
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
return null;
}

View File

@@ -0,0 +1,6 @@
export interface ProjectPickerBrowseButtonProps {
serverId: string;
disabled: boolean;
onSelect: (path: string) => void;
onError: () => void;
}

View File

@@ -21,6 +21,7 @@ import { useProjectPickerStore } from "@/stores/project-picker-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { shortenPath } from "@/utils/shorten-path";
import { isNative } from "@/constants/platform";
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
interface PathRowProps {
@@ -155,31 +156,36 @@ export function ProjectPickerModal() {
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
queryFn: async () => {
if (!client) return [];
if (!client) {
return { query: debouncedQuery, paths: [] };
}
const result = await client.getDirectorySuggestions({
query: debouncedQuery,
includeDirectories: true,
includeFiles: false,
limit: 30,
});
return (
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
);
return {
query: debouncedQuery,
paths:
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
[],
};
},
enabled: Boolean(client) && isConnected && open,
staleTime: 15_000,
retry: false,
});
const options = useMemo(
() =>
buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[directorySuggestionsQuery.data, query, recommendedPaths],
);
const options = useMemo(() => {
const currentSuggestions =
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
return buildProjectPickerOptions({
recommendedPaths,
serverPaths: currentSuggestions?.paths ?? [],
query,
});
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
const hasQuery = query.trim().length > 0;
const isSearching =
hasQuery &&
@@ -213,6 +219,8 @@ export function ProjectPickerModal() {
}
setOpenErrorReason(getOpenProjectFailureReason(result));
} catch {
setOpenErrorReason("open_failed");
} finally {
setIsSubmitting(false);
}
@@ -232,6 +240,10 @@ export function ProjectPickerModal() {
setOpenErrorReason(null);
}, []);
const handleBrowseError = useCallback(() => {
setOpenErrorReason("open_failed");
}, []);
useEffect(() => {
if (open) {
setQuery("");
@@ -262,7 +274,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
@@ -270,12 +282,6 @@ export function ProjectPickerModal() {
return;
}
if (key === "Enter") {
event.preventDefault();
submitActiveOption();
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (options.length === 0) return;
event.preventDefault();
@@ -291,7 +297,7 @@ export function ProjectPickerModal() {
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [close, open, options.length, submitActiveOption]);
}, [close, open, options.length]);
const panelStyle = useMemo(
() => [
@@ -330,6 +336,7 @@ export function ProjectPickerModal() {
<View style={panelStyle}>
<View style={headerStyle}>
<TextInput
testID="project-picker-input"
ref={inputRef}
value={query}
onChangeText={handleChangeQuery}
@@ -343,6 +350,12 @@ export function ProjectPickerModal() {
returnKeyType="go"
onSubmitEditing={submitActiveOption}
/>
<ProjectPickerBrowseButton
serverId={serverId}
disabled={isSubmitting}
onSelect={handleSelectPath}
onError={handleBrowseError}
/>
</View>
<ProjectPickerResults
@@ -383,11 +396,15 @@ const styles = StyleSheet.create((theme) => ({
...theme.shadow.lg,
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
},
input: {
flex: 1,
fontSize: theme.fontSize.lg,
paddingVertical: theme.spacing[1],
outlineStyle: "none",

View File

@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
it("puts an absolute path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/repo/api"],
serverPaths: [],
serverPaths: ["/repo/api"],
query: "/repo",
});
@@ -48,7 +48,7 @@ describe("buildProjectPickerOptions", () => {
it("puts a home-relative path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/Users/mo/src/api"],
serverPaths: [],
serverPaths: ["/Users/mo/src/api"],
query: "~/src",
});

View File

@@ -0,0 +1,69 @@
import { createContext, memo, type ReactNode, useContext } from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
const RetainedPanelActiveContext = createContext(true);
export function useRetainedPanelActive(): boolean {
return useContext(RetainedPanelActiveContext);
}
interface RetainedPanelProps {
active: boolean;
children: ReactNode;
style?: StyleProp<ViewStyle>;
testID?: string;
}
interface RetainedPanelActivityProps {
active: boolean;
children: ReactNode;
}
export function RetainedPanelActivity({ active, children }: RetainedPanelActivityProps) {
const parentActive = useRetainedPanelActive();
return (
<RetainedPanelActiveContext value={parentActive && active}>
{children}
</RetainedPanelActiveContext>
);
}
/**
* Keeps expensive panel state mounted without letting an inactive panel render
* on screen. The stable, non-collapsible native root is intentional: retained
* panels must not detach or reparent their native descendants when visibility
* changes.
*/
export const RetainedPanel = memo(function RetainedPanel({
active,
children,
style,
testID,
}: RetainedPanelProps) {
const visibleStyle = StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(styles.root, style);
const panelStyle = active
? visibleStyle
: StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(visibleStyle, styles.hidden);
return (
<RetainedPanelActivity active={active}>
<View
collapsable={false}
pointerEvents={active ? "auto" : "none"}
style={panelStyle}
testID={testID}
>
{children}
</View>
</RetainedPanelActivity>
);
});
const styles = StyleSheet.create({
root: {
flex: 1,
},
hidden: {
display: "none",
},
});

View File

@@ -0,0 +1,157 @@
import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { formatCaughtValue } from "./root-error-details";
interface RootErrorBoundaryProps {
children: ReactNode;
}
interface RootErrorBoundaryState {
error: string | null;
resetKey: number;
}
export class RootErrorBoundary extends Component<RootErrorBoundaryProps, RootErrorBoundaryState> {
state: RootErrorBoundaryState = {
error: null,
resetKey: 0,
};
static getDerivedStateFromError(error: unknown): Partial<RootErrorBoundaryState> {
return { error: formatCaughtValue(error) };
}
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
console.error("[RootErrorBoundary] Unhandled render error", {
error: formatCaughtValue(error),
componentStack: errorInfo.componentStack,
});
}
retry = () => {
this.setState(({ resetKey }) => ({
error: null,
resetKey: resetKey + 1,
}));
};
render() {
const { error, resetKey } = this.state;
if (error !== null) {
return <RootErrorFallback error={error} onRetry={this.retry} />;
}
return <Fragment key={resetKey}>{this.props.children}</Fragment>;
}
}
interface RootErrorFallbackProps {
error: string;
onRetry: () => void;
}
function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
const { t } = useTranslation();
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
testID="root-error-boundary"
>
<View style={styles.content}>
<Text style={styles.kicker}>{t("rootError.kicker")}</Text>
<Text style={styles.title}>{t("rootError.title")}</Text>
<Text style={styles.body}>{t("rootError.body")}</Text>
<View style={styles.messageBox}>
<Text style={styles.messageLabel}>{t("rootError.details")}</Text>
<Text style={styles.message}>{error}</Text>
</View>
<Pressable
accessibilityRole="button"
onPress={onRetry}
style={retryButtonStyle}
testID="root-error-boundary-retry"
>
<Text style={styles.retryButtonText}>{t("common.actions.retry")}</Text>
</Pressable>
</View>
</ScrollView>
);
}
function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
return [styles.retryButton, pressed ? styles.retryButtonPressed : null];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[8],
},
content: {
alignSelf: "center",
width: "100%",
maxWidth: 520,
gap: theme.spacing[4],
},
kicker: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
},
body: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
messageBox: {
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[4],
},
messageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
message: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
retryButton: {
alignSelf: "flex-start",
minHeight: 40,
justifyContent: "center",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.accent,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
},
retryButtonPressed: {
opacity: 0.85,
},
retryButtonText: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
}));

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { formatCaughtValue } from "./root-error-details";
describe("formatCaughtValue", () => {
it("preserves details for Error values", () => {
class RouteRenderError extends Error {
code = "E_ROUTE_RENDER";
cause = "workspace route";
constructor() {
super("route render exploded");
this.name = "RouteRenderError";
this.stack = "RouteRenderError: route render exploded\n at WorkspaceRoute";
}
}
const details = formatCaughtValue(new RouteRenderError());
expect(details).toContain("Name: RouteRenderError");
expect(details).toContain("Message: route render exploded");
expect(details).toContain("Stack:");
expect(details).toContain("RouteRenderError: route render exploded");
expect(details).toContain("Cause:");
expect(details).toContain("workspace route");
expect(details).toContain("E_ROUTE_RENDER");
});
it("does not duplicate aggregate errors as custom fields", () => {
const error = new AggregateError([new Error("first failure")], "multiple failures");
const details = formatCaughtValue(error);
expect(details).toContain("Errors:");
expect(details).toContain("first failure");
expect(details).not.toContain("Fields:");
});
it("preserves null aggregate error values", () => {
class ErrorWithNullableErrors extends Error {
errors = null;
}
const details = formatCaughtValue(new ErrorWithNullableErrors("nullable errors"));
expect(details).toContain("Errors:\nnull");
expect(details).not.toContain("Fields:");
});
it("does not throw for malformed Error text fields", () => {
const error = new Error("fallback");
Object.defineProperties(error, {
name: { configurable: true, value: null },
message: { configurable: true, value: 42 },
stack: { configurable: true, value: { frame: "bad stack" } },
});
const details = formatCaughtValue(error);
expect(details).toContain("Name: null");
expect(details).toContain("Message: 42");
expect(details).toContain('"frame": "bad stack"');
});
it("marks recursive Error causes", () => {
const error = new Error("self cause");
Object.defineProperty(error, "cause", { configurable: true, value: error });
const details = formatCaughtValue(error);
expect(details).toContain("Cause:\n[Circular Error]");
});
it("returns fallback details when Error properties throw", () => {
const error = new Error("fallback");
Object.defineProperty(error, "message", {
configurable: true,
get() {
throw new Error("bad message getter");
},
});
const details = formatCaughtValue(error);
expect(details).toContain("[Unserializable value]");
expect(details).toContain("Details unavailable:");
expect(details).toContain("Error: bad message getter");
});
it("renders string thrown values as the string", () => {
expect(formatCaughtValue("plain failure")).toBe("plain failure");
});
it("preserves empty string thrown values", () => {
expect(formatCaughtValue("")).toBe("");
});
it("renders numeric thrown values without extra category text", () => {
const details = formatCaughtValue(42);
expect(details).toBe("42");
expect(details).not.toContain("non-Error");
});
it("renders circular objects as JSON with circular markers", () => {
const value: { label: string; self?: unknown } = { label: "loop" };
value.self = value;
expect(formatCaughtValue(value)).toBe('{\n "label": "loop",\n "self": "[Circular]"\n}');
});
});

View File

@@ -0,0 +1,155 @@
export function formatCaughtValue(value: unknown): string {
try {
return formatCaughtValueWithSeenErrors(value, new WeakSet<Error>());
} catch (formattingError) {
return formatFormattingFailure(value, formattingError);
}
}
function formatCaughtValueWithSeenErrors(value: unknown, seenErrors: WeakSet<Error>): string {
if (value instanceof Error) {
return formatError(value, seenErrors);
}
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return safeString(value);
}
if (typeof value !== "object" && typeof value !== "function") {
return safeString(value);
}
return stringifyJson(value, seenErrors) ?? safeString(value);
}
function formatError(error: Error, seenErrors: WeakSet<Error>): string {
if (seenErrors.has(error)) {
return "[Circular Error]";
}
seenErrors.add(error);
const sections: string[] = [];
const name = formatErrorTextProperty(Reflect.get(error, "name"), seenErrors);
const message = formatErrorTextProperty(Reflect.get(error, "message"), seenErrors);
const stack = formatErrorTextProperty(Reflect.get(error, "stack"), seenErrors);
if (name) {
sections.push(`Name: ${name}`);
}
if (message) {
sections.push(`Message: ${message}`);
}
if (stack) {
sections.push(`Stack:\n${stack}`);
}
const errorCause = getErrorCause(error);
if (errorCause.hasCause) {
sections.push(`Cause:\n${formatCaughtValueWithSeenErrors(errorCause.value, seenErrors)}`);
}
const aggregateErrors = getAggregateErrors(error);
if (aggregateErrors.hasErrors) {
sections.push(`Errors:\n${formatCaughtValueWithSeenErrors(aggregateErrors.value, seenErrors)}`);
}
const fields = getErrorFields(error);
if (fields !== null) {
sections.push(`Fields:\n${stringifyJson(fields, seenErrors) ?? safeString(fields)}`);
}
seenErrors.delete(error);
return sections.join("\n\n") || safeString(error);
}
function formatErrorTextProperty(value: unknown, seenErrors: WeakSet<Error>): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || null;
}
if (value === undefined) {
return null;
}
return stringifyJson(value, seenErrors) ?? safeString(value);
}
function getErrorCause(error: Error): { hasCause: boolean; value: unknown } {
if (!Reflect.has(error, "cause")) {
return { hasCause: false, value: null };
}
return { hasCause: true, value: Reflect.get(error, "cause") };
}
function getAggregateErrors(error: Error): { hasErrors: boolean; value: unknown } {
if (!Reflect.has(error, "errors")) {
return { hasErrors: false, value: null };
}
return { hasErrors: true, value: Reflect.get(error, "errors") };
}
function getErrorFields(error: Error): Record<string, unknown> | null {
const fields: Record<string, unknown> = {};
for (const key of Object.keys(error)) {
if (
key === "name" ||
key === "message" ||
key === "stack" ||
key === "cause" ||
key === "errors"
) {
continue;
}
fields[key] = Reflect.get(error, key);
}
return Object.keys(fields).length > 0 ? fields : null;
}
function stringifyJson(value: unknown, seenErrors: WeakSet<Error>): string | null {
const seen = new WeakSet<object>();
try {
const serialized = JSON.stringify(
value,
(_key, nestedValue: unknown) => {
if (nestedValue instanceof Error) {
return formatError(nestedValue, seenErrors);
}
if (typeof nestedValue === "bigint") {
return String(nestedValue);
}
if (nestedValue !== null && typeof nestedValue === "object") {
if (seen.has(nestedValue)) {
return "[Circular]";
}
seen.add(nestedValue);
}
return nestedValue;
},
2,
);
return typeof serialized === "string" ? serialized : null;
} catch {
return null;
}
}
function safeString(value: unknown): string {
try {
return String(value);
} catch {
return "[Unserializable value]";
}
}
function formatFormattingFailure(value: unknown, formattingError: unknown): string {
const valueText = safeString(value);
const formattingErrorText = safeString(formattingError);
if (formattingErrorText === "[Unserializable value]") {
return valueText;
}
return `${valueText}\n\nDetails unavailable:\n${formattingErrorText}`;
}

View File

@@ -1,366 +1,140 @@
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
describeCron,
everyMsToParts,
type IntervalUnit,
partsToEveryMs,
validateCron,
} from "@/utils/schedule-format";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
import {
CADENCE_PRESET_OPTIONS,
normalizeScheduleFormCadence,
resolveCronPresetDisplay,
resolveCronPresetId,
} from "@/schedules/schedule-cadence-options";
import { getDeviceTimeZone } from "@/utils/device-timezone";
import { describeCron, validateCron } from "@/utils/schedule-format";
type CadenceMode = ScheduleCadence["type"];
interface CronPreset {
label: string;
expression: string;
}
// 5-field expressions evaluated in UTC by the daemon. Each one round-trips
// through describeCron() so the chip and the live preview agree.
const CRON_PRESETS: CronPreset[] = [
{ label: "Every hour", expression: "0 * * * *" },
{ label: "Daily 9:00", expression: "0 9 * * *" },
{ label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
{ label: "Mondays 9:00", expression: "0 9 * * 1" },
];
const MODE_OPTIONS = [
{ value: "every" as const, label: "Interval" },
{ value: "cron" as const, label: "Cron" },
];
const UNIT_OPTIONS = [
{ value: "minutes" as const, label: "Minutes" },
{ value: "hours" as const, label: "Hours" },
{ value: "days" as const, label: "Days" },
];
const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours");
const DEFAULT_CRON_EXPRESSION = "0 9 * * *";
const UNIT_NOUN: Record<IntervalUnit, string> = {
minutes: "minute",
hours: "hour",
days: "day",
};
function describeInterval(value: number, unit: IntervalUnit): string {
const noun = UNIT_NOUN[unit];
if (value === 1) {
return `Runs every ${noun}`;
}
return `Runs every ${value} ${noun}s`;
}
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
size?: FieldControlSize;
}
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
const mode = value.type;
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
testID: `schedule-cadence-preset-${option.id}`,
}));
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
// once from the incoming cadence via lazy state initializers and bump
// resetKey only when we change the content ourselves (mode switch, preset
// chip) — never on every keystroke.
const [intervalValueText, setIntervalValueText] = useState(() =>
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
);
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
);
const [cronText, setCronText] = useState(() =>
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
);
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
if (error || !expression) {
return null;
}
return describeCron({ type: "cron", expression, timezone }) ?? expression;
}
function buildCronCadence(expression: string, timezone: string): CronCadence {
return { type: "cron", expression, timezone };
}
export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEditorProps) {
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
const normalizedValue = normalizeScheduleFormCadence(value, deviceTimeZone);
const [cronText, setCronText] = useState(() => normalizedValue.expression);
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
// Remember the cron expression the user had so toggling Interval -> Cron and
// back does not discard a blanked-out field. Interval mode rebuilds straight
// from the live numeric value + unit, so it needs no equivalent ref.
const lastCronExpression = useRef(
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
const cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
const trimmedCron = cronText.trim();
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
const effectiveError = error ?? localCronError;
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
const currentCadence = useMemo<CronCadence>(
() => buildCronCadence(trimmedCron, cronTimeZone),
[cronTimeZone, trimmedCron],
);
const selectedPresetId = resolveCronPresetId(currentCadence);
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
const parsedIntervalValue = useMemo(() => {
const parsed = Number.parseInt(intervalValueText, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}, [intervalValueText]);
const emitInterval = useCallback(
(rawValue: number, unit: IntervalUnit) => {
onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) });
},
[onChange],
);
const emitCron = useCallback(
(expression: string) => {
lastCronExpression.current = expression;
onChange({ type: "cron", expression });
},
[onChange],
);
const handleModeChange = useCallback(
(nextMode: CadenceMode) => {
if (nextMode === mode) {
const handlePresetChange = useCallback(
(presetId: string) => {
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
if (!preset) {
return;
}
if (nextMode === "every") {
emitInterval(parsedIntervalValue, intervalUnit);
} else {
emitCron(cronText.trim() || lastCronExpression.current);
}
setCronText(preset.expression);
bumpFieldResetKey();
onChange(buildCronCadence(preset.expression, cronTimeZone));
},
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
);
const handleIntervalValueChange = useCallback(
(text: string) => {
// Keep only digits so the cadence stays a positive integer count.
const digits = text.replace(/[^0-9]/g, "");
setIntervalValueText(digits);
const parsed = Number.parseInt(digits, 10);
emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit);
},
[emitInterval, intervalUnit],
);
const handleUnitChange = useCallback(
(unit: IntervalUnit) => {
setIntervalUnit(unit);
emitInterval(parsedIntervalValue, unit);
},
[emitInterval, parsedIntervalValue],
[cronTimeZone, onChange],
);
const handleCronChange = useCallback(
(text: string) => {
setCronText(text);
emitCron(text.trim());
onChange(buildCronCadence(text.trim(), cronTimeZone));
},
[emitCron],
[cronTimeZone, onChange],
);
const handlePresetPress = useCallback(
(expression: string) => {
setCronText(expression);
bumpFieldResetKey();
emitCron(expression);
},
[emitCron],
);
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
const trimmedCron = cronText.trim();
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron);
let cronFeedback: ReactNode = null;
if (cronError) {
cronFeedback = <Text style={styles.error}>{cronError}</Text>;
} else if (cronPreview) {
cronFeedback = <Text style={styles.preview}>{cronPreview}</Text>;
let feedback: ReactNode = null;
if (effectiveError) {
feedback = <Text style={styles.error}>{effectiveError}</Text>;
} else if (preview) {
feedback = <Text style={styles.preview}>{preview}</Text>;
}
return (
<View style={styles.container}>
<SegmentedControl
size="sm"
value={mode}
onValueChange={handleModeChange}
options={MODE_OPTIONS}
style={styles.modeControl}
testID="cadence-mode"
/>
<Field label="Cadence">
<View style={styles.stack}>
<SelectField
label="Cadence"
value={selectedPresetId === "custom" ? null : selectedPresetId}
selectedDisplay={selectedPresetDisplay}
options={PRESET_OPTIONS}
onChange={handlePresetChange}
placeholder="Select cadence"
emptyText="No cadences found"
searchable={false}
title="Cadence"
size={size}
triggerTestID="schedule-cadence-preset-trigger"
field={false}
/>
{mode === "every" ? (
<View style={styles.section}>
<View style={styles.intervalRow}>
<AdaptiveTextInput
testID="cadence-interval-value"
accessibilityLabel="Interval value"
initialValue={intervalValueText}
resetKey={`cadence-interval-${fieldResetKey}`}
value={intervalValueText}
onChangeText={handleIntervalValueChange}
keyboardType="number-pad"
style={styles.intervalInput}
/>
<SegmentedControl
size="sm"
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
testID="cadence-interval-unit"
/>
</View>
<Text style={styles.preview}>{intervalPreview}</Text>
</View>
) : (
<View style={styles.section}>
<View style={styles.presetRow}>
{CRON_PRESETS.map((preset) => (
<CronPresetChip
key={preset.expression}
label={preset.label}
expression={preset.expression}
isSelected={trimmedCron === preset.expression}
onSelect={handlePresetPress}
/>
))}
</View>
<AdaptiveTextInput
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{cronFeedback}
<Text style={styles.hint}>Times are in UTC</Text>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
);
}
function CronPresetChip({
label,
expression,
isSelected,
onSelect,
}: {
label: string;
expression: string;
isSelected: boolean;
onSelect: (expression: string) => void;
}) {
const handlePress = useCallback(() => {
onSelect(expression);
}, [onSelect, expression]);
const chipStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.chip,
isSelected && styles.chipSelected,
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
],
[isSelected],
);
const labelStyle = useMemo(
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
[isSelected],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={chipStyle}
>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
<FormTextInput
size={size}
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{feedback}
</View>
</Field>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
stack: {
gap: theme.spacing[3],
},
section: {
gap: theme.spacing[3],
},
intervalRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
intervalInput: {
width: 88,
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// The mode toggle hugs its options at the left rather than stretching to a
// full-width track; the interval row then reads as input + toggle.
modeControl: {
alignSelf: "flex-start",
},
presetRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
chip: {
minHeight: 32,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
chipHover: {
backgroundColor: theme.colors.surface3,
},
// Selected preset reads as a chosen surface, not a second accent fill
// competing with the sheet's primary CTA.
chipSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.foreground,
},
cronInput: {
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hint: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.palette.red[300],

File diff suppressed because it is too large Load Diff

View File

@@ -38,17 +38,13 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import {
Archive,
CircleAlert,
CircleCheck,
ChevronDown,
ChevronRight,
Copy,
ExternalLink,
GitPullRequest,
Settings,
MoreVertical,
Pencil,
Plus,
Trash2,
} from "lucide-react-native";
@@ -66,9 +62,7 @@ import {
} from "@/utils/host-routes";
import {
shouldShowSidebarHostLabels,
useSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
@@ -83,7 +77,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -93,6 +86,8 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
@@ -116,7 +111,7 @@ import {
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -142,15 +137,11 @@ const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedPlus = withUnistyles(Plus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
@@ -228,8 +219,9 @@ function selectionForSelectedWorkspace(
}
interface SidebarWorkspaceListProps {
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
statusGroups: StatusGroup[];
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectKey: string) => void;
@@ -291,16 +283,6 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function getWorkspaceArchiveStatus(
isWorktree: boolean,
archiveStatus: "idle" | "pending" | "success",
isArchivingWorkspace: boolean,
): "idle" | "pending" | "success" {
if (isWorktree) return archiveStatus;
if (isArchivingWorkspace) return "pending";
return "idle";
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -358,12 +340,6 @@ function projectKebabStyle({
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
}
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getProjectWorkspaceRowStyle({
isDragging,
selected,
@@ -540,12 +516,6 @@ function ProjectRowTrailingActions({
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
);
@@ -693,7 +663,7 @@ function WorkspaceRowRightGroup({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -713,97 +683,6 @@ function WorkspaceRowRightGroup({
);
}
function WorkspaceKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProjectIcon({
iconDataUri,
placeholderInitial,
@@ -1564,20 +1443,7 @@ function WorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -1589,7 +1455,6 @@ function WorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -1656,7 +1521,7 @@ function WorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -1668,8 +1533,8 @@ function WorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -1694,11 +1559,7 @@ function WorkspaceRowWithMenu({
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={getWorkspaceArchiveStatus(
isWorktree,
worktreeArchiveStatus,
isHidingWorkspace,
)}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -1723,6 +1584,7 @@ function WorkspaceRowWithMenu({
interface WorkspaceRowItemProps {
workspace: SidebarWorkspacePlacement;
workspaceEntry: SidebarWorkspaceEntry | null;
subtitle?: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -1738,6 +1600,7 @@ interface WorkspaceRowItemProps {
function WorkspaceRowItem({
workspace,
workspaceEntry,
subtitle,
shortcutNumber,
showShortcutBadge,
@@ -1760,7 +1623,7 @@ function WorkspaceRowItem({
return (
<WorkspaceRow
workspace={workspace}
workspaceEntry={workspaceEntry}
subtitle={subtitle}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -1798,6 +1661,7 @@ function areWorkspaceRowItemPropsEqual(
});
return (
previous.workspace === next.workspace &&
previous.workspaceEntry === next.workspaceEntry &&
previous.subtitle === next.subtitle &&
previous.shortcutNumber === next.shortcutNumber &&
previous.showShortcutBadge === next.showShortcutBadge &&
@@ -1814,7 +1678,7 @@ function areWorkspaceRowItemPropsEqual(
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
function WorkspaceRow({
workspace,
workspaceEntry,
subtitle,
shortcutNumber,
showShortcutBadge,
@@ -1826,7 +1690,7 @@ function WorkspaceRow({
isCreating = false,
selected,
}: {
workspace: SidebarWorkspacePlacement;
workspaceEntry: SidebarWorkspaceEntry | null;
subtitle?: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -1838,15 +1702,13 @@ function WorkspaceRow({
isCreating?: boolean;
selected: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
if (!hydratedWorkspace) {
if (!workspaceEntry) {
return null;
}
return (
<WorkspaceRowWithMenu
workspace={hydratedWorkspace}
workspace={workspaceEntry}
subtitle={subtitle}
selected={selected}
shortcutNumber={shortcutNumber}
@@ -1863,6 +1725,7 @@ function WorkspaceRow({
function ProjectBlock({
project,
workspaceEntriesByKey,
collapsed,
displayName,
iconDataUri,
@@ -1885,6 +1748,7 @@ function ProjectBlock({
supportsMultiplicityByServerId,
}: {
project: SidebarProjectEntry;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
collapsed: boolean;
displayName: string;
iconDataUri: string | null;
@@ -1934,6 +1798,7 @@ function ProjectBlock({
return (
<MemoWorkspaceRowItem
workspace={item}
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
subtitle={
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
}
@@ -1960,6 +1825,7 @@ function ProjectBlock({
selectionEnabled,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
workspaceEntriesByKey,
],
);
@@ -2114,6 +1980,7 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
@@ -2166,8 +2033,9 @@ function areProjectBlockSelectionsEqual(
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
export function SidebarWorkspaceList({
statusWorkspacePlacements,
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
collapsedProjectKeys,
onToggleProjectCollapsed,
@@ -2196,7 +2064,7 @@ export function SidebarWorkspaceList({
const content =
groupMode === "status" ? (
<SidebarStatusModeWrapper
statusWorkspacePlacements={statusWorkspacePlacements}
statusGroups={statusGroups}
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
@@ -2206,6 +2074,7 @@ export function SidebarWorkspaceList({
) : (
<ProjectModeList
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={onToggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
@@ -2224,14 +2093,14 @@ export function SidebarWorkspaceList({
}
function SidebarStatusModeWrapper({
statusWorkspacePlacements,
statusGroups,
projectNamesByKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
}: {
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
statusGroups: StatusGroup[];
projectNamesByKey: Map<string, string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
@@ -2242,7 +2111,7 @@ function SidebarStatusModeWrapper({
return (
<SidebarStatusWorkspaceList
workspaces={statusWorkspacePlacements}
groups={statusGroups}
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
@@ -2255,6 +2124,7 @@ function SidebarStatusModeWrapper({
function ProjectModeList({
projects,
workspaceEntriesByKey,
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
@@ -2268,7 +2138,7 @@ function ProjectModeList({
supportsMultiplicityByServerId,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
> & {
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
@@ -2443,6 +2313,7 @@ function ProjectModeList({
return (
<MemoProjectBlock
project={item}
workspaceEntriesByKey={workspaceEntriesByKey}
collapsed={collapsedProjectKeys.has(item.projectKey)}
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
@@ -2481,6 +2352,7 @@ function ProjectModeList({
selectionEnabled,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
workspaceEntriesByKey,
creatingWorkspaceIds,
],
);
@@ -2872,14 +2744,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,

View File

@@ -0,0 +1,95 @@
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
type SidebarWorkspaceEntry,
type SidebarWorkspacesListResult,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import {
buildSidebarShortcutModel,
buildStatusSidebarShortcutModel,
type SidebarShortcutModel,
} from "@/utils/sidebar-shortcuts";
interface SidebarModel extends SidebarWorkspacesListResult {
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
groupMode: SidebarGroupMode;
statusGroups: StatusGroup[];
collapsedProjectKeys: ReadonlySet<string>;
toggleProjectCollapsed: (projectKey: string) => void;
shortcutModel: SidebarShortcutModel;
}
const SidebarModelContext = createContext<SidebarModel | null>(null);
export function SidebarModelProvider({
active,
children,
}: {
active?: boolean;
children: ReactNode;
}) {
const list = useSidebarWorkspacesList();
const groupMode = useSidebarViewStore((state) => state.groupMode);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleProjectCollapsed,
);
const isStatusMode = groupMode === "status";
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
list.workspacePlacements,
active !== false || isStatusMode,
);
const statusGroups = useMemo(
() =>
isStatusMode
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
: [],
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
);
const shortcutModel = useMemo(() => {
if (isStatusMode) {
return buildStatusSidebarShortcutModel({
groups: statusGroups,
collapsedStatusGroupKeys,
});
}
return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys });
}, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]);
const value = useMemo(
() => ({
...list,
workspaceEntriesByKey,
groupMode,
statusGroups,
collapsedProjectKeys,
toggleProjectCollapsed,
shortcutModel,
}),
[
collapsedProjectKeys,
groupMode,
list,
shortcutModel,
statusGroups,
toggleProjectCollapsed,
workspaceEntriesByKey,
],
);
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
}
export function useSidebarModel(): SidebarModel {
const model = useContext(SidebarModelContext);
if (!model) throw new Error("SidebarModelProvider is required");
return model;
}

View File

@@ -4,16 +4,8 @@ import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } fr
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
useSidebarWorkspaceEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
type StatusGroup,
} from "@/hooks/sidebar-status-view-model";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
@@ -25,29 +17,17 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -60,9 +40,9 @@ import {
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -77,20 +57,8 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarStatusWorkspacePlacement[];
groups: StatusGroup[];
projectNamesByKey: Map<string, string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
@@ -100,31 +68,19 @@ interface StatusWorkspaceListProps {
}
export function SidebarStatusWorkspaceList({
workspaces,
groups,
projectNamesByKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
}: StatusWorkspaceListProps) {
const groups = useMemo(
() => buildStatusGroups(workspaces, projectNamesByKey),
[workspaces, projectNamesByKey],
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const statusShortcutIndex = useMemo(
() =>
showShortcutBadges
? buildStatusShortcutIndex(
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
)
: new Map<string, number>(),
[collapsedStatusGroupKeys, groups, showShortcutBadges],
);
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
return (
<View style={styles.container}>
@@ -325,13 +281,12 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarStatusWorkspacePlacement;
workspace: SidebarWorkspaceEntry;
subtitle: string;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
@@ -343,11 +298,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!workspaceEntry) return null;
return (
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
workspace={workspace}
subtitle={subtitle}
selected={selected}
shortcutNumber={shortcutNumber}
@@ -376,20 +329,7 @@ function StatusWorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -404,7 +344,6 @@ function StatusWorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -454,7 +393,7 @@ function StatusWorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -466,8 +405,8 @@ function StatusWorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -476,13 +415,6 @@ function StatusWorkspaceRowWithMenu({
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
@@ -494,7 +426,7 @@ function StatusWorkspaceRowWithMenu({
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
@@ -657,7 +589,7 @@ function StatusWorkspaceActionSlot({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -675,105 +607,6 @@ function StatusWorkspaceActionSlot({
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
@@ -876,12 +709,4 @@ const styles = StyleSheet.create((theme) => ({
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -0,0 +1,151 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
import { isNative, isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
interface SidebarWorkspaceMenuProps {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
export function SidebarWorkspaceMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: SidebarWorkspaceMenuProps) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={triggerStyle}
accessibilityRole={isWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.trigger, hovered && styles.triggerHovered];
}
const styles = StyleSheet.create((theme) => ({
trigger: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -142,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
<View style={styles.workspaceRowRight}>{children}</View>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
@@ -502,7 +502,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
shortcutBadgeOverlay: {
position: "absolute",
top: 1,

View File

@@ -1,35 +1,26 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { Theme } from "@/styles/theme";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { isNative as platformIsNative } from "@/constants/platform";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
@@ -38,31 +29,6 @@ import {
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function noop() {}
interface SidebarWorkspaceRowProps {
@@ -99,20 +65,7 @@ export function SidebarWorkspaceRow({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -127,7 +80,6 @@ export function SidebarWorkspaceRow({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -194,7 +146,7 @@ export function SidebarWorkspaceRow({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -206,8 +158,8 @@ export function SidebarWorkspaceRow({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -216,13 +168,6 @@ export function SidebarWorkspaceRow({
},
});
let archiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
archiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
archiveStatus = "pending";
}
return (
<>
<WorkspaceRowBody
@@ -238,7 +183,7 @@ export function SidebarWorkspaceRow({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={archiveStatus}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -458,7 +403,7 @@ function WorkspaceRowTrailingActions({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -478,103 +423,6 @@ function WorkspaceRowTrailingActions({
);
}
function WorkspaceKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getWorkspaceRowStyle({
isDragging,
selected,
@@ -630,12 +478,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -1,6 +1,5 @@
import {
Fragment,
createContext,
memo,
useCallback,
useEffect,
@@ -32,6 +31,7 @@ import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { RetainedPanel } from "@/components/retained-panel";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -74,10 +74,6 @@ import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
// true = this tab slot is the active (visible) tab; false = mounted but hidden.
// Defaults to true so consumers outside a slot (e.g. web preview) are unaffected.
export const MountedTabActiveContext = createContext<boolean>(true);
interface SplitContainerProps {
layout: WorkspaceLayout;
workspaceKey: string;
@@ -214,26 +210,20 @@ const MountedTabSlot = memo(function MountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const wrapperStyle = useMemo(() => {
const display: "flex" | "none" = isVisible ? "flex" : "none";
return { display, flex: 1 };
}, [isVisible]);
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
return (
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<MountedTabActiveContext value={isVisible}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
</MountedTabActiveContext>
<RetainedPanel active={isVisible}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</RetainedPanel>
</RenderProfile>
);
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { getSyncedLoaderDotOpacity, getSyncedLoaderStep } from "./synced-loader-state";
describe("synced loader state", () => {
test("advances through six wall-clock-aligned steps every 950 milliseconds", () => {
const sampleTimes = [0, 158, 159, 316, 317, 474, 475, 633, 634, 791, 792, 949, 950];
const steps = sampleTimes.map(getSyncedLoaderStep);
expect(steps).toEqual([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 0]);
});
test("preserves the six visible snake states", () => {
const states: number[][] = [];
for (let step = 0; step < 6; step += 1) {
const dotOpacities: number[] = [];
for (let dot = 0; dot < 6; dot += 1) {
dotOpacities.push(getSyncedLoaderDotOpacity(step, dot));
}
states.push(dotOpacities);
}
expect(states).toEqual([
[1, 0, 0.78, 0, 0.56, 0.34],
[0.78, 1, 0.56, 0, 0.34, 0],
[0.56, 0.78, 0.34, 1, 0, 0],
[0.34, 0.56, 0, 0.78, 0, 1],
[0, 0.34, 0, 0.56, 1, 0.78],
[0, 0, 1, 0.34, 0.78, 0.56],
]);
});
});

View File

@@ -0,0 +1,22 @@
const SYNCED_LOADER_DURATION_MS = 950;
export const SYNCED_LOADER_DOT_COUNT = 6;
const SYNCED_LOADER_OPACITY_STATES = [
[1, 0, 0.78, 0, 0.56, 0.34],
[0.78, 1, 0.56, 0, 0.34, 0],
[0.56, 0.78, 0.34, 1, 0, 0],
[0.34, 0.56, 0, 0.78, 0, 1],
[0, 0.34, 0, 0.56, 1, 0.78],
[0, 0, 1, 0.34, 0.78, 0.56],
] as const;
export function getSyncedLoaderStep(nowMs: number): number {
"worklet";
const elapsedMs = nowMs % SYNCED_LOADER_DURATION_MS;
return Math.floor((elapsedMs * SYNCED_LOADER_DOT_COUNT) / SYNCED_LOADER_DURATION_MS);
}
export function getSyncedLoaderDotOpacity(step: number, dot: number): number {
"worklet";
return SYNCED_LOADER_OPACITY_STATES[step]?.[dot] ?? 0;
}

View File

@@ -1,65 +1,101 @@
import { useLayoutEffect, useMemo, useState } from "react";
import { View } from "react-native";
import Animated, {
Easing,
makeMutable,
type SharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
useReducedMotion,
useSharedValue,
} from "react-native-reanimated";
import { useEffect, useMemo } from "react";
import { scheduleOnUI } from "react-native-worklets";
import { useRetainedPanelActive } from "@/components/retained-panel";
import {
SYNCED_LOADER_DOT_COUNT,
getSyncedLoaderDotOpacity,
getSyncedLoaderStep,
} from "@/components/synced-loader-state";
const SYNCED_LOADER_DURATION_MS = 950;
const SYNCED_LOADER_EPOCH_MS = 0;
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
const DOT_COUNT = DOT_SEQUENCE.length;
const GRID_COLUMNS = 2;
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
const DOT_KEYS = Array.from({ length: DOT_COUNT }, (_, i) => `dot-${i}`);
const sharedStepProgress = makeMutable(0);
let sharedLoopStarted = false;
const DOT_KEYS = Array.from({ length: SYNCED_LOADER_DOT_COUNT }, (_, i) => `dot-${i}`);
const sharedStep = makeMutable(0);
const activeLoaderCount = makeMutable(0);
const clockRunning = makeMutable(false);
let nextStepListenerId = 1;
function ensureSharedStepLoopStarted(): void {
if (sharedLoopStarted) {
function advanceSharedStep(): void {
"worklet";
if (activeLoaderCount.value === 0) {
clockRunning.value = false;
return;
}
sharedLoopStarted = true;
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
sharedStepProgress.value = withTiming(
DOT_COUNT,
{
duration: Math.max(1, Math.round(SYNCED_LOADER_DURATION_MS - elapsedMs)),
easing: Easing.linear,
},
(finished) => {
if (!finished) {
sharedLoopStarted = false;
return;
}
sharedStepProgress.value = 0;
sharedStepProgress.value = withRepeat(
withTiming(DOT_COUNT, {
duration: SYNCED_LOADER_DURATION_MS,
easing: Easing.linear,
}),
-1,
false,
);
},
);
const nextStep = getSyncedLoaderStep(Date.now());
if (sharedStep.value !== nextStep) {
sharedStep.value = nextStep;
}
requestAnimationFrame(advanceSharedStep);
}
function registerStepListener(
step: SharedValue<number>,
registered: SharedValue<boolean>,
listenerId: number,
): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
step.value = getSyncedLoaderStep(Date.now());
sharedStep.addListener(listenerId, (nextStep) => {
step.value = nextStep;
});
activeLoaderCount.value += 1;
if (!clockRunning.value) {
clockRunning.value = true;
sharedStep.value = step.value;
requestAnimationFrame(advanceSharedStep);
}
}
function unregisterStepListener(registered: SharedValue<boolean>, listenerId: number): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
sharedStep.removeListener(listenerId);
activeLoaderCount.value -= 1;
}
function useSyncedLoaderStep(active: boolean, reduceMotion: boolean): SharedValue<number> {
// The local value lets retained loaders detach from the app-wide clock without
// unmounting their animated views or leaving hidden style worklets subscribed.
const step = useSharedValue(reduceMotion ? 0 : getSyncedLoaderStep(Date.now()));
const registered = useSharedValue(false);
const [listenerId] = useState(() => nextStepListenerId++);
useLayoutEffect(() => {
if (!active || reduceMotion) {
return;
}
scheduleOnUI(registerStepListener, step, registered, listenerId);
return () => {
scheduleOnUI(unregisterStepListener, registered, listenerId);
};
}, [active, listenerId, reduceMotion, registered, step]);
return step;
}
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
useEffect(() => {
ensureSharedStepLoopStarted();
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: 1,
}));
const active = useRetainedPanelActive();
const reduceMotion = useReducedMotion();
const step = useSyncedLoaderStep(active, reduceMotion);
const gap = Math.max(1, Math.round(size * 0.12));
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
@@ -67,10 +103,9 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
const gridHeight = dotSize * 3 + gap * 2;
const gridStyle = useMemo(
() => [animatedStyle, { width: gridWidth, height: gridHeight }],
[animatedStyle, gridWidth, gridHeight],
() => ({ width: gridWidth, height: gridHeight }),
[gridHeight, gridWidth],
);
const containerStyle = useMemo(
() =>
({
@@ -84,25 +119,24 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
return (
<View style={containerStyle}>
<Animated.View style={gridStyle}>
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
<View style={gridStyle}>
{Array.from({ length: SYNCED_LOADER_DOT_COUNT }).map((_, dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS;
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
return (
<SpinnerDot
key={DOT_KEYS[dotIndex]}
color={color}
dotSize={dotSize}
sequenceIndex={sequenceIndex}
progress={sharedStepProgress}
dotIndex={dotIndex}
step={step}
left={columnIndex * (dotSize + gap)}
top={rowIndex * (dotSize + gap)}
/>
);
})}
</Animated.View>
</View>
</View>
);
}
@@ -110,35 +144,21 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
function SpinnerDot({
color,
dotSize,
sequenceIndex,
progress,
dotIndex,
step,
left,
top,
}: {
color: string;
dotSize: number;
sequenceIndex: number;
progress: SharedValue<number>;
dotIndex: number;
step: SharedValue<number>;
left: number;
top: number;
}) {
const animatedStyle = useAnimatedStyle(() => {
const headIndex = Math.floor(progress.value) % DOT_COUNT;
let opacity = 0;
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
const activeSequenceIndex =
(headIndex + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
if (sequenceIndex === activeSequenceIndex) {
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
break;
}
}
return {
opacity,
};
});
const animatedStyle = useAnimatedStyle(() => ({
opacity: getSyncedLoaderDotOpacity(step.value, dotIndex),
}));
const dotStyle = useMemo(
() => [

View File

@@ -180,7 +180,7 @@ export function TerminalPane({
return trimmed.length > 0 ? trimmed : undefined;
}, [settings.monoFontFamily]);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const mobileView = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile && mobileView === "agent";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({

View File

@@ -0,0 +1,82 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import { SPACING, type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// Shared presentation primitives for the app's directory trees. Both the Files
// explorer (server-loaded listings) and the Changes view (client-built from diff
// paths) render different data, but their ROWS should look identical — same
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
}
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedChevronRight = withUnistyles(ChevronRight);
/**
* Vertical guide lines connecting nested rows to their ancestors — one line per
* ancestor depth level, positioned absolutely within the (relative) row. Renders
* nothing at depth 0.
*/
export function TreeIndentGuides({ depth }: { depth: number }) {
const guides = useMemo(
() =>
Array.from({ length: depth }, (_, index) => ({
key: index,
style: [
styles.indentGuide,
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
],
})),
[depth],
);
return (
<>
{guides.map((guide) => (
<View key={guide.key} style={guide.style} pointerEvents="none" />
))}
</>
);
}
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
export function TreeChevron({ expanded }: { expanded: boolean }) {
return (
<View style={expanded ? CHEVRON_EXPANDED_STYLE : styles.chevron}>
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
const styles = StyleSheet.create((theme: Theme) => ({
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
}));
// Stable module-level style ref so TreeChevron passes a constant array, not one created
// per render — satisfies react-perf (no inline-array prop) without a per-render useMemo.
const CHEVRON_EXPANDED_STYLE = [styles.chevron, styles.chevronExpanded];

View File

@@ -16,10 +16,16 @@ import type {
TextStyle,
ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
buttonIconSize,
createControlGeometry,
type ButtonControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "xs" | "sm" | "md" | "lg";
type ButtonSize = ButtonControlSize;
type LeftIcon =
| ReactElement
@@ -27,85 +33,130 @@ type LeftIcon =
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { xs: 12, sm: 14, md: 16, lg: 20 };
interface ButtonIconProps {
loading: boolean;
leftIcon?: LeftIcon;
iconSize: number;
iconColor: string;
}
const styles = StyleSheet.create((theme) => ({
base: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: "transparent",
},
md: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
xs: {
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
sm: {
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
lg: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius.xl,
},
default: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
secondary: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.surface3,
},
outline: {
backgroundColor: "transparent",
borderColor: theme.colors.borderAccent,
},
ghost: {
backgroundColor: "transparent",
borderColor: "transparent",
},
destructive: {
backgroundColor: theme.colors.destructive,
borderColor: theme.colors.destructive,
},
pressed: {
opacity: 0.85,
},
disabled: {
opacity: theme.opacity[50],
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
textXs: {
fontSize: theme.fontSize.xs,
},
textDefault: {
color: theme.colors.accentForeground,
},
textDestructive: {
color: theme.colors.destructiveForeground,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
}));
function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps) {
if (loading) {
return (
<View>
<ActivityIndicator size="small" color={iconColor} />
</View>
);
}
if (!leftIcon) return null;
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
if (
typeof leftIcon === "function" &&
!leftIcon.prototype?.isReactComponent &&
leftIcon.length > 0
) {
return <View>{(leftIcon as (color: string) => ReactElement)(iconColor)}</View>;
}
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return (
<View>
<Icon color={iconColor} size={iconSize} />
</View>
);
}
const ThemedButtonIcon = withUnistyles(ButtonIcon);
const foregroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
const foregroundMutedIconMapping = (theme: Theme) => ({
iconColor: theme.colors.foregroundMuted,
});
const accentForegroundIconMapping = (theme: Theme) => ({
iconColor: theme.colors.accentForeground,
});
const destructiveForegroundIconMapping = (theme: Theme) => ({
iconColor: theme.colors.destructiveForeground,
});
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
base: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: "transparent",
},
md: {
...geometry.buttonMd,
},
xs: {
...geometry.buttonXs,
},
sm: {
...geometry.buttonSm,
},
lg: {
...geometry.buttonLg,
},
default: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
secondary: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.surface3,
},
outline: {
backgroundColor: "transparent",
borderColor: theme.colors.borderAccent,
},
ghost: {
backgroundColor: "transparent",
borderColor: "transparent",
},
destructive: {
backgroundColor: theme.colors.destructive,
borderColor: theme.colors.destructive,
},
pressed: {
opacity: 0.85,
},
disabled: {
opacity: theme.opacity[50],
},
text: {
color: theme.colors.foreground,
...geometry.buttonText,
fontWeight: theme.fontWeight.normal,
},
textXs: {
...geometry.buttonTextXs,
},
textDefault: {
color: theme.colors.accentForeground,
},
textDestructive: {
color: theme.colors.destructiveForeground,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
};
});
export function Button({
children,
@@ -118,6 +169,7 @@ export function Button({
disabled,
loading = false,
accessibilityRole,
accessibilityState: accessibilityStateProp,
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
@@ -131,7 +183,6 @@ export function Button({
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const isDisabled = disabled || loading;
let variantStyle: ViewStyle;
@@ -188,55 +239,21 @@ export function Button({
);
const accessibilityState = useMemo(
() => ({ disabled: isDisabled, busy: loading }),
[isDisabled, loading],
() => ({ ...accessibilityStateProp, disabled: isDisabled, busy: loading }),
[accessibilityStateProp, isDisabled, loading],
);
function resolveIconColor(): string {
function resolveIconMapping() {
if (variant === "default") {
return theme.colors.accentForeground;
return accentForegroundIconMapping;
}
if (variant === "destructive") {
return destructiveForegroundIconMapping;
}
if (variant === "ghost") {
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return isGhostHovered ? foregroundIconMapping : foregroundMutedIconMapping;
}
return theme.colors.foreground;
}
function renderIcon() {
if (loading) {
return (
<View>
<ActivityIndicator size="small" color={resolveIconColor()} />
</View>
);
}
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = resolveIconColor();
const iconSize = ICON_SIZE[size];
// Render function
if (
typeof leftIcon === "function" &&
!leftIcon.prototype?.isReactComponent &&
leftIcon.length > 0
) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return (
<View>
<Icon color={color} size={iconSize} />
</View>
);
return foregroundIconMapping;
}
return (
@@ -249,7 +266,12 @@ export function Button({
onHoverOut={handleHoverOut}
style={pressableStyle}
>
{renderIcon()}
<ThemedButtonIcon
loading={loading}
leftIcon={leftIcon}
iconSize={buttonIconSize[size]}
uniProps={resolveIconMapping()}
/>
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
{trailing}
</Pressable>

View File

@@ -0,0 +1,58 @@
import type { ViewStyle } from "react-native";
import { describe, expect, it } from "vitest";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
function buildWidthStyle(input: {
desktopMinWidth?: number;
referenceWidth: number | null;
}): Pick<ViewStyle, "width" | "minWidth" | "maxWidth"> {
const [frameStyle] = buildDesktopFrameStyle({
desktopMinWidth: input.desktopMinWidth,
referenceWidth: input.referenceWidth,
desktopFixedHeight: undefined,
desktopPositionStyle: { left: 0, top: 0 },
shouldHideDesktopContent: false,
availableHeight: undefined,
}) as ViewStyle[];
return {
width: frameStyle.width,
minWidth: frameStyle.minWidth,
maxWidth: frameStyle.maxWidth,
};
}
describe("buildDesktopFrameStyle", () => {
it("lets a narrow trigger grow to the default desktop ceiling", () => {
expect(buildWidthStyle({ referenceWidth: 120 })).toEqual({
width: undefined,
minWidth: 120,
maxWidth: 400,
});
});
it("keeps a wide trigger from being capped below its own width", () => {
expect(buildWidthStyle({ referenceWidth: 470 })).toEqual({
width: undefined,
minWidth: 470,
maxWidth: 470,
});
});
it("uses desktopMinWidth as an explicit floor raiser", () => {
expect(buildWidthStyle({ desktopMinWidth: 360, referenceWidth: 120 })).toEqual({
width: undefined,
minWidth: 360,
maxWidth: 400,
});
});
it("keeps the trigger as the floor when it is wider than desktopMinWidth", () => {
expect(buildWidthStyle({ desktopMinWidth: 240, referenceWidth: 300 })).toEqual({
width: undefined,
minWidth: 300,
maxWidth: 400,
});
});
});

View File

@@ -0,0 +1,42 @@
import type { StyleProp, ViewStyle } from "react-native";
export interface DesktopFrameStyleInput {
desktopMinWidth: number | undefined;
referenceWidth: number | null;
desktopFixedHeight: number | undefined;
desktopPositionStyle: StyleProp<ViewStyle>;
shouldHideDesktopContent: boolean;
availableHeight: number | undefined;
}
export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
desktopPositionStyle,
shouldHideDesktopContent,
availableHeight,
} = input;
const fixedHeightStyle =
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null;
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
const availableHeightStyle =
typeof availableHeight === "number"
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
return [
{
position: "absolute" as const,
minWidth: floor,
maxWidth: Math.max(400, floor),
},
fixedHeightStyle,
desktopPositionStyle,
hiddenStyle,
availableHeightStyle,
];
}

View File

@@ -1,9 +1,18 @@
import { forwardRef, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import {
forwardRef,
useCallback,
useMemo,
useState,
type ReactElement,
type ReactNode,
} from "react";
import {
Pressable,
View,
type NativeSyntheticEvent,
type PressableProps,
type PressableStateCallbackType,
type TargetedEvent,
type ViewStyle,
type StyleProp,
} from "react-native";
@@ -20,6 +29,7 @@ const chevronColorMapping = (theme: Theme) => ({
interface TriggerState {
pressed: boolean;
hovered: boolean;
focused: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
@@ -36,23 +46,45 @@ interface ComboboxTriggerProps extends Omit<PressableProps, "style" | "children"
}
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
{ children, chevron, style, block = false, ...props },
{ children, chevron, style, block = false, onFocus, onBlur, ...props },
ref,
): ReactElement {
const [focused, setFocused] = useState(false);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered });
return style({ pressed, hovered, focused });
}
return style;
},
[style],
[focused, style],
);
const handleFocus = useCallback(
(event: NativeSyntheticEvent<TargetedEvent>) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = useCallback(
(event: NativeSyntheticEvent<TargetedEvent>) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
return (
<Pressable ref={ref} collapsable={false} style={pressableStyle} {...props}>
<Pressable
ref={ref}
collapsable={false}
style={pressableStyle}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
>
<View style={rowStyle}>
{children}
{chevron !== null &&

View File

@@ -61,10 +61,14 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
const IS_WEB = isWeb;
export type ComboboxOption = ComboboxOptionModel;
export type ComboboxDesktopPlacement = "top-start" | "bottom-start";
export interface ComboboxProps {
options: ComboboxOption[];
@@ -99,7 +103,7 @@ export interface ComboboxProps {
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
desktopPlacement?: ComboboxDesktopPlacement;
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation
@@ -381,7 +385,7 @@ function OptionsList({
interface DesktopPositionInput {
isDesktopAboveSearch: boolean;
isMobile: boolean;
desktopPlacement: "top-start" | "bottom-start";
desktopPlacement: ComboboxDesktopPlacement;
referenceTop: number | null;
referenceLeft: number | null;
referenceAtOrigin: boolean;
@@ -656,7 +660,7 @@ interface DesktopResetSetters {
function useDesktopPositionReset(
isOpen: boolean,
isMobile: boolean,
desktopPlacement: "top-start" | "bottom-start",
desktopPlacement: ComboboxDesktopPlacement,
update: () => unknown,
setters: DesktopResetSetters,
) {
@@ -850,46 +854,6 @@ function buildFloatingMiddleware(input: FloatingMiddlewareInput) {
];
}
interface DesktopContainerStyleInput {
desktopMinWidth: number | undefined;
referenceWidth: number | null;
desktopFixedHeight: number | undefined;
desktopPositionStyle: DesktopPositionResult["desktopPositionStyle"];
shouldHideDesktopContent: boolean;
availableHeight: number | undefined;
}
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
desktopPositionStyle,
shouldHideDesktopContent,
availableHeight,
} = input;
const fixedHeightStyle =
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null;
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
const availableHeightStyle =
typeof availableHeight === "number"
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
return [
{
position: "absolute" as const,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
maxWidth: Math.max(400, desktopMinWidth ?? 0),
},
fixedHeightStyle,
desktopPositionStyle,
hiddenStyle,
availableHeightStyle,
];
}
function isDesktopKey(key: string): key is DesktopKey {
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
}
@@ -1255,7 +1219,7 @@ export function Combobox({
presentation,
open,
onOpenChange,
desktopPlacement = "top-start",
desktopPlacement = "bottom-start",
desktopPreventInitialFlash = true,
desktopMinWidth,
desktopFixedHeight,

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
createControlGeometry,
getControlInteractionPhase,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
const theme = {
borderRadius: {
md: 6,
lg: 8,
xl: 12,
},
borderWidth: {
1: 1,
},
colors: {
accent: "#20744A",
borderAccent: "#2F3534",
},
fontSize: {
xs: 12,
sm: 14,
base: 16,
},
opacity: {
50: 0.5,
},
spacing: {
0: 0,
3: 12,
4: 16,
6: 24,
},
} as unknown as Theme;
describe("control geometry", () => {
it("keeps resting control borders transparent while preserving border geometry", () => {
const geometry = createControlGeometry(theme);
expect(geometry.controlRest).toMatchObject({
borderWidth: 1,
borderColor: "transparent",
outlineColor: "transparent",
outlineWidth: 0,
});
});
it("uses the shared hover border and active focus ring values", () => {
const geometry = createControlGeometry(theme);
expect(geometry.controlHover).toEqual({
borderColor: "#2F3534",
});
expect(geometry.controlActive).toEqual({
borderColor: "#2F3534",
outlineColor: "#20744A",
outlineOffset: 1,
outlineStyle: "solid",
outlineWidth: 2,
});
});
it("resolves disabled, focus, open, pressed, and hover into one interaction phase", () => {
expect(getControlInteractionPhase({ disabled: true, focused: true })).toBe("rest");
expect(getControlInteractionPhase({ focused: true })).toBe("active");
expect(getControlInteractionPhase({ open: true })).toBe("active");
expect(getControlInteractionPhase({ pressed: true })).toBe("active");
expect(getControlInteractionPhase({ hovered: true })).toBe("hover");
expect(getControlInteractionPhase({})).toBe("rest");
});
it("keeps field text sizing tied to control size", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldTextSm.fontSize).toBe(14);
expect(geometry.fieldTextSm.lineHeight).toBe(20);
expect(geometry.fieldTextMd.fontSize).toBe(16);
expect(geometry.fieldTextMd.lineHeight).toBe(22);
expect(geometry.formTextInputSm.fontSize).toBe(14);
expect(geometry.formTextInputSm.lineHeight).toBe(20);
expect(geometry.formTextInputMd.fontSize).toBe(16);
expect(geometry.formTextInputMd.lineHeight).toBe(22);
});
it("derives field padding from line height without changing the control height", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
geometry.fieldControlSm.minHeight,
);
expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
geometry.fieldControlMd.minHeight,
);
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("subtracts segmented control inset from the nested segment radius", () => {
const geometry = createControlGeometry(theme);
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
});
});

View File

@@ -0,0 +1,224 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "sm" | "md";
export type SegmentedControlSize = "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
hovered?: boolean;
focused?: boolean;
pressed?: boolean;
open?: boolean;
active?: boolean;
disabled?: boolean;
}
export interface ControlInteractionStyleMap {
controlRest: StyleProp<ViewStyle>;
controlHover: StyleProp<ViewStyle>;
controlActive: StyleProp<ViewStyle>;
controlDisabled?: StyleProp<ViewStyle>;
}
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
const SWITCH_TRACK_HEIGHT = 20;
const SWITCH_THUMB_SIZE = 16;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
const controlHeights = {
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
};
export const buttonIconSize: Record<ButtonControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
lg: ICON_SIZE.lg,
};
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
export const switchGeometry = {
trackWidth: SWITCH_TRACK_WIDTH,
trackHeight: SWITCH_TRACK_HEIGHT,
thumbSize: SWITCH_THUMB_SIZE,
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
};
function nestedRadius(containerRadius: number, inset: number): number {
return Math.max(0, containerRadius - inset);
}
function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
return (controlHeight - lineHeight) / 2;
}
export function getControlInteractionPhase(
state: ControlInteractionState,
): ControlInteractionPhase {
if (state.disabled) {
return "rest";
}
if (state.active || state.focused || state.open || state.pressed) {
return "active";
}
if (state.hovered) {
return "hover";
}
return "rest";
}
export function resolveControlInteractionStyles(
styles: ControlInteractionStyleMap,
state: ControlInteractionState,
): StyleProp<ViewStyle> {
const phase = getControlInteractionPhase(state);
return [
styles.controlRest,
phase === "hover" ? styles.controlHover : null,
phase === "active" ? styles.controlActive : null,
state.disabled ? styles.controlDisabled : null,
];
}
export function createControlGeometry(theme: Theme) {
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlSm = {
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(controlHeights.compact, fieldTextSmLineHeight),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: controlHeights.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(controlHeights.field, fieldTextMdLineHeight),
borderRadius: theme.borderRadius.lg,
};
const fieldTextSm = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextSmLineHeight,
};
const fieldTextMd = {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const segmentedContainerSmRadius = theme.borderRadius.md;
const segmentedContainerMdRadius = theme.borderRadius.lg;
const switchControl = {
minHeight: controlHeights.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
} satisfies { minHeight: number; justifyContent: "center" };
return {
buttonXs: {
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
buttonSm: {
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
buttonMd: {
minHeight: controlHeights.field,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
},
buttonLg: {
minHeight: controlHeights.field,
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius.xl,
},
buttonText: {
fontSize: theme.fontSize.sm,
},
buttonTextXs: {
fontSize: theme.fontSize.xs,
},
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
},
formTextInputMd: {
...fieldControlMd,
...fieldTextMd,
},
formTextInput: {
...fieldControlMd,
...fieldTextMd,
},
fieldControlSm,
fieldControlMd,
fieldTextSm,
fieldTextMd,
controlRest: {
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
},
controlHover: {
borderColor: theme.colors.borderAccent,
},
controlActive: {
borderColor: theme.colors.borderAccent,
outlineColor: theme.colors.accent,
outlineOffset: CONTROL_FOCUS_RING_OFFSET,
outlineStyle: "solid" as const,
outlineWidth: CONTROL_FOCUS_RING_WIDTH,
},
controlFocusRingColor: {
outlineColor: theme.colors.accent,
},
controlDisabled: {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerSm: {
minHeight: controlHeights.compact,
padding: SEGMENTED_COMPACT_INSET,
borderRadius: segmentedContainerSmRadius,
},
segmentedContainerMd: {
minHeight: controlHeights.field,
padding: SEGMENTED_FIELD_INSET,
borderRadius: segmentedContainerMdRadius,
},
segmentedSegmentSm: {
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[4],
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
},
segmentedSegmentMd: {
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[6],
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
},
segmentedLabelSm: {
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.base,
},
};
}

View File

@@ -1,9 +1,30 @@
import { forwardRef, useMemo, type ReactNode } from "react";
import { Text, View, type TextInput } from "react-native";
import {
forwardRef,
useCallback,
useMemo,
useState,
type ForwardedRef,
type ReactNode,
} from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
Text,
View,
type PressableStateCallbackType,
type TextInput,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
interface FormFieldProps {
interface FieldProps {
label: string;
children: ReactNode;
hint?: string;
@@ -11,20 +32,20 @@ interface FormFieldProps {
testID?: string;
}
export function FormField({ label, children, hint, error, testID }: FormFieldProps) {
export function Field({ label, children, hint, error, testID }: FieldProps) {
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
const hintOrError = useMemo(() => {
const subtext = useMemo(() => {
if (error) {
return (
<Text style={styles.errorText} testID={errorTestID}>
<Text numberOfLines={1} style={styles.errorText} testID={errorTestID}>
{error}
</Text>
);
}
if (hint) {
return (
<Text style={styles.hintText} testID={hintTestID}>
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
{hint}
</Text>
);
@@ -36,17 +57,167 @@ export function FormField({ label, children, hint, error, testID }: FormFieldPro
<View style={styles.container} testID={testID}>
<Text style={styles.label}>{label}</Text>
{children}
{hintOrError}
{subtext}
</View>
);
}
export const FormTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(function FormTextInput(
{ style, ...props },
type FormTextInputProps = AdaptiveTextInputProps & {
size?: FieldControlSize;
};
type FlatFormTextInputStyle = ViewStyle & TextStyle;
interface SplitFormTextInputStyle {
chromeStyle?: ViewStyle;
inputStyle?: TextStyle;
}
function splitFormTextInputStyle(style: AdaptiveTextInputProps["style"]): SplitFormTextInputStyle {
const flattened = RNStyleSheet.flatten(style) as FlatFormTextInputStyle | undefined;
if (!flattened) {
return {};
}
const {
color,
fontFamily,
fontSize,
fontStyle,
fontVariant,
fontWeight,
includeFontPadding,
letterSpacing,
lineHeight,
textAlign,
textAlignVertical,
textDecorationColor,
textDecorationLine,
textDecorationStyle,
textShadowColor,
textShadowOffset,
textShadowRadius,
textTransform,
writingDirection,
...chromeStyle
} = flattened;
const inputStyle: TextStyle = {
color,
fontFamily,
fontSize,
fontStyle,
fontVariant,
fontWeight,
includeFontPadding,
letterSpacing,
lineHeight,
textAlign,
textAlignVertical,
textDecorationColor,
textDecorationLine,
textDecorationStyle,
textShadowColor,
textShadowOffset,
textShadowRadius,
textTransform,
writingDirection,
};
return {
chromeStyle: stripUnistylesMetadata(chromeStyle),
inputStyle: stripUnistylesMetadata(inputStyle),
};
}
function stripUnistylesMetadata<TStyle extends object>(style: TStyle): TStyle {
const cleanStyle: Record<string, unknown> = {};
for (const [key, value] of Object.entries(style as Record<string, unknown>)) {
if (key.startsWith("unistyles_") || value === undefined) {
continue;
}
cleanStyle[key] = value;
}
return cleanStyle as TStyle;
}
function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInput | null): void {
if (typeof forwardedRef === "function") {
forwardedRef(node);
return;
}
if (forwardedRef) {
forwardedRef.current = node;
}
}
export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function FormTextInput(
{ size = "md", style, onFocus, onBlur, editable, ...props },
ref,
) {
const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
return <AdaptiveTextInput ref={ref} {...props} style={inputStyle} />;
const [focused, setFocused] = useState(false);
const isDisabled = editable === false;
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback(
(node: TextInput | null) => {
assignTextInputRef(ref, node);
},
[ref],
);
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
(event) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const inputStyle = useMemo(
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
[inputSizeStyle, splitStyle.inputStyle],
) as AdaptiveTextInputProps["style"];
const chromeStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
formInputStyles.chrome,
chromeSizeStyle,
resolveControlInteractionStyles(
{
controlRest: formInputStyles.controlRest,
controlHover: formInputStyles.controlHover,
controlActive: formInputStyles.controlActive,
controlDisabled: formInputStyles.controlDisabled,
},
{
hovered,
focused,
disabled: isDisabled,
},
),
splitStyle.chromeStyle,
],
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
);
return (
<Pressable disabled={isDisabled} style={chromeStyle}>
<AdaptiveTextInput
ref={setInputRef}
editable={editable}
{...props}
onBlur={handleBlur}
onFocus={handleFocus}
style={inputStyle}
/>
</Pressable>
);
});
const styles = StyleSheet.create((theme) => ({
@@ -56,27 +227,59 @@ const styles = StyleSheet.create((theme) => ({
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: Math.round(theme.fontSize.xs * 1.4),
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
lineHeight: Math.round(theme.fontSize.xs * 1.4),
},
}));
const formInputStyles = StyleSheet.create((theme) => ({
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
}));
const formInputStyles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
chrome: {
backgroundColor: theme.colors.surface2,
},
chromeSm: {
...geometry.fieldControlSm,
},
chromeMd: {
...geometry.fieldControlMd,
},
controlRest: {
...geometry.controlRest,
},
controlHover: {
...geometry.controlHover,
},
controlActive: {
...geometry.controlActive,
},
controlDisabled: {
...geometry.controlDisabled,
},
input: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
paddingHorizontal: 0,
paddingVertical: 0,
outlineColor: "transparent",
outlineWidth: 0,
},
inputSm: {
...geometry.fieldTextSm,
},
inputMd: {
...geometry.fieldTextMd,
},
};
});

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