Compare commits

...

29 Commits

Author SHA1 Message Date
Mohamed Boudra
603b380321 fix(app): preserve workspace-root link tooltips
Workspace file resolution deliberately rejects the root as an openable file, so tooltip formatting preserves its display-only dot form separately.
2026-07-11 09:21:12 +00:00
Mohamed Boudra
ac45b2f2ae fix(app): show Windows file links relative to workspace
Tooltip formatting only handled matching POSIX separators, so Windows paths could remain absolute. Reuse the workspace file-path resolver for normalized relative paths.
2026-07-10 18:49:06 +00: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
161 changed files with 8507 additions and 4546 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,5 +1,30 @@
# Changelog
## 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

View File

@@ -45,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

@@ -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

@@ -303,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`.

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

@@ -0,0 +1,88 @@
# 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.
## 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,6 +26,8 @@ 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 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. 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

@@ -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

@@ -1 +1 @@
sha256-G88S68t7czYE7YtBIuRdkI85+GpVKEZUMZQtcnN73BA=
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.104",
"version": "0.1.105",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.104",
"version": "0.1.105",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.104",
"version": "0.1.105",
"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",
"version": "0.1.105",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/server": "0.1.104",
"@getpaseo/client": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/server": "0.1.105",
"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",
"version": "0.1.105",
"dependencies": {
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.104",
"version": "0.1.105",
"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",
"version": "0.1.105",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.104",
"version": "0.1.105",
"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",
"version": "0.1.105",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.104",
"version": "0.1.105",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.104",
"version": "0.1.105",
"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",
"@getpaseo/highlight": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"@getpaseo/client": "0.1.105",
"@getpaseo/highlight": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@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",
"version": "0.1.105",
"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",
"version": "0.1.105",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

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,7 @@
import path from "node:path";
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";
@@ -50,7 +51,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 +75,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 +102,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");

View File

@@ -1,16 +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) {
@@ -102,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,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

@@ -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

@@ -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",
"version": "0.1.105",
"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

@@ -52,6 +52,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";
@@ -318,6 +319,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(
@@ -633,10 +635,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";
@@ -81,6 +68,8 @@ 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";
@@ -99,13 +88,7 @@ import { 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 +387,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);
@@ -476,26 +453,22 @@ function AppContainer({
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && <LeftSidebar />}
{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 && chromeEnabled && <LeftSidebar />}
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
@@ -511,11 +484,13 @@ function AppContainer({
</View>
);
if (!isCompactLayout) {
return content;
}
const content = isCompactLayout ? (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
) : (
surface
);
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
return <SidebarModelProvider>{content}</SidebarModelProvider>;
}
function MobileGestureWrapper({
@@ -525,127 +500,7 @@ 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}>
@@ -881,7 +736,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 +749,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() {
@@ -973,7 +804,7 @@ function WorkspaceRouteNavigationBridge() {
function AppShell() {
return (
<SidebarAnimationProvider>
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
@@ -981,7 +812,7 @@ function AppShell() {
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</MobilePanelsProvider>
);
}
@@ -998,31 +829,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 +863,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

@@ -18,6 +18,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
@@ -38,7 +39,7 @@ export function AssistantMarkdownLink({
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
[target, workspaceRoot],
);
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
@@ -147,38 +148,6 @@ export function AssistantMarkdownCodeLink({
);
}
function formatInlinePathTargetForTooltip(
target: { path: string; lineStart?: number; lineEnd?: number },
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
if (!workspaceRoot) {
return filePath;
}
const root = workspaceRoot.replace(/\/+$/, "");
if (!root) {
return filePath;
}
if (filePath === root) {
return ".";
}
const prefix = `${root}/`;
if (filePath.startsWith(prefix)) {
return filePath.slice(prefix.length);
}
return filePath;
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { formatFileLinkTooltipPath } from "./tooltip-path";
describe("formatFileLinkTooltipPath", () => {
it("shows a Windows file path relative to its workspace regardless of separators", () => {
expect(
formatFileLinkTooltipPath({
target: {
path: "C:/Users/me/repo/src/app.ts",
lineStart: 12,
lineEnd: 20,
},
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toBe("src/app.ts:12-20");
});
it("shows the workspace root as a dot", () => {
expect(
formatFileLinkTooltipPath({
target: { path: "/Users/me/repo" },
workspaceRoot: "/Users/me/repo",
}),
).toBe(".");
});
it("keeps an absolute path outside the workspace", () => {
expect(
formatFileLinkTooltipPath({
target: { path: "/Users/me/notes.md" },
workspaceRoot: "/Users/me/repo",
}),
).toBe("/Users/me/notes.md");
});
it("keeps the target path when the workspace root is unavailable", () => {
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
"src/app.ts:12",
);
});
});

View File

@@ -0,0 +1,35 @@
import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
interface FormatFileLinkTooltipPathInput {
target: WorkspaceFileLocation;
workspaceRoot?: string;
}
export function formatFileLinkTooltipPath({
target,
workspaceRoot,
}: FormatFileLinkTooltipPathInput): string {
const normalizedTargetPath = normalizeWorkspacePath(target.path);
const normalizedWorkspaceRoot = normalizeWorkspacePath(workspaceRoot);
let isWorkspaceRoot = false;
if (normalizedTargetPath && normalizedWorkspaceRoot) {
isWorkspaceRoot = normalizedTargetPath === normalizedWorkspaceRoot;
if (/^[A-Za-z]:\//.test(normalizedTargetPath)) {
isWorkspaceRoot =
normalizedTargetPath.toLowerCase() === normalizedWorkspaceRoot.toLowerCase();
}
}
const resolvedPaths = workspaceRoot
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
: null;
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}

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,10 +29,9 @@ 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";
@@ -91,25 +90,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 +107,38 @@ 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>
<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>
);
}
@@ -604,17 +443,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

@@ -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,14 @@ 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,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { type SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
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 +51,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 +69,20 @@ 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[];
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 +108,6 @@ interface SidebarLabels {
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
@@ -136,11 +120,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 +131,18 @@ export const LeftSidebar = memo(function LeftSidebar({
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const {
workspacePlacements,
projects,
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,7 +233,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const sharedProps = {
theme,
statusWorkspacePlacements,
statusGroups,
projects,
projectNamesByKey,
isInitialLoad,
@@ -278,7 +254,6 @@ export const LeftSidebar = memo(function LeftSidebar({
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
@@ -556,7 +531,7 @@ function SidebarFooter({
function MobileSidebar({
theme,
statusWorkspacePlacements,
statusGroups,
projects,
projectNamesByKey,
isInitialLoad,
@@ -576,7 +551,6 @@ function MobileSidebar({
handleOpenHostSettings,
insetsTop,
insetsBottom,
isOpen,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
@@ -584,268 +558,118 @@ 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}
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,
projectNamesByKey,
isInitialLoad,
@@ -970,7 +794,7 @@ function DesktopSidebar({
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
statusGroups={statusGroups}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
@@ -1059,17 +883,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

@@ -3038,6 +3038,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 +3056,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 +3101,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 +3114,7 @@ export const ToolCall = memo(function ToolCall({
setIsExpanded((prev) => !prev);
}
}, [
isMobile,
shouldRenderInline,
openToolCall,
presentation.displayName,
presentation.summary,
@@ -3120,22 +3125,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 +3153,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 +3162,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 +3180,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 +3205,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

@@ -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,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

@@ -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";
@@ -68,7 +64,6 @@ import {
shouldShowSidebarHostLabels,
useSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
@@ -93,6 +88,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,
@@ -142,15 +139,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,7 +221,7 @@ function selectionForSelectedWorkspace(
}
interface SidebarWorkspaceListProps {
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
statusGroups: StatusGroup[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
collapsedProjectKeys: ReadonlySet<string>;
@@ -358,12 +351,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 +527,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 +674,7 @@ function WorkspaceRowRightGroup({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -713,97 +694,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,
@@ -2166,7 +2056,7 @@ function areProjectBlockSelectionsEqual(
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
export function SidebarWorkspaceList({
statusWorkspacePlacements,
statusGroups,
projects,
projectNamesByKey,
collapsedProjectKeys,
@@ -2196,7 +2086,7 @@ export function SidebarWorkspaceList({
const content =
groupMode === "status" ? (
<SidebarStatusModeWrapper
statusWorkspacePlacements={statusWorkspacePlacements}
statusGroups={statusGroups}
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
@@ -2224,14 +2114,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 +2132,7 @@ function SidebarStatusModeWrapper({
return (
<SidebarStatusWorkspaceList
workspaces={statusWorkspacePlacements}
groups={statusGroups}
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
@@ -2268,7 +2158,7 @@ function ProjectModeList({
supportsMultiplicityByServerId,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
> & {
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
@@ -2872,14 +2762,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,76 @@
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
type SidebarWorkspacesListResult,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
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 {
groupMode: SidebarGroupMode;
statusGroups: StatusGroup[];
collapsedProjectKeys: ReadonlySet<string>;
toggleProjectCollapsed: (projectKey: string) => void;
shortcutModel: SidebarShortcutModel;
}
const SidebarModelContext = createContext<SidebarModel | null>(null);
export function SidebarModelProvider({ children }: { 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 statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: list.workspacePlacements,
enabled: isStatusMode,
});
const statusGroups = useMemo(
() =>
isStatusMode ? buildStatusGroups(statusWorkspacePlacements, list.projectNamesByKey) : [],
[isStatusMode, list.projectNamesByKey, statusWorkspacePlacements],
);
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,
groupMode,
statusGroups,
collapsedProjectKeys,
toggleProjectCollapsed,
shortcutModel,
}),
[collapsedProjectKeys, groupMode, list, shortcutModel, statusGroups, toggleProjectCollapsed],
);
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

@@ -9,11 +9,7 @@ import {
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
type StatusGroup,
} from "@/hooks/sidebar-status-view-model";
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,18 +21,8 @@ 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";
@@ -47,7 +33,6 @@ 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 +45,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 +62,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 +73,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}>
@@ -657,7 +618,7 @@ function StatusWorkspaceActionSlot({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -675,105 +636,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 +738,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

@@ -1,22 +1,13 @@
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";
@@ -28,8 +19,9 @@ 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 { 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 +30,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 {
@@ -458,7 +425,7 @@ function WorkspaceRowTrailingActions({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -478,103 +445,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 +500,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

@@ -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

@@ -14,6 +14,7 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import type { HostProfile } from "@/types/host-connection";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
import { SidebarModelProvider } from "./sidebar/sidebar-model";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
@@ -122,7 +123,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
root?.render(
<SidebarModelProvider>
<WorkspaceShortcutTargetsSubscriber enabled={true} />
</SidebarModelProvider>,
);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -186,7 +191,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
root?.render(
<SidebarModelProvider>
<WorkspaceShortcutTargetsSubscriber enabled={true} />
</SidebarModelProvider>,
);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -220,7 +229,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
root?.render(
<SidebarModelProvider>
<WorkspaceShortcutTargetsSubscriber enabled={true} />
</SidebarModelProvider>,
);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -238,11 +251,19 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
root?.render(
<SidebarModelProvider>
<WorkspaceShortcutTargetsSubscriber enabled={true} />
</SidebarModelProvider>,
);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
root?.render(
<SidebarModelProvider>
<WorkspaceShortcutTargetsSubscriber enabled={false} />
</SidebarModelProvider>,
);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);

View File

@@ -1,56 +1,13 @@
import { useEffect, useMemo } from "react";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useEffect } from "react";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import {
buildSidebarShortcutModel,
buildStatusSidebarShortcutModel,
} from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({
enabled,
});
const groupMode = useSidebarViewStore((state) => state.groupMode);
const isStatusMode = enabled && groupMode === "status";
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
enabled: isStatusMode,
});
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const { shortcutModel } = useSidebarModel();
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets,
);
const shortcutModel = useMemo(() => {
if (groupMode === "status") {
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspacePlacements,
projectNamesByKey,
collapsedStatusGroupKeys,
});
}
return buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
});
}, [
collapsedProjectKeys,
collapsedStatusGroupKeys,
groupMode,
projectNamesByKey,
projects,
statusWorkspacePlacements,
]);
useEffect(() => {
if (!enabled) {
setSidebarShortcutWorkspaceTargets([]);

View File

@@ -1,266 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
// Keeps the overlay displayed long enough for the close animation to finish
// before React hides it.
const OVERLAY_CLOSE_GRACE_MS = 300;
interface ExplorerSidebarAnimationContextValue {
translateX: SharedValue<number>;
backdropOpacity: SharedValue<number>;
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
overlayVisible: boolean;
setOverlayPeek: (peek: boolean) => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationContextValue | null>(
null,
);
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { startMobilePanelTransition, settleMobilePanel } = useSidebarAnimation();
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const isOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
);
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// React owns whether the overlay is displayed at all; the worklet owns its
// motion. See the matching block in sidebar-animation-context.tsx for why
// (Fabric re-applies stale animated props over committed props,
// reanimated#9635).
const [overlayPeek, setOverlayPeek] = useState(false);
const overlayTarget = isOpen || overlayPeek;
const [overlayVisible, setOverlayVisible] = useState(overlayTarget);
useEffect(() => {
if (overlayTarget) {
setOverlayVisible(true);
return;
}
const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS);
return () => clearTimeout(timer);
}, [overlayTarget]);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange =
previousMobileView === "file-explorer" || mobileView === "file-explorer";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
if (!didStateChange && !didMobileViewChange) {
return;
}
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("file-explorer");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("file-explorer");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
isCompactLayout,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
startMobilePanelTransition("file-explorer");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("file-explorer");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
startMobilePanelTransition("agent");
translateX.value = withTiming(
windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
setOverlayPeek,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
isGesturing,
],
);
return (
<ExplorerSidebarAnimationContext.Provider value={value}>
{children}
</ExplorerSidebarAnimationContext.Provider>
);
}
export function useExplorerSidebarAnimation() {
const context = useContext(ExplorerSidebarAnimationContext);
if (!context) {
throw new Error(
"useExplorerSidebarAnimation must be used within ExplorerSidebarAnimationProvider",
);
}
return context;
}
export function useExplorerSidebarAnimationOptional() {
return useContext(ExplorerSidebarAnimationContext);
}

View File

@@ -1,388 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Keyboard, useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
// Keeps the overlay displayed long enough for the close animation to finish
// before React hides it.
const OVERLAY_CLOSE_GRACE_MS = 300;
export const MOBILE_VISUAL_PANEL_AGENT = 0;
export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1;
export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2;
interface SidebarAnimationContextValue {
translateX: SharedValue<number>;
backdropOpacity: SharedValue<number>;
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
overlayVisible: boolean;
setOverlayPeek: (peek: boolean) => void;
isGesturing: SharedValue<boolean>;
mobileVisualPanel: SharedValue<number>;
mobilePanelState: SharedValue<number>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_VISUAL_PANEL_AGENT_LIST;
}
if (mobileView === "file-explorer") {
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
}
return MOBILE_VISUAL_PANEL_AGENT;
}
function getSettledMobilePanelState(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
if (mobileView === "file-explorer") {
return MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
return MOBILE_PANEL_STATE_AGENT;
}
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView));
const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView));
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// React owns whether the overlay is displayed at all; the worklet owns its
// motion. On Fabric, Reanimated re-applies its own (possibly stale) animated
// props over React's committed props on every commit, so a settled-closed
// overlay can ghost back on screen after a heavy commit (reanimated#9635) —
// no committed transform/opacity value can prevent that. display lives on
// the plain wrapper View that Reanimated never touches, so React's value is
// authoritative: a hidden overlay stays hidden no matter what the animated
// props revert to. overlayPeek shows the overlay during an open gesture
// before the store flips; the close grace keeps it displayed while the
// close animation plays.
const [overlayPeek, setOverlayPeek] = useState(false);
const overlayTarget = isOpen || overlayPeek;
const [overlayVisible, setOverlayVisible] = useState(overlayTarget);
useEffect(() => {
if (overlayTarget) {
setOverlayVisible(true);
return;
}
const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS);
return () => clearTimeout(timer);
}, [overlayTarget]);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
const startMobilePanelTransition = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPENING;
return;
}
if (nextMobileView === "file-explorer") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING;
return;
}
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT;
if (mobilePanelState.value === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING;
return;
}
if (mobilePanelState.value === MOBILE_PANEL_STATE_AGENT_LIST_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_CLOSING;
return;
}
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobilePanelState, mobilePanelTarget],
);
const settleMobilePanel = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_AGENT_LIST,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
return;
}
if (nextMobileView === "file-explorer") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
return;
}
if (!shouldSettleMobilePanelTransition(mobilePanelTarget.value, MOBILE_PANEL_TARGET_AGENT)) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobileVisualPanel, mobilePanelState, mobilePanelTarget],
);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
const didOpen = !previousIsOpen && isOpen;
if (!didStateChange && !didMobileViewChange) {
return;
}
if (didOpen && isCompactLayout && isNative) {
Keyboard.dismiss();
}
// Gesture onEnd already started the animation on the UI thread — skip to avoid
// a second competing withTiming that can desync translateX and backdropOpacity
// after a provider remount (e.g. theme change).
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("agent-list");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("agent-list");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
isCompactLayout,
mobileVisualPanel,
mobilePanelState,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
startMobilePanelTransition("agent-list");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent-list");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
startMobilePanelTransition("agent");
translateX.value = withTiming(
-windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<SidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
overlayVisible,
setOverlayPeek,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
overlayVisible,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
],
);
return (
<SidebarAnimationContext.Provider value={value}>{children}</SidebarAnimationContext.Provider>
);
}
export function useSidebarAnimation() {
const context = useContext(SidebarAnimationContext);
if (!context) {
throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider");
}
return context;
}

View File

@@ -125,6 +125,11 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
onVolumeLevel: (level) => {
runtime?.handleCaptureVolume(level);
},
onInterruption: () => {
void runtime?.stopVoice().catch((error) => {
console.error("[VoiceEngine] Failed to stop after audio interruption:", error);
});
},
onError: (error) => {
console.error("[VoiceEngine] Capture error:", error);
},

View File

@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.38",
version: "3.0.39",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.38", "--acp"],
command: ["npx", "-y", "cline@3.0.39", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.117.2",
version: "2.118.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.117.2", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.118.2", "--acp"],
},
{
id: "codewhale",
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.15",
version: "0.1.17",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.15"],
command: ["npx", "-y", "deepagents-acp@0.1.17"],
},
{
id: "devin",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.22",
version: "0.2.26",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.22", "acp"],
command: ["npx", "-y", "dimcode@0.2.26", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.13",
version: "0.4.15",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.15", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.164.1",
version: "0.169.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.164.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.169.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,19 +173,19 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.9.2",
version: "0.9.5",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.9.2", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.5", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.49.0",
version: "0.50.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.49.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.50.0", "--acp"],
},
{
id: "glm-acp-agent",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.39",
version: "1.0.41",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.39", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.41", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.7",
version: "0.19.8",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.7", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.8", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "./host";
import { pickDirectory } from "./pick-directory";
describe("pickDirectory", () => {
it("opens a single-directory picker and returns the selection", async () => {
const recordedOptions: DesktopDialogOpenOptions[] = [];
const dialog: DesktopDialogBridge = {
open: async (options) => {
if (options) {
recordedOptions.push(options);
}
return "/repo/project";
},
};
await expect(pickDirectory(dialog)).resolves.toBe("/repo/project");
expect(recordedOptions).toEqual([
{
directory: true,
multiple: false,
},
]);
});
it("returns null when the picker is cancelled", async () => {
const dialog: DesktopDialogBridge = {
open: async () => null,
};
await expect(pickDirectory(dialog)).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
import { getDesktopHost, type DesktopDialogBridge } from "./host";
export async function pickDirectory(
dialog: DesktopDialogBridge | null = getDesktopHost()?.dialog ?? null,
): Promise<string | null> {
const open = dialog?.open;
if (typeof open !== "function") {
throw new Error("Desktop dialog open() is unavailable in this environment.");
}
const selection = await open({
directory: true,
multiple: false,
});
if (selection === null) {
return null;
}
if (typeof selection === "string") {
return selection;
}
throw new Error("Unexpected directory picker response.");
}

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
import type { ParsedDiffFile } from "@/git/use-diff-query";
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
}
function summarize(items: DiffFlatItem[]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
});
}
describe("buildDiffFlatItems", () => {
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
it("emits a body and a sticky index for each expanded file", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files: [createFile("a.ts"), createFile("b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["a.ts"]),
});
// Root-level files (no folder): header, body (for expanded a.ts), header
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
// sticky points at the header (index 0), not the body
expect(stickyHeaderIndices).toEqual([0]);
expect(items[0].type).toBe("header");
});
it("emits the old flat list without folder rows or indentation", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
expect(stickyHeaderIndices).toEqual([0]);
expect(items.every((item) => item.type !== "folder")).toBe(true);
});
it("groups files under compressed folder rows, all expanded by default", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// dirs sort before files within a level: [nested] precedes a.ts
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
});
it("collapsing a folder hides its descendants but keeps the row", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
});
it("collapsing an ancestor hides everything below it", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
});
it("derives sticky indices file-headers-only from the post-collapse list", () => {
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
// header at index 3, with the body right after.
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual([
"[src/app]",
" [nested]",
" b.ts",
" a.ts",
" body:a.ts",
]);
expect(stickyHeaderIndices).toEqual([3]);
for (const idx of stickyHeaderIndices) {
expect(items[idx].type).toBe("header");
}
});
it("maps tree file rows back to their original files array index", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
const headers = items.filter((i) => i.type === "header");
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
});
});
describe("sumHeightsBefore", () => {
const items = buildDiffFlatItems({
files: [createFile("src/a.ts"), createFile("src/b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
}).items; // [folder, a.ts, b.ts]
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
it("sums the heights of items before the index, counting folder rows", () => {
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
});
it("clamps an out-of-range index to the list length", () => {
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
});
});

View File

@@ -0,0 +1,121 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
// The row model for the Changes FlatList. `fileIndex` is the file's index within
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
// independent of folder rows and collapse state. `depth` drives indentation.
export type DiffFlatItem =
| {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
export interface DiffFlatItemsResult {
items: DiffFlatItem[];
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
stickyHeaderIndices: number[];
}
export interface BuildDiffFlatItemsInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
collapsedFolders: ReadonlySet<string>;
/** File paths whose diff body is expanded. */
expandedPaths: ReadonlySet<string>;
/**
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
* the tree on every collapse toggle; omitted callers (tests) build it internally.
*/
tree?: DiffTreeDirNode;
}
/**
* Build the FlatList item model plus its sticky-header indices: files grouped
* under directory rows, single-child chains compressed, descendants of collapsed
* folders omitted.
*
* Sticky indices are derived from the FINAL (post-collapse) item list and only
* ever point at expanded FILE headers, so folder rows can't stack or collide in
* the sticky header stack.
*/
export function buildDiffFlatItems({
files,
viewMode,
collapsedFolders,
expandedPaths,
tree,
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
const items: DiffFlatItem[] = [];
const stickyHeaderIndices: number[] = [];
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex, isExpanded, depth });
if (isExpanded) {
stickyHeaderIndices.push(items.length - 1);
items.push({ type: "body", file, fileIndex, depth });
}
};
if (viewMode === "flat") {
for (const [fileIndex, file] of files.entries()) {
pushFile(file, fileIndex, 0);
}
return { items, stickyHeaderIndices };
}
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
const rows = flattenDiffTree(compressedTree, collapsedFolders);
for (const row of rows) {
if (row.kind === "folder") {
items.push({
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
});
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex === undefined) {
// Should never happen: the tree is built from the same `files` array.
continue;
}
pushFile(row.file, fileIndex, row.depth);
}
return { items, stickyHeaderIndices };
}
/** Cumulative height of every item before `index`. Single source of truth for
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
export function sumHeightsBefore(
items: DiffFlatItem[],
index: number,
heightFor: (item: DiffFlatItem) => number,
): number {
let offset = 0;
const end = Math.min(index, items.length);
for (let i = 0; i < end; i++) {
offset += heightFor(items[i]);
}
return Math.max(0, offset);
}

View File

@@ -0,0 +1,124 @@
import { useCallback, useMemo } from "react";
import {
View,
Text,
Pressable,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
interface DiffFolderRowProps {
/** full uncompressed directory path — the collapse identity */
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
onToggle: (dirPath: string) => void;
onHeightChange?: (height: number) => void;
testID?: string;
}
function folderRowPressableStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
// Subtle background highlight on hover/press, matching the Files explorer rows
// (entryRowActive) — no opacity darken.
return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive];
}
export function DiffFolderRow({
dirPath,
displayName,
depth,
collapsed,
additions,
deletions,
onToggle,
onHeightChange,
testID,
}: DiffFolderRowProps) {
const handlePress = useCallback(() => {
onToggle(dirPath);
}, [dirPath, onToggle]);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
onHeightChange?.(event.nativeEvent.layout.height);
},
[onHeightChange],
);
const leftStyle = useMemo(
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
[depth],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<View style={styles.container} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Pressable
onPress={handlePress}
style={folderRowPressableStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
testID={testID ? `${testID}-toggle` : undefined}
>
<View style={leftStyle}>
<TreeChevron expanded={!collapsed} />
<Text style={styles.folderName} numberOfLines={1}>
{displayName}
</Text>
</View>
<View style={styles.right}>
<DiffStat additions={additions} deletions={deletions} />
</View>
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme: Theme) => ({
container: {
overflow: "hidden",
},
folderRow: {
flexDirection: "row",
alignItems: "center",
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
gap: theme.spacing[1],
minWidth: 0,
},
folderRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
left: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
right: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
},
folderName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
},
}));

View File

@@ -36,8 +36,10 @@ import {
ChevronDown,
Columns2,
Download,
FolderTree,
GitCommitHorizontal,
GitMerge,
List,
ListChevronsDownUp,
ListChevronsUpDown,
Pilcrow,
@@ -52,6 +54,12 @@ import {
type DiffLine,
type HighlightToken,
} from "@/git/use-diff-query";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -191,6 +199,10 @@ function HighlightedText({
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
/** Tree indentation level (0 on the flat/mobile path). */
depth?: number;
/** Show the muted directory suffix (flat list); false inside the folder tree. */
showDir?: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
@@ -889,6 +901,8 @@ function SplitDiffColumn({
const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
depth = 0,
showDir = true,
onToggle,
onHeaderHeightChange,
testID,
@@ -940,13 +954,27 @@ const DiffFileHeader = memo(function DiffFileHeader({
[isExpanded],
);
const headerPressableStyle = useCallback(
(state: PressableStateCallbackType) =>
depth > 0
? [
fileHeaderPressableStyle(state),
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
]
: fileHeaderPressableStyle(state),
[depth],
);
const fileName = file.path.split("/").pop() ?? file.path;
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={fileHeaderPressableStyle}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
@@ -954,14 +982,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
onPress={toggleExpanded}
>
<View style={styles.fileHeaderLeft}>
{showDir ? null : (
<View style={styles.fileIcon}>
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
</View>
)}
<Text style={styles.fileName} numberOfLines={1}>
{file.path.split("/").pop()}
</Text>
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
{fileName}
</Text>
{showDir ? (
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
</Text>
) : (
// Flex spacer in tree mode (no dir suffix) so the New/Deleted badge
// stays right-aligned next to the diff stats, as in the flat list.
<View style={styles.fileDirSpacer} />
)}
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
@@ -1181,7 +1220,6 @@ type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
const foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
@@ -1191,6 +1229,8 @@ const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedList = withUnistyles(List);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
@@ -1201,101 +1241,89 @@ const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const ThemedChevronDown = withUnistyles(ChevronDown);
interface DiffLayoutToggleGroupProps {
const DIFF_OPTIONS_WHITESPACE_ICON = (
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_WRAP_ICON = (
<ThemedWrapText size={14} uniProps={foregroundMutedIconColorMapping} />
);
interface DiffLayoutToggleProps {
layout: "unified" | "split";
unifiedToggleStyle: PressableStyleFn;
splitToggleStyle: PressableStyleFn;
onUnified: () => void;
onSplit: () => void;
}
function DiffLayoutToggleGroup({
layout,
unifiedToggleStyle,
splitToggleStyle,
onUnified,
onSplit,
}: DiffLayoutToggleGroupProps) {
const { t } = useTranslation();
const unifiedLabel = t("workspace.git.diff.unified");
const splitLabel = t("workspace.git.diff.split");
return (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={unifiedLabel}
testID="changes-layout-unified"
onPress={onUnified}
style={unifiedToggleStyle}
>
<ThemedAlignJustify
size={14}
uniProps={
layout === "unified" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{unifiedLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={splitLabel}
testID="changes-layout-split"
onPress={onSplit}
style={splitToggleStyle}
>
<ThemedColumns2
size={14}
uniProps={
layout === "split" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{splitLabel}</Text>
</TooltipContent>
</Tooltip>
</View>
);
}
interface DiffWhitespaceToggleProps {
hideWhitespace: boolean;
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffWhitespaceToggle({
hideWhitespace,
isMobile,
toggleStyle,
onToggle,
}: DiffWhitespaceToggleProps) {
function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) {
const { t } = useTranslation();
const label = t("workspace.git.diff.hideWhitespace");
const label =
layout === "unified"
? t("workspace.git.diff.switchToSplit")
: t("workspace.git.diff.switchToUnified");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-whitespace"
testID="changes-toggle-layout"
onPress={onToggle}
style={toggleStyle}
>
{layout === "unified" ? (
<ThemedColumns2 size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedAlignJustify
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface DiffViewModeToggleProps {
viewMode: "flat" | "tree";
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffViewModeToggle({
viewMode,
isMobile,
toggleStyle,
onToggle,
}: DiffViewModeToggleProps) {
const { t } = useTranslation();
const label =
viewMode === "flat"
? t("workspace.git.diff.showTreeView")
: t("workspace.git.diff.showFlatView");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-view-mode"
style={toggleStyle}
onPress={onToggle}
>
<ThemedPilcrow
size={isMobile ? 18 : 14}
uniProps={hideWhitespace ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
{viewMode === "flat" ? (
<ThemedFolderTree
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedList size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
@@ -1306,50 +1334,33 @@ function DiffWhitespaceToggle({
}
interface DiffFilesToolbarProps {
wrapLines: boolean;
allExpanded: boolean;
allFileDiffsExpanded: boolean;
isMobile: boolean;
wrapLinesToggleStyle: PressableStyleFn;
expandAllToggleStyle: PressableStyleFn;
onToggleWrapLines: () => void;
onToggleExpandAll: () => void;
}
function DiffFilesToolbar({
wrapLines,
allExpanded,
allFileDiffsExpanded,
isMobile,
wrapLinesToggleStyle,
expandAllToggleStyle,
onToggleWrapLines,
onToggleExpandAll,
}: DiffFilesToolbarProps) {
const { t } = useTranslation();
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const expandAllLabel = allExpanded
const expandAllLabel = allFileDiffsExpanded
? t("workspace.git.diff.collapseAll")
: t("workspace.git.diff.expandAll");
return (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
<ThemedWrapText
size={isMobile ? 18 : 14}
uniProps={wrapLines ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{wrapLinesLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
{allExpanded ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={expandAllLabel}
style={expandAllToggleStyle}
onPress={onToggleExpandAll}
>
{allFileDiffsExpanded ? (
<ThemedListChevronsDownUp
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
@@ -1370,53 +1381,105 @@ function DiffFilesToolbar({
);
}
interface DiffRefreshButtonProps {
interface DiffOptionsMenuProps {
hideWhitespace: boolean;
isMobile: boolean;
isRefreshing: boolean;
toggleStyle: PressableStyleFn;
onPress: () => void;
overflowToggleStyle: PressableStyleFn;
refreshSupported: boolean;
wrapLines: boolean;
onRefresh: () => void;
onToggleHideWhitespace: () => void;
onToggleWrapLines: () => void;
}
function DiffOptionsMenu({
hideWhitespace,
isMobile,
isRefreshing,
overflowToggleStyle,
refreshSupported,
wrapLines,
onRefresh,
onToggleHideWhitespace,
onToggleWrapLines,
}: DiffOptionsMenuProps) {
const { t } = useTranslation();
const whitespaceLabel = hideWhitespace
? t("workspace.git.diff.showWhitespace")
: t("workspace.git.diff.hideWhitespace");
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const optionsLabel = t("workspace.git.diff.options");
const refreshIcon = useMemo(
() =>
isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
),
[isRefreshing],
);
return (
<DropdownMenu>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
accessibilityRole="button"
accessibilityLabel={optionsLabel}
testID="changes-options-menu"
style={overflowToggleStyle}
>
<ThemedChevronDown
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{optionsLabel}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" width={240} testID="changes-options-menu-content">
<DropdownMenuItem
leading={DIFF_OPTIONS_WHITESPACE_ICON}
selected={hideWhitespace}
testID="changes-toggle-whitespace"
onSelect={onToggleHideWhitespace}
>
{whitespaceLabel}
</DropdownMenuItem>
<DropdownMenuItem
leading={DIFF_OPTIONS_WRAP_ICON}
selected={wrapLines}
testID="changes-toggle-wrap-lines"
onSelect={onToggleWrapLines}
>
{wrapLinesLabel}
</DropdownMenuItem>
{refreshSupported ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={refreshIcon}
disabled={isRefreshing}
testID="changes-refresh"
onSelect={onRefresh}
>
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
const { t } = useTranslation();
const refreshLabel = t("workspace.git.diff.refresh");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={
isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refreshState")
}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{refreshLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
@@ -1679,30 +1742,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const viewMode = changesPreferences.viewMode;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
const handleLayoutChange = useCallback(
(nextLayout: "unified" | "split") => {
void updateChangesPreferences({ layout: nextLayout });
},
[updateChangesPreferences],
);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const handleLayoutUnified = useCallback(() => {
handleLayoutChange("unified");
}, [handleLayoutChange]);
const handleLayoutSplit = useCallback(() => {
handleLayoutChange("split");
}, [handleLayoutChange]);
const handleToggleLayout = useCallback(() => {
void updateChangesPreferences({
layout: changesPreferences.layout === "unified" ? "split" : "unified",
});
}, [changesPreferences.layout, updateChangesPreferences]);
const codeFontSize = appSettings.codeFontSize;
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
@@ -1719,37 +1774,19 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const unifiedToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "unified", [
styles.toggleButton,
styles.toggleButtonGroupStart,
]),
[changesPreferences.layout],
const layoutToggleStyle = useMemo(
() => buildToggleButtonStyle(false, styles.expandAllButton),
[],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "split", [
styles.toggleButton,
styles.toggleButtonGroupEnd,
]),
[changesPreferences.layout],
);
const hideWhitespaceToggleStyle = useMemo(
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
[changesPreferences.hideWhitespace],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines],
const viewModeToggleStyle = useMemo(
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
[viewMode],
);
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const toast = useToast();
const refreshSupported = useSessionStore(
@@ -1892,7 +1929,40 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
(state) => state.setDiffExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
// The Changes view groups files into a directory tree on every form factor,
// consistent with the Files explorer (which is also a tree on mobile).
const collapsedFoldersArray = usePanelStore((state) =>
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
);
const setDiffCollapsedFoldersForWorkspace = usePanelStore(
(state) => state.setDiffCollapsedFoldersForWorkspace,
);
// Build the directory tree once per files-change; collapse/expand toggles only
// re-flatten it (they don't change tree shape).
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
// Every directory path currently in the tree — used by "collapse all folders" and to
// filter stale collapse state.
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
// Effective collapsed set: intersect the persisted paths with the folders actually
// present, purely at render (no store-syncing effect). A folder that left the diff and
// reappears defaults to expanded; toggles write back this pruned set, so the stored
// array stays bounded. (empty = all folders expanded, the default)
const collapsedFolders = useMemo(
() => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))),
[collapsedFoldersArray, allFolderPathSet],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const handleToggleViewMode = useCallback(() => {
const nextViewMode = viewMode === "flat" ? "tree" : "flat";
if (nextViewMode === "tree") {
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
if (workspaceStateKey) {
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []);
}
}
void updateChangesPreferences({ viewMode: nextViewMode });
}, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
@@ -1900,26 +1970,23 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
// Folder rows are a distinct kind; keep their height out of headerHeightByPathRef
// (Codex item 6) so file/folder heights can't collide by path.
const folderRowHeightRef = useRef<number>(0);
const defaultHeaderHeightRef = useRef<number>(44);
const [heightVersion, setHeightVersion] = useState(0);
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) {
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
items.push({ type: "body", file, fileIndex: i });
}
}
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
files,
viewMode,
tree: compressedTree,
collapsedFolders,
expandedPaths,
});
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [expandedPaths, files]);
}, [compressedTree, collapsedFolders, expandedPaths, files, viewMode]);
const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => {
@@ -1958,6 +2025,35 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
);
// Single height source of truth for both getItemLayout and the collapse
// scroll-anchor math. Folder rows use their own measured height (Codex item 6),
// falling back to the default header height before first measurement.
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "folder") {
return folderRowHeightRef.current || defaultHeaderHeightRef.current;
}
if (item.type === "header") {
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
}
const bodyHeightKey = getBodyHeightKey(item.file);
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
},
[estimateBodyHeight, getBodyHeightKey],
);
const handleFolderRowHeightChange = useCallback((height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
const previousHeight = folderRowHeightRef.current;
if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) {
return;
}
folderRowHeightRef.current = height;
setHeightVersion((version) => version + 1);
}, []);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
@@ -2013,23 +2109,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[scrollbar],
);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
let offset = 0;
for (const file of files) {
if (file.path === path) {
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedPaths.has(file.path)) {
const bodyHeightKey = getBodyHeightKey(file);
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
}
// Offset of the first item matching `predicate`, walking the SAME flatItems
// list getFlatItemLayout uses so folder rows are counted (single source of
// truth — Codex item 5 / finding 2).
const computeItemOffset = useCallback(
(predicate: (item: DiffFlatItem) => boolean): number | null => {
const index = flatItems.findIndex(predicate);
if (index < 0) {
return null;
}
return Math.max(0, offset);
return sumHeightsBefore(flatItems, index, getFlatItemHeight);
},
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
[flatItems, getFlatItemHeight],
);
const computeHeaderOffset = useCallback(
(path: string): number =>
computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0,
[computeItemOffset],
);
const handleToggleExpanded = useCallback(
@@ -2067,7 +2164,41 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
);
const allExpanded = useMemo(() => {
const handleToggleFolder = useCallback(
(dirPath: string) => {
if (!workspaceStateKey) {
return;
}
const isCurrentlyCollapsed = collapsedFolders.has(dirPath);
// Collapsing hides the subtree below this row; anchor to the folder row
// first so the viewport doesn't jump to a dead offset (Codex item 5).
if (!isCurrentlyCollapsed) {
const targetOffset = computeItemOffset(
(item) => item.type === "folder" && item.dirPath === dirPath,
);
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
if (
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight: folderHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
})
) {
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
}
}
const nextCollapsed = isCurrentlyCollapsed
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed);
},
[collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
);
const allFileDiffsExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
@@ -2076,7 +2207,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
if (!workspaceStateKey) {
return;
}
if (allExpanded) {
if (allFileDiffsExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
@@ -2084,15 +2215,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
files.map((file) => file.path),
);
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={item.collapsed}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onHeightChange={handleFolderRowHeightChange}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
if (item.type === "header") {
return (
<DiffFileHeader
file={item.file}
isExpanded={item.isExpanded}
depth={item.depth}
showDir={viewMode === "flat"}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
@@ -2117,40 +2265,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
diffTextMetricsStyle,
effectiveLayout,
handleBodyHeightChange,
handleFolderRowHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
handleToggleFolder,
reviewActions,
viewMode,
wrapLines,
],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
(item: DiffFlatItem) =>
item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`,
[],
);
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "header") {
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
}
const bodyHeightKey = getBodyHeightKey(item.file);
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
},
[estimateBodyHeight, getBodyHeightKey],
);
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
(_data, index) => {
let offset = 0;
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
const item = flatItems[itemIndex];
if (item) {
offset += getFlatItemHeight(item);
}
}
const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight);
const item = flatItems[index];
const length = item ? getFlatItemHeight(item) : 0;
return { length, offset, index };
@@ -2161,17 +2294,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const flatExtraData = useMemo(
() => ({
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
}),
[
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
],
@@ -2301,38 +2438,40 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
</DropdownMenu>
<View style={styles.diffStatusButtons}>
{canUseSplitLayout ? (
<DiffLayoutToggleGroup
<DiffLayoutToggle
layout={changesPreferences.layout}
unifiedToggleStyle={unifiedToggleStyle}
splitToggleStyle={splitToggleStyle}
onUnified={handleLayoutUnified}
onSplit={handleLayoutSplit}
isMobile={isMobile}
toggleStyle={layoutToggleStyle}
onToggle={handleToggleLayout}
/>
) : null}
{files.length > 0 ? (
<DiffViewModeToggle
viewMode={viewMode}
isMobile={isMobile}
toggleStyle={viewModeToggleStyle}
onToggle={handleToggleViewMode}
/>
) : null}
<DiffWhitespaceToggle
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
toggleStyle={hideWhitespaceToggleStyle}
onToggle={handleToggleHideWhitespace}
/>
{files.length > 0 ? (
<DiffFilesToolbar
wrapLines={wrapLines}
allExpanded={allExpanded}
allFileDiffsExpanded={allFileDiffsExpanded}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
/>
) : null}
{refreshSupported ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
<DiffOptionsMenu
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
isRefreshing={isRefreshing}
overflowToggleStyle={overflowToggleStyle}
refreshSupported={refreshSupported}
wrapLines={wrapLines}
onRefresh={handleRefresh}
onToggleHideWhitespace={handleToggleHideWhitespace}
onToggleWrapLines={handleToggleWrapLines}
/>
</View>
</View>
</View>
@@ -2414,46 +2553,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexWrap: "wrap",
},
toggleButtonGroup: {
flexDirection: "row",
alignItems: "center",
},
toggleButton: {
alignItems: "center",
justifyContent: "center",
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
},
toggleButtonGroupStart: {
borderTopLeftRadius: theme.borderRadius.base,
borderBottomLeftRadius: theme.borderRadius.base,
},
toggleButtonGroupEnd: {
borderTopRightRadius: theme.borderRadius.base,
borderBottomRightRadius: theme.borderRadius.base,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
@@ -2574,6 +2676,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexShrink: 0,
},
fileIcon: {
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
fileName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
@@ -2588,6 +2695,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
fileDirSpacer: {
flex: 1,
minWidth: 0,
},
newBadge: {
backgroundColor: "rgba(46, 160, 67, 0.2)",
paddingHorizontal: theme.spacing[2],

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import {
buildDiffTree,
collectDirPaths,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeRow,
} from "./diff-tree";
import type { ParsedDiffFile } from "@/git/use-diff-query";
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions,
deletions,
hunks: [],
};
}
// The Changes tree is fed the already-path-sorted diff files; build against that.
function tree(paths: Array<string | ParsedDiffFile>) {
return buildDiffTree(paths.map((p) => (typeof p === "string" ? createFile(p) : p)));
}
function compressed(paths: Array<string | ParsedDiffFile>) {
return compressSingleChildChains(tree(paths));
}
function rowLabels(rows: DiffTreeRow[]): string[] {
return rows.map((row) =>
row.kind === "folder"
? `${" ".repeat(row.depth)}[${row.displayName}]`
: `${" ".repeat(row.depth)}${row.file.path.split("/").pop()}`,
);
}
describe("buildDiffTree", () => {
it("returns an empty root for no files", () => {
const root = tree([]);
expect(root.dirPath).toBe("");
expect(root.children).toEqual([]);
});
it("places root-level files directly under the root with no folder", () => {
const root = tree(["README.md", "package.json"]);
expect(root.children.every((c) => c.kind === "file")).toBe(true);
expect(root.children.map((c) => (c.kind === "file" ? c.name : c.dirPath))).toEqual([
"README.md",
"package.json",
]);
});
it("nests files under their directory, keyed by full path", () => {
const root = tree(["src/a.ts", "src/nested/b.ts"]);
expect(root.children).toHaveLength(1);
const src = root.children[0];
expect(src.kind).toBe("dir");
if (src.kind !== "dir") return;
expect(src.dirPath).toBe("src");
const nested = src.children.find((c) => c.kind === "dir");
expect(nested?.kind === "dir" && nested.dirPath).toBe("src/nested");
});
it("orders directories before files within a level, each alphabetically", () => {
// Input alpha-by-full-path puts the file 'src/a.ts' before dir 'src/z/...',
// but rendering wants dirs first.
const root = tree(["src/a.ts", "src/z/deep.ts", "src/m/mid.ts"]);
const src = root.children[0];
if (src.kind !== "dir") throw new Error("expected dir");
expect(
src.children.map((c) => (c.kind === "dir" ? `dir:${c.name}` : `file:${c.name}`)),
).toEqual(["dir:m", "dir:z", "file:a.ts"]);
});
});
describe("compressSingleChildChains", () => {
it("collapses a single-child chain into one row keyed by the deepest dir", () => {
const rows = flattenDiffTree(compressed(["packages/app/src/git/diff-pane.tsx"]), new Set());
expect(rowLabels(rows)).toEqual(["[packages/app/src/git]", " diff-pane.tsx"]);
const folder = rows[0];
expect(folder.kind === "folder" && folder.dirPath).toBe("packages/app/src/git");
});
it("stops compressing where a directory has multiple children", () => {
const rows = flattenDiffTree(
compressed(["packages/app/a.ts", "packages/server/b.ts"]),
new Set(),
);
// "packages" has two dir children, so it stays its own row.
expect(rowLabels(rows)).toEqual([
"[packages]",
" [app]",
" a.ts",
" [server]",
" b.ts",
]);
});
it("compresses a chain that ends in a file-bearing directory", () => {
const rows = flattenDiffTree(compressed(["a/b/c/one.ts", "a/b/c/two.ts"]), new Set());
expect(rowLabels(rows)).toEqual(["[a/b/c]", " one.ts", " two.ts"]);
});
it("does not merge the virtual root with a single top-level dir's siblings", () => {
const rows = flattenDiffTree(compressed(["only/deep/file.ts"]), new Set());
expect(rows[0].kind === "folder" && rows[0].displayName).toBe("only/deep");
});
});
describe("flattenDiffTree", () => {
it("expands everything when the collapsed set is empty", () => {
const rows = flattenDiffTree(compressed(["src/a.ts", "src/b.ts"]), new Set());
expect(rowLabels(rows)).toEqual(["[src]", " a.ts", " b.ts"]);
});
it("omits descendants of a collapsed directory but keeps the folder row", () => {
const root = compressed(["src/a.ts", "src/b.ts"]);
const rows = flattenDiffTree(root, new Set(["src"]));
expect(rowLabels(rows)).toEqual(["[src]"]);
});
it("collapsing a parent hides nested folders and files", () => {
const root = compressed(["src/deep/a.ts", "src/top.ts"]);
const rows = flattenDiffTree(root, new Set(["src"]));
expect(rowLabels(rows)).toEqual(["[src]"]);
});
it("sums descendant additions/deletions onto the folder row", () => {
const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/nested/b.ts", 5, 2)]);
const rows = flattenDiffTree(root, new Set());
const srcFolder = rows.find((r) => r.kind === "folder" && r.dirPath === "src");
expect(srcFolder).toMatchObject({ additions: 8, deletions: 3 });
});
it("reports full aggregate stats even when the folder is collapsed", () => {
const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/b.ts", 4, 0)]);
const rows = flattenDiffTree(root, new Set(["src"]));
expect(rows[0]).toMatchObject({ kind: "folder", additions: 7, deletions: 1 });
});
});
describe("collectDirPaths", () => {
it("returns every logical directory path in the compressed tree", () => {
const root = compressed(["packages/app/a.ts", "packages/server/b.ts"]);
expect(collectDirPaths(root).sort()).toEqual(["packages", "packages/app", "packages/server"]);
});
});

View File

@@ -0,0 +1,207 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
// Builds a directory hierarchy from the flat, path-sorted `ParsedDiffFile[]` the
// Changes view renders. The tree renders on every form factor, consistent with
// the Files explorer.
//
// Directory nodes are keyed by their FULL uncompressed path (e.g. "packages/app/src").
// That path is the stable identity used to persist folder-collapse state, so the
// state survives path-compression changes as the diff mutates: if a compressed
// row later splits because a sibling appears, the logical directories keep the
// same keys.
export interface DiffTreeFileNode {
kind: "file";
file: ParsedDiffFile;
/** basename, e.g. "diff-pane.tsx" */
name: string;
}
export interface DiffTreeDirNode {
kind: "dir";
/** full uncompressed directory path, e.g. "packages/app/src"; "" for the virtual root */
dirPath: string;
/** display label; a compressed chain joins segments, e.g. "packages/app/src" */
name: string;
children: DiffTreeNode[];
}
export type DiffTreeNode = DiffTreeFileNode | DiffTreeDirNode;
export interface DiffTreeFolderRow {
kind: "folder";
/** full uncompressed path of the DEEPEST directory this row represents */
dirPath: string;
/** compressed display label, e.g. "packages/app/src" */
displayName: string;
depth: number;
additions: number;
deletions: number;
}
export interface DiffTreeFileRow {
kind: "file";
file: ParsedDiffFile;
depth: number;
}
export type DiffTreeRow = DiffTreeFolderRow | DiffTreeFileRow;
function sortTree(node: DiffTreeDirNode): void {
node.children.sort((a, b) => {
if (a.kind !== b.kind) {
// directories before files within a level
return a.kind === "dir" ? -1 : 1;
}
// Plain ASCII comparison, matching compareCheckoutDiffPaths (diff-order.ts)
// so the tree order is consistent with the rest of the Changes view.
if (a.name === b.name) return 0;
return a.name < b.name ? -1 : 1;
});
for (const child of node.children) {
if (child.kind === "dir") {
sortTree(child);
}
}
}
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
function ensureDir(dirPath: string): DiffTreeDirNode {
const existing = dirByPath.get(dirPath);
if (existing) {
return existing;
}
const parts = dirPath.split("/");
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join("/");
const parent = ensureDir(parentPath);
const node: DiffTreeDirNode = { kind: "dir", dirPath, name, children: [] };
parent.children.push(node);
dirByPath.set(dirPath, node);
return node;
}
for (const file of files) {
const parts = file.path.split("/");
const name = parts[parts.length - 1];
const dirPath = parts.slice(0, -1).join("/");
ensureDir(dirPath).children.push({ kind: "file", file, name });
}
sortTree(root);
return root;
}
// Collapse runs of single-child directories into one row, like VS Code / GitHub:
// a directory whose only child is another directory absorbs it. The merged row
// displays the joined segments ("packages/app/src") but keeps the DEEPEST
// directory's full path as its identity.
function compressNode(node: DiffTreeDirNode): DiffTreeDirNode {
let name = node.name;
let dirPath = node.dirPath;
let children = node.children.map((child) => (child.kind === "dir" ? compressNode(child) : child));
while (children.length === 1 && children[0].kind === "dir") {
const only = children[0];
name = name ? `${name}/${only.name}` : only.name;
dirPath = only.dirPath;
children = only.children;
}
return { kind: "dir", dirPath, name, children };
}
/**
* Compress single-child directory chains. The virtual root is never merged
* (it isn't rendered); only its subtrees are compressed.
*/
export function compressSingleChildChains(root: DiffTreeDirNode): DiffTreeDirNode {
return {
...root,
children: root.children.map((child) => (child.kind === "dir" ? compressNode(child) : child)),
};
}
interface DirStats {
additions: number;
deletions: number;
}
const EMPTY_DIR_STATS: DirStats = { additions: 0, deletions: 0 };
// Single post-order pass computing every directory node's aggregate stats from
// its already-summed children — O(n), vs. re-walking each subtree per folder row.
function computeDirStats(root: DiffTreeDirNode): Map<DiffTreeDirNode, DirStats> {
const statsByNode = new Map<DiffTreeDirNode, DirStats>();
function visit(node: DiffTreeDirNode): DirStats {
const stats: DirStats = { additions: 0, deletions: 0 };
for (const child of node.children) {
if (child.kind === "file") {
stats.additions += child.file.additions;
stats.deletions += child.file.deletions;
} else {
const childStats = visit(child);
stats.additions += childStats.additions;
stats.deletions += childStats.deletions;
}
}
statsByNode.set(node, stats);
return stats;
}
visit(root);
return statsByNode;
}
/**
* Flatten the (compressed) tree into depth-tagged rows for the list. Descendants
* of any directory whose `dirPath` is in `collapsed` are omitted; folder rows
* always carry the FULL aggregate stats of their subtree, collapsed or not.
*/
export function flattenDiffTree(
root: DiffTreeDirNode,
collapsed: ReadonlySet<string>,
): DiffTreeRow[] {
const statsByNode = computeDirStats(root);
const rows: DiffTreeRow[] = [];
function walk(node: DiffTreeDirNode, depth: number): void {
for (const child of node.children) {
if (child.kind === "file") {
rows.push({ kind: "file", file: child.file, depth });
continue;
}
const stats = statsByNode.get(child) ?? EMPTY_DIR_STATS;
rows.push({
kind: "folder",
dirPath: child.dirPath,
displayName: child.name,
depth,
additions: stats.additions,
deletions: stats.deletions,
});
if (!collapsed.has(child.dirPath)) {
walk(child, depth + 1);
}
}
}
walk(root, 0);
return rows;
}
/** Every directory path in the (compressed) tree — used for "collapse all folders". */
export function collectDirPaths(root: DiffTreeDirNode): string[] {
const paths: string[] = [];
function walk(node: DiffTreeDirNode): void {
for (const child of node.children) {
if (child.kind === "dir") {
paths.push(child.dirPath);
walk(child);
}
}
}
walk(root);
return paths;
}

View File

@@ -231,6 +231,7 @@ describe("shared sidebar workspace model", () => {
sessions: [
{
serverId: "host-a",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"main",
@@ -246,6 +247,7 @@ describe("shared sidebar workspace model", () => {
},
{
serverId: "host-b",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"feature",

View File

@@ -2,13 +2,13 @@ import type { PrHint } from "@/git/pr-hint";
import { selectPrHintFromStatus } from "@/git/pr-hint";
import { type HostProjectListItem } from "@/projects/host-project-model";
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -66,7 +66,7 @@ export interface SidebarWorkspacePlacementModel {
export interface SidebarStatusWorkspaceSession {
serverId: string;
workspaces: Map<string, WorkspaceDescriptor>;
agents?: Map<string, Agent>;
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
}
interface EffectiveWorkspaceStatus {
@@ -74,8 +74,6 @@ interface EffectiveWorkspaceStatus {
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
return (
workspace.projectCustomName ??
@@ -96,7 +94,7 @@ export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
@@ -129,7 +127,7 @@ function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
@@ -144,10 +142,7 @@ function deriveEffectiveWorkspaceStatus(input: {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id);
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
@@ -173,28 +168,6 @@ function getPendingInitialAgentCreateStartedAt(input: {
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents?: Map<string, Agent>;
}): WorkspaceAgentActivity | null {
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function buildSidebarWorkspacePlacementModel(input: {
projects: readonly HostProjectListItem[];
}): SidebarWorkspacePlacementModel {
@@ -298,7 +271,7 @@ export function buildSidebarStatusWorkspacePlacements(input: {
serverId: placement.serverId,
workspace,
pendingCreateAttempts: input.pendingCreateAttempts,
agents: session.agents,
workspaceAgentActivity: session.workspaceAgentActivity,
});
rows.push({

View File

@@ -28,6 +28,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({
layout: "unified",
viewMode: "flat",
wrapLines: true,
hideWhitespace: false,
});
@@ -37,6 +38,7 @@ describe("loadChangesPreferencesFromStorage", () => {
it("loads persisted layout and whitespace preferences without rewriting storage", async () => {
const persisted = JSON.stringify({
layout: "split",
viewMode: "tree",
hideWhitespace: true,
wrapLines: false,
});
@@ -48,6 +50,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({
layout: "split",
viewMode: "tree",
hideWhitespace: true,
wrapLines: false,
});
@@ -64,11 +67,16 @@ describe("saveChangesPreferences", () => {
await saveChangesPreferences({
queryClient,
updates: { layout: "split", hideWhitespace: true },
updates: { layout: "split", viewMode: "tree", hideWhitespace: true },
storage,
});
const expected = { ...DEFAULT_CHANGES_PREFERENCES, layout: "split", hideWhitespace: true };
const expected = {
...DEFAULT_CHANGES_PREFERENCES,
layout: "split",
viewMode: "tree",
hideWhitespace: true,
};
expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected);
expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(expected));
});

View File

@@ -7,18 +7,21 @@ export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
viewMode: z.enum(["flat", "tree"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
});
export interface ChangesPreferences {
layout: "unified" | "split";
viewMode: "flat" | "tree";
wrapLines: boolean;
hideWhitespace: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
};

View File

@@ -12,6 +12,7 @@ import type {
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
const onPcmSegmentRef = useRef(config.onPcmSegment);
const onErrorRef = useRef(config.onError);
const onInterruptionRef = useRef(config.onInterruption);
const [volume, setVolume] = useState(0);
const engineRef = useRef<ReturnType<typeof createAudioEngine> | null>(null);
@@ -30,6 +31,9 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
onError: (error) => {
onErrorRef.current?.(error);
},
onInterruption: () => {
onInterruptionRef.current?.();
},
});
return engineRef.current;
}, []);
@@ -37,7 +41,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
useEffect(() => {
onPcmSegmentRef.current = config.onPcmSegment;
onErrorRef.current = config.onError;
}, [config.onPcmSegment, config.onError]);
onInterruptionRef.current = config.onInterruption;
}, [config.onPcmSegment, config.onError, config.onInterruption]);
const start = useCallback(async () => {
const engine = getOrCreateEngine();

View File

@@ -1,6 +1,7 @@
export interface DictationAudioSourceConfig {
onPcmSegment: (pcm16Base64: string) => void;
onError?: (error: Error) => void;
onInterruption?: () => void;
}
export interface DictationAudioSource {

View File

@@ -184,19 +184,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
});
}, [client]);
const audio = useDictationAudioSource({
onPcmSegment: (audioData) => {
senderRef.current?.enqueueSegment(audioData);
},
onError: (err) => {
onErrorRef.current?.(err);
},
});
const audioStopRef = useRef(audio.stop);
useEffect(() => {
audioStopRef.current = audio.stop;
}, [audio.stop]);
const handleStreamingTranscriptionSuccess = useCallback(
(text: string, requestId: string) => {
setIsProcessing(false);
@@ -220,6 +207,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
(failure: unknown) => {
const normalized = toError(failure);
const failureId = generateMessageId();
stopDurationTracking();
setIsProcessing(false);
isProcessingRef.current = false;
isRecordingRef.current = false;
@@ -234,9 +222,30 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
reportError(normalized, "Failed to complete dictation");
},
[reportError],
[reportError, stopDurationTracking],
);
const audio = useDictationAudioSource({
onPcmSegment: (audioData) => {
senderRef.current?.enqueueSegment(audioData);
},
onError: (err) => {
onErrorRef.current?.(err);
},
onInterruption: () => {
try {
senderRef.current?.cancel();
} catch {
// no-op
}
handleDictationFailure(new Error("Dictation was interrupted by another audio source."));
},
});
const audioStopRef = useRef(audio.stop);
useEffect(() => {
audioStopRef.current = audio.stop;
}, [audio.stop]);
const startDictation = useCallback(async () => {
if (
actionGateRef.current.starting ||

View File

@@ -1,147 +0,0 @@
import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { isWeb } from "@/constants/platform";
import { canOpenRightSidebarGesture } from "@/utils/sidebar-animation-state";
interface UseExplorerOpenGestureParams {
enabled: boolean;
onOpen: () => void;
}
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
setOverlayPeek,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useExplorerSidebarAnimation();
const {
mobilePanelState,
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
openGestureRef: leftOpenGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
mobilePanelGestureAnimatingRef.current = true;
onOpen();
}, [onOpen, gestureAnimatingRef, mobilePanelGestureAnimatingRef]);
return useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.simultaneousWithExternalGesture(leftOpenGestureRef)
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canOpenRightSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
// Browser back-swipe owns most of the viewport; keep this gesture on the right edge.
if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) {
stateManager.fail();
return;
}
// Fail quickly on rightward or clearly vertical intent.
if (deltaX >= 10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional leftward movement.
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) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0.
const newTranslateX = Math.max(
0,
Math.min(windowWidth, windowWidth + event.translationX),
);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldOpenByPosition = translateX.value < (windowWidth * 2) / 3;
const shouldOpenByVelocity = event.velocityX < -500;
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
if (shouldOpen) {
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
runOnJS(setOverlayPeek)(false);
}),
[
enabled,
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
setOverlayPeek,
isGesturing,
openGestureRef,
leftOpenGestureRef,
handleGestureOpen,
touchStartX,
touchStartY,
],
);
}

View File

@@ -96,6 +96,8 @@ export interface UseSettingsReturn {
resetSettings: () => Promise<void>;
}
type SettingsSelector<TSelected> = (settings: Settings) => TSelected;
export function useAppSettings(): UseAppSettingsReturn {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
@@ -137,7 +139,11 @@ export function useAppSettings(): UseAppSettingsReturn {
};
}
export function useSettings(): UseSettingsReturn {
export function useSettings(): UseSettingsReturn;
export function useSettings<TSelected>(selector: SettingsSelector<TSelected>): TSelected;
export function useSettings<TSelected>(
selector?: SettingsSelector<TSelected>,
): UseSettingsReturn | TSelected {
const appSettings = useAppSettings();
const desktopSettings = useDesktopSettings();
@@ -177,6 +183,9 @@ export function useSettings(): UseSettingsReturn {
if (updates.workspaceTitleSource !== undefined) {
appUpdates.workspaceTitleSource = updates.workspaceTitleSource;
}
if (updates.autoExpandReasoning !== undefined) {
appUpdates.autoExpandReasoning = updates.autoExpandReasoning;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -210,13 +219,19 @@ export function useSettings(): UseSettingsReturn {
await Promise.all(resets);
}, [appSettings, desktopSettings]);
const settings = {
...DEFAULT_APP_SETTINGS,
...appSettings.settings,
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
releaseChannel: desktopSettings.settings.releaseChannel,
};
if (selector) {
return selector(settings);
}
return {
settings: {
...DEFAULT_APP_SETTINGS,
...appSettings.settings,
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
releaseChannel: desktopSettings.settings.releaseChannel,
},
settings,
isLoading: appSettings.isLoading || desktopSettings.isLoading,
error: appSettings.error ?? desktopSettings.error,
updateSettings,

View File

@@ -39,6 +39,7 @@ export interface AppSettings {
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
autoExpandReasoning: boolean;
}
export interface Settings extends AppSettings {
@@ -58,6 +59,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
autoExpandReasoning: false,
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -204,6 +206,9 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
if (typeof stored.autoExpandReasoning === "boolean") {
result.autoExpandReasoning = stored.autoExpandReasoning;
}
return result;
}

View File

@@ -61,12 +61,14 @@ export function useSidebarWorkspaceEntry(
(state) => {
const workspace = selectWorkspace(state, serverId, workspaceId);
if (!workspace) return null;
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
const workspaceAgentActivity = serverId
? state.sessions[serverId]?.workspaceAgentActivity
: undefined;
return createSidebarWorkspaceEntry({
serverId: serverId ?? "",
workspace,
pendingCreateAttempts,
agents,
workspaceAgentActivity,
});
},
equal,

View File

@@ -4,20 +4,21 @@ import {
selectStatusModeSessions,
type StatusModeSession,
} from "./use-status-mode-workspaces";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function workspaceMap(): Map<string, WorkspaceDescriptor> {
return new Map();
}
function agentMap(): Map<string, Agent> {
function activityMap(): Map<string, WorkspaceAgentActivity> {
return new Map();
}
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
return {
workspaces: input?.workspaces ?? workspaceMap(),
agents: input?.agents ?? agentMap(),
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
};
}
@@ -37,34 +38,44 @@ describe("status mode session selection", () => {
["host-b", "missing", "host-a"],
),
).toEqual([
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
{
serverId: "host-b",
workspaces: hostB.workspaces,
workspaceAgentActivity: hostB.workspaceAgentActivity,
},
{
serverId: "host-a",
workspaces: hostA.workspaces,
workspaceAgentActivity: hostA.workspaceAgentActivity,
},
]);
});
it("keeps selector output equal when only wrapper objects change", () => {
const workspaces = workspaceMap();
const agents = agentMap();
const workspaceAgentActivity = activityMap();
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
const previous = selectStatusModeSessions(
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
["host-a"],
);
const next = selectStatusModeSessions(
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
["host-a"],
);
expect(previous).not.toBe(next);
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
});
it("detects workspace or agent map changes for selected hosts", () => {
const agents = agentMap();
it("detects workspace or activity index changes for selected hosts", () => {
const workspaceAgentActivity = activityMap();
const previous = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
["host-a"],
);
const next = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
["host-a"],
);

View File

@@ -16,13 +16,13 @@ const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
interface StatusModeSessionSource {
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
}
export interface StatusModeSession {
serverId: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
}
export function selectStatusModeSessions(
@@ -38,7 +38,7 @@ export function selectStatusModeSessions(
statusSessions.push({
serverId,
workspaces: session.workspaces,
agents: session.agents,
workspaceAgentActivity: session.workspaceAgentActivity,
});
}
return statusSessions;
@@ -59,7 +59,7 @@ export function areStatusModeSessionsEqual(
!rightSession ||
leftSession.serverId !== rightSession.serverId ||
leftSession.workspaces !== rightSession.workspaces ||
leftSession.agents !== rightSession.agents
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
) {
return false;
}

View File

@@ -386,7 +386,7 @@ describe("translation resources", () => {
});
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
expect(en.projectPicker.placeholder).toBe("Type a directory path...");
expect(en.projectPicker.placeholder).toBe("Type to search...");
expect(en.projectPicker.opening).toBe("Opening project...");
expect(en.projectPicker.searching).toBe("Searching...");
expect(en.projectPicker.empty).toBe("Start typing a path");

View File

@@ -707,11 +707,19 @@ export const ar: TranslationResources = {
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
unified: "الفرق الموحدة",
split: "فرق جنبًا إلى جنب",
switchToUnified: "التبديل إلى الفرق الموحد",
switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب",
showTreeView: "إظهار شجرة المجلدات",
showFlatView: "إظهار قائمة الملفات المسطحة",
options: "خيارات الفرق",
hideWhitespace: "إخفاء المسافة البيضاء",
showWhitespace: "إظهار المسافة البيضاء",
scrollLongLines: "قم بتمرير الخطوط الطويلة",
wrapLongLines: "لف الخطوط الطويلة",
collapseAll: "طي كافة الملفات",
expandAll: "قم بتوسيع كافة الملفات",
collapseAllFolders: "طي كافة المجلدات",
expandAllFolders: "توسيع كافة المجلدات",
refreshing: "منعش",
refresh: "ينعش",
refreshState: "تحديث بوابة وحالة GitHub",
@@ -1048,6 +1056,12 @@ export const ar: TranslationResources = {
},
},
},
rootError: {
kicker: "حدث خطأ",
title: "واجه Paseo مشكلة.",
body: "جرّب مرة أخرى لإعادة تحميل التطبيق. إذا استمر حدوث ذلك، فأرفق التفاصيل أدناه عند الإبلاغ عنه.",
details: "التفاصيل",
},
startup: {
errorTitle: "حدث خطأ ما",
errorDescription:
@@ -1079,7 +1093,8 @@ export const ar: TranslationResources = {
},
},
projectPicker: {
placeholder: "اكتب مسار الدليل...",
placeholder: "اكتب للبحث...",
browse: "استعراض…",
opening: "افتتاح المشروع...",
searching: "جارٍ البحث...",
empty: "ابدأ بكتابة المسار",
@@ -1457,6 +1472,10 @@ export const ar: TranslationResources = {
description: "يتم الاحتفاظ بالخطوط في المخزن المؤقت الطرفي المدمج",
accessibilityLabel: "خطوط التمرير Terminal",
},
autoExpandReasoning: {
label: "عرض التفكير دائماً",
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
},
language: {
label: "لغة",
description: "لغة التطبيق",
@@ -1538,6 +1557,9 @@ export const ar: TranslationResources = {
auto: "نظام",
},
},
detailLevel: {
title: "مستوى التفاصيل",
},
fonts: {
title: "الخطوط",
systemDefault: "الافتراضي للنظام",

View File

@@ -714,11 +714,19 @@ export const en = {
tooLarge: "Diff too large to display",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "Switch to unified diff",
switchToSplit: "Switch to side-by-side diff",
showTreeView: "Show folder tree",
showFlatView: "Show flat file list",
options: "Diff options",
hideWhitespace: "Hide whitespace",
showWhitespace: "Show whitespace",
scrollLongLines: "Scroll long lines",
wrapLongLines: "Wrap long lines",
collapseAll: "Collapse all files",
expandAll: "Expand all files",
collapseAllFolders: "Collapse all folders",
expandAllFolders: "Expand all folders",
refreshing: "Refreshing",
refresh: "Refresh",
refreshState: "Refresh git and GitHub state",
@@ -1055,6 +1063,12 @@ export const en = {
},
},
},
rootError: {
kicker: "Something went wrong",
title: "Paseo ran into a problem.",
body: "Try again to reload the app. If this keeps happening, include the details below when you report it.",
details: "Details",
},
startup: {
errorTitle: "Something went wrong",
errorDescription:
@@ -1086,7 +1100,8 @@ export const en = {
},
},
projectPicker: {
placeholder: "Type a directory path...",
placeholder: "Type to search...",
browse: "Browse…",
opening: "Opening project...",
searching: "Searching...",
empty: "Start typing a path",
@@ -1464,6 +1479,10 @@ export const en = {
description: "Lines kept in the built-in terminal buffer",
accessibilityLabel: "Terminal scrollback lines",
},
autoExpandReasoning: {
label: "Always expand reasoning",
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
},
language: {
label: "Language",
description: "App language",
@@ -1545,6 +1564,9 @@ export const en = {
auto: "System",
},
},
detailLevel: {
title: "Detail level",
},
fonts: {
title: "Fonts",
systemDefault: "System default",

View File

@@ -734,11 +734,19 @@ export const es: TranslationResources = {
tooLarge: "La diferencia es demasiado grande para mostrarse",
unified: "Diferencia unificada",
split: "Diferencia de lado a lado",
switchToUnified: "Cambiar a diferencia unificada",
switchToSplit: "Cambiar a diferencia lado a lado",
showTreeView: "Mostrar árbol de carpetas",
showFlatView: "Mostrar lista plana de archivos",
options: "Opciones de diferencia",
hideWhitespace: "Ocultar espacios en blanco",
showWhitespace: "Mostrar espacios en blanco",
scrollLongLines: "Desplazarse por largas filas",
wrapLongLines: "Envolver largas filas",
collapseAll: "Contraer todos los archivos",
expandAll: "Expandir todos los archivos",
collapseAllFolders: "Contraer todas las carpetas",
expandAllFolders: "Expandir todas las carpetas",
refreshing: "Refrescante",
refresh: "Refrescar",
refreshState: "Actualizar el estado de git yGitHub",
@@ -1084,6 +1092,12 @@ export const es: TranslationResources = {
},
},
},
rootError: {
kicker: "Algo salió mal",
title: "Paseo tuvo un problema.",
body: "Vuelve a intentarlo para recargar la app. Si sigue ocurriendo, incluye los detalles de abajo al reportarlo.",
details: "Detalles",
},
startup: {
errorTitle: "algo salió mal",
errorDescription:
@@ -1115,7 +1129,8 @@ export const es: TranslationResources = {
},
},
projectPicker: {
placeholder: "Escriba una ruta de directorio...",
placeholder: "Escriba para buscar...",
browse: "Explorar…",
opening: "Proyecto de apertura...",
searching: "Buscando...",
empty: "Comience a escribir una ruta",
@@ -1497,6 +1512,11 @@ export const es: TranslationResources = {
description: "Líneas mantenidas en el búfer de terminal incorporado",
accessibilityLabel: "Líneas del historial de terminal",
},
autoExpandReasoning: {
label: "Siempre expandir razonamiento",
description:
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
},
language: {
label: "Idioma",
description: "Idioma de la aplicación",
@@ -1578,6 +1598,9 @@ export const es: TranslationResources = {
auto: "Sistema",
},
},
detailLevel: {
title: "Nivel de detalle",
},
fonts: {
title: "Fuentes",
systemDefault: "Valor predeterminado del sistema",

View File

@@ -733,11 +733,19 @@ export const fr: TranslationResources = {
tooLarge: "Diff trop grand pour être affiché",
unified: "Différentiel unifié",
split: "Différent côte à côte",
switchToUnified: "Passer au diff unifié",
switchToSplit: "Passer au diff côte à côte",
showTreeView: "Afficher l'arborescence des dossiers",
showFlatView: "Afficher la liste de fichiers à plat",
options: "Options du diff",
hideWhitespace: "Masquer les espaces",
showWhitespace: "Afficher les espaces",
scrollLongLines: "Faire défiler les longues lignes",
wrapLongLines: "Enroulez les longues lignes",
collapseAll: "Réduire tous les fichiers",
expandAll: "Développer tous les fichiers",
collapseAllFolders: "Réduire tous les dossiers",
expandAllFolders: "Développer tous les dossiers",
refreshing: "Rafraîchissant",
refresh: "Rafraîchir",
refreshState: "Actualiser l'état de git etGitHub",
@@ -1086,6 +1094,12 @@ export const fr: TranslationResources = {
},
},
},
rootError: {
kicker: "Une erreur s'est produite",
title: "Paseo a rencontré un problème.",
body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.",
details: "Détails",
},
startup: {
errorTitle: "Quelque chose s'est mal passé",
errorDescription:
@@ -1117,7 +1131,8 @@ export const fr: TranslationResources = {
},
},
projectPicker: {
placeholder: "Tapez un chemin de répertoire...",
placeholder: "Tapez pour rechercher...",
browse: "Parcourir…",
opening: "Projet d'ouverture...",
searching: "Recherche en cours...",
empty: "Commencez à taper un chemin",
@@ -1500,6 +1515,10 @@ export const fr: TranslationResources = {
description: "Lignes conservées dans le tampon du terminal intégré",
accessibilityLabel: "Lignes de défilementTerminal",
},
autoExpandReasoning: {
label: "Toujours afficher le raisonnement",
description: "Afficher le raisonnement de l'agent entièrement développé par défaut",
},
language: {
label: "Langue",
description: "Langue de l'application",
@@ -1582,6 +1601,9 @@ export const fr: TranslationResources = {
auto: "Système",
},
},
detailLevel: {
title: "Niveau de détail",
},
fonts: {
title: "Polices",
systemDefault: "Valeur par défaut du système",

View File

@@ -719,11 +719,19 @@ export const ja: TranslationResources = {
tooLarge: "差分が大きすぎて表示できません",
unified: "ユニファイド差分",
split: "左右比較",
switchToUnified: "ユニファイド差分に切り替え",
switchToSplit: "左右比較差分に切り替え",
showTreeView: "フォルダツリーを表示",
showFlatView: "フラットなファイル一覧を表示",
options: "差分オプション",
hideWhitespace: "空白を非表示",
showWhitespace: "空白を表示",
scrollLongLines: "長い行をスクロール",
wrapLongLines: "長い行を折り返す",
collapseAll: "すべて折りたたむ",
expandAll: "すべて展開",
collapseAllFolders: "すべてのフォルダを折りたたむ",
expandAllFolders: "すべてのフォルダを展開",
refreshing: "更新中",
refresh: "更新",
refreshState: "gitとGitHubの状態を更新",
@@ -1062,6 +1070,12 @@ export const ja: TranslationResources = {
},
},
},
rootError: {
kicker: "問題が発生しました",
title: "Paseo で問題が発生しました。",
body: "アプリを再読み込みするにはもう一度お試しください。繰り返し発生する場合は、以下の詳細を添えて報告してください。",
details: "詳細",
},
startup: {
errorTitle: "問題が発生しました",
errorDescription:
@@ -1093,7 +1107,8 @@ export const ja: TranslationResources = {
},
},
projectPicker: {
placeholder: "ディレクトリパスを入力...",
placeholder: "入力して検索...",
browse: "参照…",
opening: "プロジェクトを開いています...",
searching: "検索中...",
empty: "パスを入力してください",
@@ -1473,6 +1488,10 @@ export const ja: TranslationResources = {
description: "組み込みターミナルバッファに保持する行数",
accessibilityLabel: "ターミナルスクロールバック行数",
},
autoExpandReasoning: {
label: "常に思考プロセスを展開",
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
},
language: {
label: "言語",
description: "アプリの言語",
@@ -1554,6 +1573,9 @@ export const ja: TranslationResources = {
auto: "システム",
},
},
detailLevel: {
title: "詳細レベル",
},
fonts: {
title: "フォント",
systemDefault: "システムデフォルト",

View File

@@ -725,11 +725,19 @@ export const ptBR: TranslationResources = {
tooLarge: "Diff grande demais para exibir",
unified: "Diff unificado",
split: "Diff lado a lado",
switchToUnified: "Alternar para diff unificado",
switchToSplit: "Alternar para diff lado a lado",
showTreeView: "Mostrar árvore de pastas",
showFlatView: "Mostrar lista plana de arquivos",
options: "Opções do diff",
hideWhitespace: "Ocultar espaços em branco",
showWhitespace: "Mostrar espaços em branco",
scrollLongLines: "Rolar linhas longas",
wrapLongLines: "Quebrar linhas longas",
collapseAll: "Recolher todos os arquivos",
expandAll: "Expandir todos os arquivos",
collapseAllFolders: "Recolher todas as pastas",
expandAllFolders: "Expandir todas as pastas",
refreshing: "Atualizando",
refresh: "Atualizar",
refreshState: "Atualizar estado do git e do GitHub",
@@ -1070,6 +1078,12 @@ export const ptBR: TranslationResources = {
},
},
},
rootError: {
kicker: "Algo deu errado",
title: "O Paseo encontrou um problema.",
body: "Tente novamente para recarregar o app. Se isso continuar acontecendo, inclua os detalhes abaixo ao relatar o problema.",
details: "Detalhes",
},
startup: {
errorTitle: "Algo deu errado",
errorDescription:
@@ -1101,7 +1115,8 @@ export const ptBR: TranslationResources = {
},
},
projectPicker: {
placeholder: "Digite um caminho de diretório...",
placeholder: "Digite para pesquisar...",
browse: "Procurar…",
opening: "Abrindo projeto...",
searching: "Buscando...",
empty: "Comece digitando um caminho",
@@ -1482,6 +1497,11 @@ export const ptBR: TranslationResources = {
description: "Linhas mantidas no buffer do terminal integrado",
accessibilityLabel: "Linhas do scrollback do terminal",
},
autoExpandReasoning: {
label: "Sempre expandir raciocínio",
description:
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
},
language: {
label: "Idioma",
description: "Idioma do app",
@@ -1563,6 +1583,9 @@ export const ptBR: TranslationResources = {
auto: "Sistema",
},
},
detailLevel: {
title: "Nível de detalhe",
},
fonts: {
title: "Fontes",
systemDefault: "Sistema padrão",

View File

@@ -726,11 +726,19 @@ export const ru: TranslationResources = {
tooLarge: "Разница слишком велика для отображения",
unified: "Единый дифференциал",
split: "Параллельная разница",
switchToUnified: "Переключить на объединенный diff",
switchToSplit: "Переключить на diff рядом",
showTreeView: "Показать дерево папок",
showFlatView: "Показать плоский список файлов",
options: "Параметры diff",
hideWhitespace: "Скрыть пробелы",
showWhitespace: "Показать пробелы",
scrollLongLines: "Прокручивать длинные строки",
wrapLongLines: "Перенос длинных строк",
collapseAll: "Свернуть все файлы",
expandAll: "Развернуть все файлы",
collapseAllFolders: "Свернуть все папки",
expandAllFolders: "Развернуть все папки",
refreshing: "Освежающий",
refresh: "Обновить",
refreshState: "Обновить состояние git и GitHub.",
@@ -1074,6 +1082,12 @@ export const ru: TranslationResources = {
},
},
},
rootError: {
kicker: "Что-то пошло не так",
title: "В Paseo возникла проблема.",
body: "Попробуйте снова перезагрузить приложение. Если это повторяется, приложите приведенные ниже подробности к отчету.",
details: "Подробности",
},
startup: {
errorTitle: "Что- то пошло не так",
errorDescription:
@@ -1105,7 +1119,8 @@ export const ru: TranslationResources = {
},
},
projectPicker: {
placeholder: "Введите путь к каталогу...",
placeholder: "Введите текст для поиска...",
browse: "Обзор…",
opening: "Открытие проекта...",
searching: "Идет поиск...",
empty: "Начните вводить путь",
@@ -1487,6 +1502,11 @@ export const ru: TranslationResources = {
description: "Строки, хранящиеся во встроенном буфере терминала.",
accessibilityLabel: "Линии прокрутки Terminal",
},
autoExpandReasoning: {
label: "Всегда разворачивать размышления",
description:
"По умолчанию показывать блоки размышлений и логики агента полностью развернутыми",
},
language: {
label: "Язык",
description: "Язык приложения",
@@ -1569,6 +1589,9 @@ export const ru: TranslationResources = {
auto: "Система",
},
},
detailLevel: {
title: "Уровень детализации",
},
fonts: {
title: "Шрифты",
systemDefault: "Система по умолчанию",

View File

@@ -701,11 +701,19 @@ export const zhCN: TranslationResources = {
tooLarge: "Diff 过大,无法显示",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "切换到统一差异视图",
switchToSplit: "切换到并排差异视图",
showTreeView: "显示文件夹树",
showFlatView: "显示平铺文件列表",
options: "Diff 选项",
hideWhitespace: "隐藏空白差异",
showWhitespace: "显示空白差异",
scrollLongLines: "滚动长行",
wrapLongLines: "自动换行长行",
collapseAll: "折叠所有文件",
expandAll: "展开所有文件",
collapseAllFolders: "折叠所有文件夹",
expandAllFolders: "展开所有文件夹",
refreshing: "正在刷新",
refresh: "刷新",
refreshState: "刷新 git 和 GitHub 状态",
@@ -1034,6 +1042,12 @@ export const zhCN: TranslationResources = {
},
},
},
rootError: {
kicker: "出现问题",
title: "Paseo 遇到了问题。",
body: "请重试以重新加载应用。如果问题持续发生,请在报告时附上下面的详细信息。",
details: "详情",
},
startup: {
errorTitle: "出现问题",
errorDescription: "本地服务器启动失败。如果持续发生,请在 GitHub 报告问题并附上下方日志。",
@@ -1064,7 +1078,8 @@ export const zhCN: TranslationResources = {
},
},
projectPicker: {
placeholder: "输入目录路径...",
placeholder: "输入以搜索...",
browse: "浏览…",
opening: "正在打开 project...",
searching: "正在搜索...",
empty: "开始输入路径",
@@ -1439,6 +1454,10 @@ export const zhCN: TranslationResources = {
description: "内置终端缓冲区保留的行数",
accessibilityLabel: "终端回滚行数",
},
autoExpandReasoning: {
label: "始终展开推理过程",
description: "默认情况下完全展开 AI 的思考和推理过程",
},
language: {
label: "语言",
description: "应用语言",
@@ -1520,6 +1539,9 @@ export const zhCN: TranslationResources = {
auto: "系统",
},
},
detailLevel: {
title: "详细程度",
},
fonts: {
title: "字体",
systemDefault: "系统默认",

View File

@@ -0,0 +1,422 @@
import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { isWeb } from "@/constants/platform";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { usePanelStore } from "@/stores/panel-store";
import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model";
import { useMobilePanelsRuntime } from "./provider";
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
const PAN_INTENT_FAIL = -1;
const PAN_INTENT_WAIT = 0;
const PAN_INTENT_ACTIVATE = 1;
type PanDirection = -1 | 1;
type PanIntent = typeof PAN_INTENT_ACTIVATE | typeof PAN_INTENT_FAIL | typeof PAN_INTENT_WAIT;
function isCurrentSelection(startedRevision: number): boolean {
return usePanelStore.getState().mobilePanel.revision === startedRevision;
}
function getHorizontalPanIntent(
deltaX: number,
deltaY: number,
direction: PanDirection,
): PanIntent {
"worklet";
const directedDelta = deltaX * direction;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (directedDelta <= -10 || (absDeltaY > 10 && absDeltaY > absDeltaX)) {
return PAN_INTENT_FAIL;
}
if (directedDelta >= 15 && absDeltaX > absDeltaY) {
return PAN_INTENT_ACTIVATE;
}
return PAN_INTENT_WAIT;
}
function useGestureState() {
return {
startedRevision: useSharedValue(-1),
touchStartX: useSharedValue(0),
touchStartY: useSharedValue(0),
};
}
function useRevisionCommit(action: () => void) {
return useCallback(
(revision: number) => {
if (isCurrentSelection(revision)) {
action();
}
},
[action],
);
}
export function useOpenAgentListGesture(enabled: boolean) {
const {
beginGesture,
finishGesture,
leftOpenGestureRef,
motionState,
position,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const horizontalScroll = useHorizontalScrollOptional();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const commit = useRevisionCommit(showMobileAgentList);
return useMemo(
() =>
Gesture.Pan()
.withRef(leftOpenGestureRef)
.enabled(enabled)
.manualActivation(true)
.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) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1);
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
horizontalScroll?.isAnyScrolledRight.value ||
(isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldOpen ? "agent-list" : "agent",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
commit,
enabled,
beginGesture,
finishGesture,
horizontalScroll?.isAnyScrolledRight,
leftOpenGestureRef,
motionState,
position,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
}
export function useCloseAgentListGesture() {
const {
beginGesture,
finishGesture,
leftCloseGestureRef,
motionState,
position,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const commit = useRevisionCommit(showMobileAgent);
const gesture = useMemo(
() =>
Gesture.Pan()
.withRef(leftCloseGestureRef)
.manualActivation(true)
.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) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1);
if (
!canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({
origin: "agent-list",
preview: "agent-list",
});
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -1 - event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldClose ? "agent" : "agent-list",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
beginGesture,
commit,
finishGesture,
leftCloseGestureRef,
motionState,
position,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
return { gesture, gestureRef: leftCloseGestureRef };
}
interface OpenFileExplorerGestureOptions {
enabled: boolean;
onOpen: () => void;
}
export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorerGestureOptions) {
const {
beginGesture,
finishGesture,
leftOpenGestureRef,
motionState,
position,
rightOpenGestureRef,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const commit = useRevisionCommit(onOpen);
return useMemo(
() =>
Gesture.Pan()
.withRef(rightOpenGestureRef)
.simultaneousWithExternalGesture(leftOpenGestureRef)
.enabled(enabled)
.manualActivation(true)
.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) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1);
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
(isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({
origin: "agent",
preview: "file-explorer",
});
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldOpen = event.translationX < -windowWidth / 3 || event.velocityX < -500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldOpen ? "file-explorer" : "agent",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
beginGesture,
commit,
enabled,
finishGesture,
leftOpenGestureRef,
motionState,
position,
rightOpenGestureRef,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
}
export function useCloseFileExplorerGesture() {
const {
beginGesture,
finishGesture,
motionState,
position,
rightCloseGestureRef,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const commit = useRevisionCommit(showMobileAgent);
const gesture = useMemo(
() =>
Gesture.Pan()
.withRef(rightCloseGestureRef)
.manualActivation(true)
.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) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1);
if (
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({
origin: "file-explorer",
preview: "file-explorer",
});
})
.onUpdate((event) => {
updateGesture(startedRevision.value, 1 - event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldClose ? "agent" : "file-explorer",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
beginGesture,
commit,
finishGesture,
motionState,
position,
rightCloseGestureRef,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
return { gesture, gestureRef: rightCloseGestureRef };
}
export function useFileExplorerCloseGestureRef() {
return useMobilePanelsRuntime().rightCloseGestureRef;
}

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import type { MobilePanelView } from "@/stores/panel-store";
import {
canBeginMobilePanelGesture,
createMobilePanelMotionState,
getMobilePanelFrame,
isMobilePanelGestureCurrent,
transitionMobilePanel,
type MobilePanelCommit,
type MobilePanelEvent,
type MobilePanelMotionState,
} from "./model";
class MobilePanelsScenario {
private nextRevision = 0;
private startedRevision = -1;
private state: MobilePanelMotionState;
readonly commits: MobilePanelCommit[] = [];
constructor(target: MobilePanelView = "agent") {
this.state = createMobilePanelMotionState({ target, revision: this.nextRevision });
}
command(target: MobilePanelView) {
this.nextRevision += 1;
this.dispatch({ type: "command", selection: { target, revision: this.nextRevision } });
return this;
}
beginGesture(origin: MobilePanelView) {
this.dispatch({ type: "gesture.begin", origin });
this.startedRevision = this.state.gesture?.startedRevision ?? -1;
return this;
}
finishGesture(target: MobilePanelView) {
this.dispatch({
type: "gesture.finish",
startedRevision: this.startedRevision,
success: true,
target,
});
return this;
}
cancelGesture() {
this.dispatch({
type: "gesture.finish",
startedRevision: this.startedRevision,
success: false,
target: this.state.target,
});
return this;
}
finishAnimation(target: MobilePanelView, revision = this.state.revision) {
this.dispatch({ type: "animation.finished", revision, target });
return this;
}
snapshot() {
return {
motionTarget: this.state.motionTarget,
revision: this.state.revision,
settledTarget: this.state.settledTarget,
target: this.state.target,
};
}
private dispatch(event: MobilePanelEvent) {
const transition = transitionMobilePanel(this.state, event);
this.state = transition.state;
if (transition.commit) {
this.commits.push(transition.commit);
}
}
}
describe("mobile panel ownership", () => {
it("follows programmatic commands through left, center, and right", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list").finishAnimation("agent-list");
expect(panels.snapshot()).toEqual({
target: "agent-list",
motionTarget: "agent-list",
settledTarget: "agent-list",
revision: 1,
});
panels.command("agent").command("file-explorer").finishAnimation("file-explorer");
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "file-explorer",
revision: 3,
});
});
it("turns a completed drag into one semantic commit", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").finishGesture("agent-list");
expect(panels.snapshot()).toEqual({
target: "agent",
motionTarget: "agent-list",
settledTarget: "agent",
revision: 0,
});
expect(panels.commits).toEqual([{ target: "agent-list", startedRevision: 0 }]);
});
it("returns a canceled drag to the latest canonical target", () => {
const panels = new MobilePanelsScenario("agent-list");
panels.beginGesture("agent-list").cancelGesture();
expect(panels.snapshot()).toEqual({
target: "agent-list",
motionTarget: "agent-list",
settledTarget: "agent-list",
revision: 0,
});
expect(panels.commits).toEqual([]);
});
it("makes a command during a drag invalidate the stale gesture finish", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").command("file-explorer").finishGesture("agent-list");
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "agent",
revision: 1,
});
expect(panels.commits).toEqual([]);
});
it("keeps the latest rapid command and rejects stale animation completion", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list");
const staleRevision = panels.snapshot().revision;
panels.command("agent").command("file-explorer");
panels.finishAnimation("agent-list", staleRevision);
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "agent",
revision: 3,
});
});
it("keeps an activated drag current while blocking a second drag", () => {
const initial = createMobilePanelMotionState({ target: "agent", revision: 7 });
const active = transitionMobilePanel(initial, {
type: "gesture.begin",
origin: "agent",
}).state;
expect(isMobilePanelGestureCurrent(active, 7)).toBe(true);
expect(canBeginMobilePanelGesture(active, "agent", 0)).toBe(false);
});
it("derives both transforms and both backdrops from one normalized position", () => {
expect(getMobilePanelFrame(0.25, 400)).toEqual({
leftBackdropOpacity: 0,
leftTranslateX: -400,
rightBackdropOpacity: 0.25,
rightTranslateX: 300,
});
expect(getMobilePanelFrame(0.25, 800)).toEqual({
leftBackdropOpacity: 0,
leftTranslateX: -800,
rightBackdropOpacity: 0.25,
rightTranslateX: 600,
});
});
});

View File

@@ -0,0 +1,155 @@
import type { MobilePanelSelection, MobilePanelView } from "@/stores/panel-store";
interface MobilePanelGesture {
startedRevision: number;
}
export interface MobilePanelMotionState extends MobilePanelSelection {
gesture: MobilePanelGesture | null;
motionTarget: MobilePanelView;
settledTarget: MobilePanelView;
}
export interface MobilePanelCommit {
startedRevision: number;
target: MobilePanelView;
}
export interface MobilePanelTransition {
animationTarget?: MobilePanelView;
commit?: MobilePanelCommit;
state: MobilePanelMotionState;
}
export type MobilePanelEvent =
| { type: "command"; selection: MobilePanelSelection }
| { type: "gesture.begin"; origin: MobilePanelView }
| {
type: "gesture.finish";
startedRevision: number;
success: boolean;
target: MobilePanelView;
}
| { type: "animation.finished"; revision: number; target: MobilePanelView };
export function createMobilePanelMotionState(
selection: MobilePanelSelection,
): MobilePanelMotionState {
return {
...selection,
gesture: null,
motionTarget: selection.target,
settledTarget: selection.target,
};
}
export function transitionMobilePanel(
state: MobilePanelMotionState,
event: MobilePanelEvent,
): MobilePanelTransition {
"worklet";
if (event.type === "command") {
if (event.selection.revision <= state.revision) {
return { state };
}
return {
animationTarget: event.selection.target,
state: {
...state,
...event.selection,
gesture: null,
motionTarget: event.selection.target,
},
};
}
if (event.type === "gesture.begin") {
if (
state.gesture ||
state.target !== event.origin ||
state.motionTarget !== event.origin ||
state.settledTarget !== event.origin
) {
return { state };
}
return {
state: {
...state,
gesture: {
startedRevision: state.revision,
},
},
};
}
if (event.type === "gesture.finish") {
const ownsCurrentRevision = state.gesture?.startedRevision === state.revision;
const ownsFinish = state.gesture?.startedRevision === event.startedRevision;
if (!ownsCurrentRevision || !ownsFinish || !state.gesture) {
return { state };
}
const startedRevision = state.gesture.startedRevision;
const target = event.success ? event.target : state.target;
return {
animationTarget: target,
commit: event.success && target !== state.target ? { startedRevision, target } : undefined,
state: {
...state,
gesture: null,
motionTarget: target,
},
};
}
const ownsCurrentRevision = event.revision === state.revision;
const isCanonicalTarget = event.target === state.target;
const isCurrentMotionTarget = event.target === state.motionTarget;
if (!ownsCurrentRevision || !isCanonicalTarget || !isCurrentMotionTarget) {
return { state };
}
return {
state: { ...state, settledTarget: event.target },
};
}
export function getMobilePanelAnchor(panel: MobilePanelView): number {
"worklet";
if (panel === "agent-list") {
return -1;
}
if (panel === "file-explorer") {
return 1;
}
return 0;
}
export function canBeginMobilePanelGesture(
state: MobilePanelMotionState,
origin: MobilePanelView,
position: number,
): boolean {
"worklet";
const isCanonical = state.target === origin;
const isMotionSettled = state.motionTarget === origin && state.settledTarget === origin;
const isAtOrigin = Math.abs(position - getMobilePanelAnchor(origin)) <= 0.002;
return !state.gesture && isCanonical && isMotionSettled && isAtOrigin;
}
export function isMobilePanelGestureCurrent(
state: MobilePanelMotionState,
startedRevision: number,
): boolean {
"worklet";
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
}
export function getMobilePanelFrame(position: number, width: number) {
"worklet";
const clampedPosition = Math.max(-1, Math.min(1, position));
return {
leftBackdropOpacity: Math.max(0, -clampedPosition),
leftTranslateX: -Math.min(1, clampedPosition + 1) * width,
rightBackdropOpacity: Math.max(0, clampedPosition),
rightTranslateX: Math.min(1, 1 - clampedPosition) * width,
};
}

View File

@@ -0,0 +1,115 @@
import { useMemo, type ComponentProps, type ReactNode } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { isWeb } from "@/constants/platform";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { getMobilePanelFrame } from "./model";
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
type OverlayPanel = Exclude<MobilePanelView, "agent">;
interface MobilePanelOverlayProps {
children: ReactNode;
closeGesture: GestureType;
panel: OverlayPanel;
panelStyle?: ComponentProps<typeof Animated.View>["style"];
}
export function MobilePanelOverlay({
children,
closeGesture,
panel,
panelStyle,
}: MobilePanelOverlayProps) {
const { position, windowWidth } = useMobilePanelsRuntime();
const target = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const isOpen = target === panel;
const isPresented = useIsMobilePanelPresented(panel);
const isLeft = panel === "agent-list";
const sidebarAnimatedStyle = useAnimatedStyle(() => {
const frame = getMobilePanelFrame(position.value, windowWidth);
return {
transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }],
};
}, [isLeft, windowWidth]);
const backdropAnimatedStyle = useAnimatedStyle(() => {
const frame = getMobilePanelFrame(position.value, windowWidth);
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
}, [isLeft, windowWidth]);
const overlayStyle = useMemo(
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
[isPresented],
);
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
const backdropStyle = useMemo(
() => [styles.backdrop, backdropAnimatedStyle],
[backdropAnimatedStyle],
);
const combinedPanelStyle = useMemo(
() => [
styles.panel,
positionedPanelStyle,
{ width: windowWidth },
panelStyle,
sidebarAnimatedStyle,
],
[panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth],
);
let overlayPointerEvents: "auto" | "box-none" | "none";
if (!isWeb) {
overlayPointerEvents = "box-none";
} else {
overlayPointerEvents = isOpen ? "auto" : "none";
}
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Pressable
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onPress={showMobileAgent}
pointerEvents={isOpen ? "auto" : "none"}
style={StyleSheet.absoluteFillObject}
testID={`${panel}-backdrop`}
>
<Animated.View pointerEvents="none" style={backdropStyle} />
</Pressable>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
{children}
</Animated.View>
</GestureDetector>
</View>
);
}
// Reanimated owns only these static native styles and the derived transform.
// Theme values stay inline at call sites, avoiding Unistyles patching the same
// native node after Fabric commits.
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
position: "absolute",
top: 0,
bottom: 0,
overflow: "hidden",
},
leftPanel: {
left: 0,
},
rightPanel: {
right: 0,
},
});

View File

@@ -0,0 +1,260 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { Keyboard, useWindowDimensions } from "react-native";
import type { GestureType } from "react-native-gesture-handler";
import {
cancelAnimation,
Easing,
useSharedValue,
withTiming,
type SharedValue,
} from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
import { isNative } from "@/constants/platform";
import {
usePanelStore,
type MobilePanelSelection,
type MobilePanelView,
} from "@/stores/panel-store";
import {
canBeginMobilePanelGesture,
createMobilePanelMotionState,
getMobilePanelAnchor,
isMobilePanelGestureCurrent,
transitionMobilePanel,
type MobilePanelCommit,
type MobilePanelMotionState,
type MobilePanelTransition,
} from "./model";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
const LEFT_PANEL_MASK = 1;
const RIGHT_PANEL_MASK = 2;
function getPanelMask(panel: MobilePanelView): number {
if (panel === "agent-list") {
return LEFT_PANEL_MASK;
}
if (panel === "file-explorer") {
return RIGHT_PANEL_MASK;
}
return 0;
}
interface MobilePanelsRuntime {
beginGesture: (input: BeginGestureInput) => number;
finishGesture: (input: FinishGestureInput) => MobilePanelCommit | null;
leftCloseGestureRef: RefObject<GestureType | undefined>;
leftOpenGestureRef: RefObject<GestureType | undefined>;
motionState: SharedValue<MobilePanelMotionState>;
position: SharedValue<number>;
rightCloseGestureRef: RefObject<GestureType | undefined>;
rightOpenGestureRef: RefObject<GestureType | undefined>;
updateGesture: (startedRevision: number, nextPosition: number) => boolean;
windowWidth: number;
}
interface BeginGestureInput {
origin: MobilePanelView;
preview: MobilePanelView;
}
interface FinishGestureInput {
startedRevision: number;
success: boolean;
target: MobilePanelView;
}
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
const MobilePanelPresentationContext = createContext(0);
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const initialSelection = useRef(usePanelStore.getState().mobilePanel).current;
const position = useSharedValue(getMobilePanelAnchor(initialSelection.target));
const motionState = useSharedValue(createMobilePanelMotionState(initialSelection));
const leftOpenGestureRef = useRef<GestureType | undefined>(undefined);
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
const presentPanel = useCallback((panel: MobilePanelView) => {
const mask = getPanelMask(panel);
if (mask) {
setPresentedPanels((current) => current | mask);
}
}, []);
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
const selection = usePanelStore.getState().mobilePanel;
if (selection.revision !== revision || selection.target !== panel) {
return;
}
setPresentedPanels(getPanelMask(panel));
}, []);
const animateTransition = useCallback(
(transition: MobilePanelTransition) => {
"worklet";
if (!transition.animationTarget) {
return;
}
const target = transition.animationTarget;
const revision = transition.state.revision;
position.value = withTiming(
getMobilePanelAnchor(target),
{ duration: ANIMATION_DURATION, easing: ANIMATION_EASING },
(finished) => {
if (!finished) {
return;
}
const currentState = motionState.value;
const settled = transitionMobilePanel(currentState, {
type: "animation.finished",
revision,
target,
});
if (settled.state === currentState) {
return;
}
motionState.value = settled.state;
scheduleOnRN(settlePresentation, target, revision);
},
);
},
[motionState, position, settlePresentation],
);
const applySelection = useCallback(
(selection: MobilePanelSelection) => {
"worklet";
const currentState = motionState.value;
const transition = transitionMobilePanel(currentState, {
type: "command",
selection,
});
if (transition.state === currentState) {
return;
}
motionState.value = transition.state;
animateTransition(transition);
},
[animateTransition, motionState],
);
useEffect(() => {
return usePanelStore.subscribe((state, previousState) => {
const selection = state.mobilePanel;
if (selection === previousState.mobilePanel) {
return;
}
if (selection.target !== "agent") {
presentPanel(selection.target);
if (isNative) {
Keyboard.dismiss();
}
}
scheduleOnUI(applySelection, selection);
});
}, [applySelection, presentPanel]);
const beginGesture = useCallback(
({ origin, preview }: BeginGestureInput): number => {
"worklet";
const currentState = motionState.value;
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
return -1;
}
const transition = transitionMobilePanel(currentState, {
type: "gesture.begin",
origin,
});
motionState.value = transition.state;
cancelAnimation(position);
scheduleOnRN(presentPanel, preview);
return transition.state.gesture?.startedRevision ?? -1;
},
[motionState, position, presentPanel],
);
const updateGesture = useCallback(
(startedRevision: number, nextPosition: number): boolean => {
"worklet";
if (!isMobilePanelGestureCurrent(motionState.value, startedRevision)) {
return false;
}
position.value = Math.max(-1, Math.min(1, nextPosition));
return true;
},
[motionState, position],
);
const finishGesture = useCallback(
({ startedRevision, target, success }: FinishGestureInput): MobilePanelCommit | null => {
"worklet";
const currentState = motionState.value;
const transition = transitionMobilePanel(currentState, {
type: "gesture.finish",
startedRevision,
success,
target,
});
if (transition.state === currentState) {
return null;
}
motionState.value = transition.state;
animateTransition(transition);
return transition.commit ?? null;
},
[animateTransition, motionState],
);
const value = useMemo<MobilePanelsRuntime>(
() => ({
beginGesture,
finishGesture,
leftCloseGestureRef,
leftOpenGestureRef,
motionState,
position,
rightCloseGestureRef,
rightOpenGestureRef,
updateGesture,
windowWidth,
}),
[beginGesture, finishGesture, motionState, position, updateGesture, windowWidth],
);
return (
<MobilePanelsContext.Provider value={value}>
<MobilePanelPresentationContext.Provider value={presentedPanels}>
{children}
</MobilePanelPresentationContext.Provider>
</MobilePanelsContext.Provider>
);
}
/** Internal to the mobile-panels module. Callers use gesture and presentation adapters. */
export function useMobilePanelsRuntime(): MobilePanelsRuntime {
const context = useContext(MobilePanelsContext);
if (!context) {
throw new Error("useMobilePanelsRuntime must be used within MobilePanelsProvider");
}
return context;
}
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
const presentedPanels = useContext(MobilePanelPresentationContext);
return (presentedPanels & getPanelMask(panel)) !== 0;
}

View File

@@ -58,10 +58,8 @@ export function useProviderUsage(
});
const refresh = useCallback(async () => {
if (!canFetch) return;
await queryClient.invalidateQueries({ queryKey });
if (!canFetch) {
return;
}
await queryClient.fetchQuery({
queryKey,
queryFn,

View File

@@ -16,6 +16,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import {
MAX_CODE_FONT_SIZE,
@@ -189,6 +190,28 @@ function ThemeRow({ value, onChange }: ThemeRowProps) {
);
}
interface AutoExpandReasoningRowProps {
value: boolean;
onChange: (value: boolean) => void;
}
function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) {
const { t } = useTranslation();
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.autoExpandReasoning.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.autoExpandReasoning.description")}
</Text>
</View>
<Switch value={value} onValueChange={onChange} />
</View>
);
}
// ---------------------------------------------------------------------------
// Fonts: family text fields + numeric size fields (commit on blur/submit)
// ---------------------------------------------------------------------------
@@ -397,6 +420,13 @@ export function AppearanceSection() {
[updateSettings],
);
const handleAutoExpandReasoningChange = useCallback(
(autoExpandReasoning: boolean) => {
void updateSettings({ autoExpandReasoning });
},
[updateSettings],
);
const commitUiFontFamily = useCallback(
(value: string) => {
const sanitized = sanitizeFontFamily(value);
@@ -477,6 +507,14 @@ export function AppearanceSection() {
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
<View style={settingsStyles.card}>
<AutoExpandReasoningRow
value={settings.autoExpandReasoning}
onChange={handleAutoExpandReasoningChange}
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.fonts.title")}>
<View style={settingsStyles.card}>
{showFontFamilyRows ? (

View File

@@ -26,10 +26,12 @@ import {
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility,
type DesktopSidebarState,
type ExplorerPanelIntent,
type MobilePanelView,
type MobilePanelSelection,
type PanelLayoutInput,
type PanelVisibilityState,
type SortOption,
@@ -41,6 +43,7 @@ export type {
DesktopSidebarState,
ExplorerPanelIntent,
MobilePanelView,
MobilePanelSelection,
PanelLayoutInput,
PanelVisibilityState,
SortOption,
@@ -61,8 +64,8 @@ export {
};
export interface PanelState {
// Mobile: which panel is currently shown
mobileView: MobilePanelView;
// Mobile: React's durable target plus the generation that owns it.
mobilePanel: MobilePanelSelection;
// Desktop: independent sidebar toggles
desktop: DesktopSidebarState;
@@ -72,6 +75,11 @@ export interface PanelState {
explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
// Changes-view folder tree. Inverted semantics vs the fields above:
// this stores COLLAPSED directory paths (empty = all folders expanded), keyed
// by full uncompressed dir path, so folders default to expanded and new
// folders stay expanded as the diff changes.
diffCollapsedFoldersByWorkspace: Record<string, string[]>;
sidebarWidth: number;
explorerWidth: number;
explorerSortOption: SortOption;
@@ -98,6 +106,7 @@ export interface PanelState {
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setSidebarWidth: (width: number) => void;
setExplorerWidth: (width: number) => void;
@@ -108,11 +117,19 @@ export interface PanelState {
const DEFAULT_DESKTOP_OPEN = isWeb;
function setMobilePanelTargetPatch(
state: PanelState,
target: MobilePanelView,
): PanelState | Pick<PanelState, "mobilePanel"> {
const mobilePanel = setMobilePanelTarget(state.mobilePanel, target);
return mobilePanel === state.mobilePanel ? state : { mobilePanel };
}
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Mobile always starts at agent view
mobileView: "agent",
mobilePanel: { target: "agent", revision: 0 },
// Desktop defaults based on platform
desktop: {
@@ -126,6 +143,7 @@ export const usePanelStore = create<PanelState>()(
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {},
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
@@ -137,26 +155,17 @@ export const usePanelStore = create<PanelState>()(
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
})),
showMobileAgent: () =>
set((state) => {
if (state.mobileView === "agent") {
return state;
}
return { mobileView: "agent" as const };
}),
showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")),
showMobileAgentList: () =>
set((state) => {
if (state.mobileView === "agent-list") {
return state;
}
return { mobileView: "agent-list" as const };
}),
showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")),
toggleMobileAgentList: () =>
set((state) => ({
mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list",
})),
set((state) =>
setMobilePanelTargetPatch(
state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
),
),
openDesktopAgentList: () =>
set((state) => {
@@ -190,9 +199,7 @@ export const usePanelStore = create<PanelState>()(
openAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return state.mobileView === "agent-list"
? state
: { mobileView: "agent-list" as const };
return setMobilePanelTargetPatch(state, "agent-list");
}
return state.desktop.agentListOpen
? state
@@ -202,7 +209,7 @@ export const usePanelStore = create<PanelState>()(
closeAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return state.mobileView === "agent" ? state : { mobileView: "agent" as const };
return setMobilePanelTargetPatch(state, "agent");
}
return state.desktop.agentListOpen
? { desktop: { ...state.desktop, agentListOpen: false } }
@@ -212,7 +219,10 @@ export const usePanelStore = create<PanelState>()(
toggleAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" };
return setMobilePanelTargetPatch(
state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
);
}
return {
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
@@ -253,6 +263,13 @@ export const usePanelStore = create<PanelState>()(
[workspaceKey]: paths,
},
})),
setDiffCollapsedFoldersForWorkspace: (workspaceKey, dirPaths) =>
set((state) => ({
diffCollapsedFoldersByWorkspace: {
...state.diffCollapsedFoldersByWorkspace,
[workspaceKey]: dirPaths,
},
})),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
explorerTab: resolveExplorerTabForCheckout({
@@ -276,17 +293,17 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 11,
version: 12,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
@@ -300,7 +317,7 @@ export const usePanelStore = create<PanelState>()(
/**
* Hook that provides platform-aware panel state.
*
* On mobile, uses the state machine (mobileView).
* On mobile, uses the revisioned mobile panel target.
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
*
* @param isMobile - Whether the current breakpoint is mobile

View File

@@ -10,13 +10,14 @@ import {
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility,
type PanelCoreState,
} from "./state";
function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState {
return {
mobileView: "agent",
mobilePanel: { target: "agent", revision: 0 },
desktop: {
agentListOpen: false,
fileExplorerOpen: false,
@@ -103,12 +104,47 @@ describe("panel-store migration", () => {
expect(state.explorerShowHiddenFiles).toBe(true);
});
it("initializes diffCollapsedFoldersByWorkspace for pre-v12 state", () => {
const state = migratePanelState({}, 11, { isWeb: false });
expect(state.diffCollapsedFoldersByWorkspace).toEqual({});
});
it("preserves an existing diffCollapsedFoldersByWorkspace map", () => {
const state = migratePanelState({ diffCollapsedFoldersByWorkspace: { ws: ["src/app"] } }, 12, {
isWeb: false,
});
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
});
it("drops persisted compact panel state so cold starts return to content", () => {
const state = migratePanelState(
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
11,
{ isWeb: false },
);
expect(state.mobileView).toBeUndefined();
expect(state.mobilePanel).toBeUndefined();
});
});
describe("panel-store visibility selectors", () => {
it("uses mobileView for compact layout visibility", () => {
it("increments the mobile panel revision only when the target changes", () => {
const initial = { target: "agent" as const, revision: 4 };
expect(setMobilePanelTarget(initial, "agent")).toBe(initial);
expect(setMobilePanelTarget(initial, "agent-list")).toEqual({
target: "agent-list",
revision: 5,
});
});
it("uses the mobile panel target for compact layout visibility", () => {
const state = makePanelState({
mobileView: "file-explorer",
mobilePanel: { target: "file-explorer", revision: 1 },
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
});
@@ -122,7 +158,7 @@ describe("panel-store visibility selectors", () => {
it("uses desktop flags for expanded layout visibility", () => {
const state = makePanelState({
mobileView: "file-explorer",
mobilePanel: { target: "file-explorer", revision: 1 },
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
});
@@ -146,7 +182,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout });
expect(patch.mobileView).toBe("file-explorer");
expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
expect(patch.desktop).toBeUndefined();
expect(patch.explorerTab).toBe("files");
});
@@ -161,7 +197,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
expect(patch.mobileView).toBeUndefined();
expect(patch.mobilePanel).toBeUndefined();
expect(patch.desktop?.fileExplorerOpen).toBe(true);
expect(patch.explorerTab).toBe("files");
});

View File

@@ -8,6 +8,11 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
export interface MobilePanelSelection {
target: MobilePanelView;
revision: number;
}
export interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
@@ -43,7 +48,7 @@ export interface ExplorerPanelIntent extends PanelLayoutInput {
}
export interface PanelCoreState {
mobileView: MobilePanelView;
mobilePanel: MobilePanelSelection;
desktop: DesktopSidebarState;
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
@@ -74,8 +79,8 @@ export function selectPanelVisibility(
): PanelVisibilityState {
if (input.isCompact) {
return {
isAgentListOpen: state.mobileView === "agent-list",
isFileExplorerOpen: state.mobileView === "file-explorer",
isAgentListOpen: state.mobilePanel.target === "agent-list",
isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
};
}
return {
@@ -92,6 +97,16 @@ export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayo
return selectPanelVisibility(state, input).isFileExplorerOpen;
}
export function setMobilePanelTarget(
selection: MobilePanelSelection,
target: MobilePanelView,
): MobilePanelSelection {
if (selection.target === target) {
return selection;
}
return { target, revision: selection.revision + 1 };
}
function resolveExplorerTabFromCheckout(
state: PanelCoreState,
checkout: ExplorerCheckoutContext,
@@ -105,7 +120,7 @@ function resolveExplorerTabFromCheckout(
}
export interface OpenFileExplorerPatch {
mobileView?: MobilePanelView;
mobilePanel?: MobilePanelSelection;
desktop?: DesktopSidebarState;
explorerTab: ExplorerTab;
}
@@ -117,7 +132,7 @@ export function buildOpenFileExplorerPatch(
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) {
return {
mobileView: "file-explorer",
mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
explorerTab: resolvedTab,
};
}
@@ -129,7 +144,7 @@ export function buildOpenFileExplorerPatch(
export type ToggleFileExplorerPatch =
| OpenFileExplorerPatch
| { mobileView: MobilePanelView }
| { mobilePanel: MobilePanelSelection }
| { desktop: DesktopSidebarState };
export function buildToggleFileExplorerPatch(
@@ -141,7 +156,7 @@ export function buildToggleFileExplorerPatch(
return buildOpenFileExplorerPatch(state, input);
}
if (input.isCompact) {
return { mobileView: "agent" };
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}
@@ -245,9 +260,22 @@ export function migratePanelState(
) {
state.diffExpandedPathsByWorkspace = {};
}
if (
version < 12 ||
typeof state.diffCollapsedFoldersByWorkspace !== "object" ||
!state.diffCollapsedFoldersByWorkspace
) {
state.diffCollapsedFoldersByWorkspace = {};
}
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
if (version < 12) {
// Compact panel position is transient UI state. Cold starts always begin
// at content, regardless of what an older version persisted.
delete state.mobileView;
delete state.mobilePanel;
}
return state;
}

View File

@@ -37,6 +37,10 @@ import {
createAgentLastActivityCoalescer,
type AgentLastActivityCommitter,
} from "@/runtime/activity";
import {
buildWorkspaceAgentActivityIndex,
type WorkspaceAgentActivity,
} from "@/utils/workspace-agent-activity";
// Re-export types that were in session-context
export type MessageEntry =
@@ -353,6 +357,7 @@ export interface SessionState {
// Agents
agents: Map<string, Agent>;
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
agentDetails: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
// Project parents with no active workspaces, keyed by projectId. The
@@ -560,6 +565,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
agentAuthoritativeHistoryApplied: new Map(),
initializingAgents: new Map(),
agents: new Map(),
workspaceAgentActivity: new Map(),
agentDetails: new Map(),
workspaces: new Map(),
emptyProjects: new Map(),
@@ -1194,7 +1200,11 @@ export const useSessionStore = create<SessionStore>()(
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agents: nextAgents },
[serverId]: {
...session,
agents: nextAgents,
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(nextAgents),
},
},
};
});

View File

@@ -27,6 +27,39 @@ const SERVER_ID = "server-1";
const AGENT_ID = "agent-1";
const WORKSPACE_ID = "workspace-1";
function agent(archivedAt: Date | null): Agent {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
return {
serverId: SERVER_ID,
id: AGENT_ID,
provider: "codex",
status: "idle",
createdAt,
updatedAt: createdAt,
lastUserMessageAt: null,
lastActivityAt: createdAt,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
cwd: "/repo",
workspaceId: WORKSPACE_ID,
model: null,
archivedAt,
parentAgentId: null,
labels: {},
};
}
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
return (
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
@@ -44,11 +77,7 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, {
id: AGENT_ID,
workspaceId: WORKSPACE_ID,
archivedAt: new Date(),
} as unknown as Agent);
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
return next;
});
}
@@ -95,11 +124,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
const store = useSessionStore.getState();
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, {
id: AGENT_ID,
workspaceId: WORKSPACE_ID,
archivedAt: undefined,
} as unknown as Agent);
next.set(AGENT_ID, agent(null));
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));

View File

@@ -1,111 +0,0 @@
import { describe, expect, it } from "vitest";
import {
canCloseLeftSidebarGesture,
canCloseRightSidebarGesture,
canOpenLeftSidebarGesture,
canOpenRightSidebarGesture,
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
it("allows the left open gesture only after the app is settled on the agent panel", () => {
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -430, 430)).toBe(true);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -240, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, -430, 430)).toBe(false);
});
it("allows the left close gesture only while the left sidebar is settled open", () => {
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN)).toBe(true);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("allows the right open gesture only after the app is settled on the agent panel", () => {
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 430, 430)).toBe(true);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 240, 430)).toBe(false);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN, 430, 430)).toBe(false);
});
it("allows the right close gesture only while the right sidebar is settled open", () => {
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN)).toBe(true);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("rejects stale settle callbacks from a panel that is no longer the destination", () => {
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_AGENT_LIST,
),
).toBe(true);
expect(
shouldSettleMobilePanelTransition(MOBILE_PANEL_TARGET_AGENT_LIST, MOBILE_PANEL_TARGET_AGENT),
).toBe(false);
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
),
).toBe(false);
});
});

View File

@@ -1,96 +0,0 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export const MOBILE_PANEL_STATE_AGENT = 0;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPENING = 1;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPEN = 2;
export const MOBILE_PANEL_STATE_AGENT_LIST_CLOSING = 3;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING = 4;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN = 5;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING = 6;
export const MOBILE_PANEL_TARGET_AGENT = 0;
export const MOBILE_PANEL_TARGET_AGENT_LIST = 1;
export const MOBILE_PANEL_TARGET_FILE_EXPLORER = 2;
const CLOSED_POSITION_TOLERANCE = 1;
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function canOpenLeftSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX <= -windowWidth + CLOSED_POSITION_TOLERANCE
);
}
export function canCloseLeftSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
export function canOpenRightSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX >= windowWidth - CLOSED_POSITION_TOLERANCE
);
}
export function canCloseRightSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
export function shouldSettleMobilePanelTransition(
activeTarget: number,
settledTarget: number,
): boolean {
"worklet";
return activeTarget === settledTarget;
}

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