mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
35 Commits
v0.1.104-b
...
tooltip-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603b380321 | ||
|
|
ac45b2f2ae | ||
|
|
860fcb2e35 | ||
|
|
4c72bf0209 | ||
|
|
8c639fd796 | ||
|
|
aa656772ef | ||
|
|
4dd783c36a | ||
|
|
e6a316456d | ||
|
|
3c2339f841 | ||
|
|
61a9117a6b | ||
|
|
1f0285c06b | ||
|
|
a689ccb0af | ||
|
|
cac71ee8f5 | ||
|
|
a743a22496 | ||
|
|
b2c6476817 | ||
|
|
6af1379357 | ||
|
|
15d3091104 | ||
|
|
ebaecace2b | ||
|
|
d96d599466 | ||
|
|
29a3261dd6 | ||
|
|
c7406a415d | ||
|
|
202c417347 | ||
|
|
3259a69466 | ||
|
|
cab0e4b3f8 | ||
|
|
6ca1f4dac5 | ||
|
|
6f3ab98986 | ||
|
|
251d88a37a | ||
|
|
c72bcb49e5 | ||
|
|
1a03171f0c | ||
|
|
f3fdeab131 | ||
|
|
fdc6a4a57d | ||
|
|
ed67e6e0f1 | ||
|
|
5ec7ef9771 | ||
|
|
4fb70668f2 | ||
|
|
06c3161008 |
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -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()
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,6 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.104-beta.5 - 2026-07-07
|
||||
## 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
|
||||
|
||||
@@ -14,15 +39,18 @@
|
||||
|
||||
### Improved
|
||||
|
||||
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
|
||||
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
|
||||
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
||||
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
||||
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
||||
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
||||
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
|
||||
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
|
||||
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
|
||||
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
|
||||
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -173,6 +173,19 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
### Agent Tool Catalog Measurement
|
||||
|
||||
Measure the MCP `tools/list` payload that Paseo injects into agents with:
|
||||
|
||||
```bash
|
||||
npm run measure:agent-tools --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The command reports compact JSON bytes, estimated tokens, field totals, largest
|
||||
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
|
||||
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
|
||||
for machine-readable output.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
@@ -290,6 +303,8 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
|
||||
|
||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||
|
||||
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
|
||||
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||
|
||||
88
docs/mobile-panels.md
Normal file
88
docs/mobile-panels.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
|
||||
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.104-beta.5",
|
||||
"version": "0.1.105",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.104-beta.5",
|
||||
"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-beta.5",
|
||||
"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-beta.5",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.104-beta.5",
|
||||
"@getpaseo/protocol": "0.1.104-beta.5",
|
||||
"@getpaseo/server": "0.1.104-beta.5",
|
||||
"@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-beta.5",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.104-beta.5",
|
||||
"@getpaseo/relay": "0.1.104-beta.5",
|
||||
"@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-beta.5",
|
||||
"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-beta.5",
|
||||
"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-beta.5",
|
||||
"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-beta.5",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37819,7 +37819,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.104-beta.5",
|
||||
"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-beta.5",
|
||||
"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-beta.5",
|
||||
"@getpaseo/highlight": "0.1.104-beta.5",
|
||||
"@getpaseo/protocol": "0.1.104-beta.5",
|
||||
"@getpaseo/relay": "0.1.104-beta.5",
|
||||
"@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-beta.5",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.104-beta.5",
|
||||
"version": "0.1.105",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal file
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal 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);
|
||||
}
|
||||
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal file
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal 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-", "");
|
||||
}
|
||||
@@ -371,6 +371,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
|
||||
// Host group rows are now flat top-level sections (no drill-in).
|
||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||
|
||||
60
packages/app/e2e/project-picker-desktop.spec.ts
Normal file
60
packages/app/e2e/project-picker-desktop.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
@@ -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, "\\$&"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.104-beta.5",
|
||||
"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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import {
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
||||
function SettingsDaemonRedirect() {
|
||||
const hosts = useHosts();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
|
||||
if (localDaemon.status === "loading") {
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
if (
|
||||
localDaemon.status === "resolved" &&
|
||||
localDaemon.serverId !== null &&
|
||||
hosts.some((host) => host.serverId === localDaemon.serverId)
|
||||
) {
|
||||
return <Redirect href={buildSettingsHostSectionRoute(localDaemon.serverId, "host")} />;
|
||||
}
|
||||
|
||||
return <Redirect href={buildSettingsRoute()} />;
|
||||
}
|
||||
|
||||
export default function SettingsSectionRoute() {
|
||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||
@@ -10,5 +41,14 @@ export default function SettingsSectionRoute() {
|
||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||
|
||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
||||
if (rawSection === "daemon") {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsDaemonRedirect />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
41
packages/app/src/assistant-file-links/tooltip-path.test.ts
Normal file
41
packages/app/src/assistant-file-links/tooltip-path.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
35
packages/app/src/assistant-file-links/tooltip-path.ts
Normal file
35
packages/app/src/assistant-file-links/tooltip-path.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ContextWindowMeter({
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
void refreshProviderUsage();
|
||||
void refreshProviderUsage().catch(() => {});
|
||||
}
|
||||
},
|
||||
[refreshProviderUsage],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ADD_HOST_OPTION_ID = "__add_host__";
|
||||
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
|
||||
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
|
||||
|
||||
export function getHostPickerLabel(
|
||||
hosts: Array<{ label: string; serverId: string }>,
|
||||
|
||||
@@ -11,10 +11,16 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
} from "./host-picker-constants";
|
||||
|
||||
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
|
||||
export {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
};
|
||||
|
||||
const SEARCHABLE_THRESHOLD = 10;
|
||||
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||
@@ -115,6 +121,12 @@ export function HostPickerOption({
|
||||
);
|
||||
}
|
||||
|
||||
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
|
||||
add: "Add host",
|
||||
all: "All hosts",
|
||||
enableBuiltInDaemon: "Enable built-in daemon",
|
||||
};
|
||||
|
||||
function SystemHostPickerOption({
|
||||
active,
|
||||
selected,
|
||||
@@ -125,12 +137,12 @@ function SystemHostPickerOption({
|
||||
active: boolean;
|
||||
selected?: boolean;
|
||||
onPress: () => void;
|
||||
kind: "add" | "all";
|
||||
kind: "add" | "all" | "enableBuiltInDaemon";
|
||||
testID?: string;
|
||||
}): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = kind === "add" ? Plus : Server;
|
||||
const label = kind === "add" ? "Add host" : "All hosts";
|
||||
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
|
||||
const leadingSlot = useMemo(
|
||||
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
@@ -158,6 +170,8 @@ export interface HostPickerProps {
|
||||
includeAllHost?: boolean;
|
||||
includeAddHost?: boolean;
|
||||
onAddHost?: () => void;
|
||||
includeEnableBuiltInDaemon?: boolean;
|
||||
onEnableBuiltInDaemon?: () => void;
|
||||
showActiveConnection?: boolean;
|
||||
onOpenHostSettings?: (serverId: string) => void;
|
||||
searchable?: boolean;
|
||||
@@ -179,6 +193,8 @@ export function HostPicker({
|
||||
includeAllHost,
|
||||
includeAddHost,
|
||||
onAddHost,
|
||||
includeEnableBuiltInDaemon,
|
||||
onEnableBuiltInDaemon,
|
||||
showActiveConnection,
|
||||
onOpenHostSettings,
|
||||
searchable,
|
||||
@@ -199,8 +215,13 @@ export function HostPicker({
|
||||
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
|
||||
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
|
||||
if (includeEnableBuiltInDaemon)
|
||||
hostOptions.push({
|
||||
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
|
||||
label: "Enable built-in daemon",
|
||||
});
|
||||
return hostOptions;
|
||||
}, [orderedHosts, includeAllHost, includeAddHost]);
|
||||
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
|
||||
|
||||
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
|
||||
|
||||
@@ -208,12 +229,14 @@ export function HostPicker({
|
||||
(id: string) => {
|
||||
if (id === ADD_HOST_OPTION_ID) {
|
||||
onAddHost?.();
|
||||
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
||||
onEnableBuiltInDaemon?.();
|
||||
} else {
|
||||
onSelect(id);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onAddHost, onOpenChange, onSelect],
|
||||
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
|
||||
);
|
||||
|
||||
const handleOpenHostSettings = useCallback(
|
||||
@@ -246,6 +269,11 @@ export function HostPicker({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
|
||||
return (
|
||||
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HostPickerOption
|
||||
serverId={option.id}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
||||
|
||||
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ProjectPickerBrowseButtonProps {
|
||||
serverId: string;
|
||||
disabled: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
onError: () => void;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
157
packages/app/src/components/root-error-boundary.tsx
Normal file
157
packages/app/src/components/root-error-boundary.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
109
packages/app/src/components/root-error-details.test.ts
Normal file
109
packages/app/src/components/root-error-details.test.ts
Normal 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}');
|
||||
});
|
||||
});
|
||||
155
packages/app/src/components/root-error-details.ts
Normal file
155
packages/app/src/components/root-error-details.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
76
packages/app/src/components/sidebar/sidebar-model.tsx
Normal file
76
packages/app/src/components/sidebar/sidebar-model.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
151
packages/app/src/components/sidebar/sidebar-workspace-menu.tsx
Normal file
151
packages/app/src/components/sidebar/sidebar-workspace-menu.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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({
|
||||
|
||||
82
packages/app/src/components/tree-primitives.tsx
Normal file
82
packages/app/src/components/tree-primitives.tsx
Normal 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];
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,6 +34,7 @@ interface UseBuiltInDaemonManagementInput {
|
||||
interface UseBuiltInDaemonManagementResult {
|
||||
isUpdating: boolean;
|
||||
toggle: () => void;
|
||||
enable: () => Promise<DaemonManagementToggleResult | null>;
|
||||
}
|
||||
|
||||
export function useBuiltInDaemonManagement(
|
||||
@@ -42,12 +43,16 @@ export function useBuiltInDaemonManagement(
|
||||
const { t } = useTranslation();
|
||||
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
|
||||
DaemonManagementToggleResult,
|
||||
Error
|
||||
>({
|
||||
mutationFn: async () => {
|
||||
const wasManagingDaemon = settings.manageBuiltInDaemon;
|
||||
const {
|
||||
mutate: toggleDaemonManagement,
|
||||
mutateAsync: toggleDaemonManagementAsync,
|
||||
isPending: isUpdating,
|
||||
} = useMutation<DaemonManagementToggleResult, Error, { forceEnable: boolean }>({
|
||||
mutationFn: async ({ forceEnable }) => {
|
||||
// forceEnable takes the enable branch regardless of the persisted
|
||||
// setting — the recovery affordance must never reach the stop/confirm
|
||||
// path even if manageBuiltInDaemon was left true.
|
||||
const wasManagingDaemon = forceEnable ? false : settings.manageBuiltInDaemon;
|
||||
try {
|
||||
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
||||
confirm: () =>
|
||||
@@ -109,8 +114,21 @@ export function useBuiltInDaemonManagement(
|
||||
return;
|
||||
}
|
||||
|
||||
toggleDaemonManagement();
|
||||
toggleDaemonManagement({ forceEnable: false });
|
||||
}, [isUpdating, toggleDaemonManagement]);
|
||||
|
||||
return { isUpdating, toggle };
|
||||
const enable = useCallback(async () => {
|
||||
if (isUpdating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await toggleDaemonManagementAsync({ forceEnable: true });
|
||||
} catch {
|
||||
// onError has already surfaced the failure; callers only act on success.
|
||||
return null;
|
||||
}
|
||||
}, [isUpdating, toggleDaemonManagementAsync]);
|
||||
|
||||
return { isUpdating, toggle, enable };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
|
||||
export interface EnableBuiltInDaemonOption {
|
||||
visible: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
const isElectron = getIsElectron();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const hosts = useHosts();
|
||||
const { settings, updateSettings } = useDesktopSettings();
|
||||
const { data, setStatus, refetch } = useDaemonStatus();
|
||||
const { enable } = useBuiltInDaemonManagement({
|
||||
daemonStatus: data?.status ?? null,
|
||||
settings: settings.daemon,
|
||||
updateSettings: (updates) => updateSettings({ daemon: updates }),
|
||||
setStatus,
|
||||
refreshStatus: refetch,
|
||||
});
|
||||
|
||||
const isLocalhostConfigured =
|
||||
localDaemon.status === "resolved" &&
|
||||
localDaemon.serverId !== null &&
|
||||
hosts.some((host) => host.serverId === localDaemon.serverId);
|
||||
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
void (async () => {
|
||||
const result = await enable();
|
||||
if (result?.kind === "enabled") {
|
||||
router.push(buildSettingsHostSectionRoute(result.newStatus.serverId, "host"));
|
||||
}
|
||||
})();
|
||||
}, [enable]);
|
||||
|
||||
return { visible, onPress };
|
||||
}
|
||||
33
packages/app/src/desktop/pick-directory.test.ts
Normal file
33
packages/app/src/desktop/pick-directory.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
packages/app/src/desktop/pick-directory.ts
Normal file
23
packages/app/src/desktop/pick-directory.ts
Normal 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.");
|
||||
}
|
||||
134
packages/app/src/git/diff-flat-items.test.ts
Normal file
134
packages/app/src/git/diff-flat-items.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
packages/app/src/git/diff-flat-items.ts
Normal file
121
packages/app/src/git/diff-flat-items.ts
Normal 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);
|
||||
}
|
||||
124
packages/app/src/git/diff-folder-row.tsx
Normal file
124
packages/app/src/git/diff-folder-row.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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],
|
||||
|
||||
149
packages/app/src/git/diff-tree.test.ts
Normal file
149
packages/app/src/git/diff-tree.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
207
packages/app/src/git/diff-tree.ts
Normal file
207
packages/app/src/git/diff-tree.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface DictationAudioSourceConfig {
|
||||
onPcmSegment: (pcm16Base64: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onInterruption?: () => void;
|
||||
}
|
||||
|
||||
export interface DictationAudioSource {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,10 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
|
||||
};
|
||||
}
|
||||
|
||||
export function useLocalDaemonServerId(): string | null {
|
||||
function useLocalDaemonServerIdQuery() {
|
||||
const isDesktopApp = shouldUseDesktopDaemon();
|
||||
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
|
||||
queryFn: loadDesktopDaemonServerId,
|
||||
enabled: isDesktopApp,
|
||||
@@ -30,6 +30,11 @@ export function useLocalDaemonServerId(): string | null {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLocalDaemonServerId(): string | null {
|
||||
const isDesktopApp = shouldUseDesktopDaemon();
|
||||
const query = useLocalDaemonServerIdQuery();
|
||||
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
@@ -38,6 +43,27 @@ export function useLocalDaemonServerId(): string | null {
|
||||
return query.data?.serverId ?? null;
|
||||
}
|
||||
|
||||
export type LocalDaemonServerIdState =
|
||||
| { status: "loading" }
|
||||
| { status: "error" }
|
||||
| { status: "resolved"; serverId: string | null };
|
||||
|
||||
export function useLocalDaemonServerIdState(): LocalDaemonServerIdState {
|
||||
const isDesktopApp = shouldUseDesktopDaemon();
|
||||
const query = useLocalDaemonServerIdQuery();
|
||||
|
||||
if (!isDesktopApp) {
|
||||
return { status: "resolved", serverId: null };
|
||||
}
|
||||
if (query.isError) {
|
||||
return { status: "error" };
|
||||
}
|
||||
if (query.isSuccess) {
|
||||
return { status: "resolved", serverId: query.data.serverId };
|
||||
}
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
export function useIsLocalDaemon(serverId: string): boolean {
|
||||
const normalizedServerId = serverId.trim();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: "ابدأ بكتابة المسار",
|
||||
@@ -1404,6 +1419,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
backToWorkspace: "خلف",
|
||||
addHost: "أضف مضيفًا",
|
||||
enableBuiltInDaemon: "تفعيل البرنامج الخفي المدمج",
|
||||
projects: "المشاريع",
|
||||
projectList: {
|
||||
hostLoadFailed: "تعذر تحميل المشاريع من المضيف{{hostName}}:{{message}}",
|
||||
@@ -1412,7 +1428,6 @@ export const ar: TranslationResources = {
|
||||
groupInfo: "حول{{title}}",
|
||||
sections: {
|
||||
general: "عام",
|
||||
daemon: "Daemon",
|
||||
appearance: "مظهر",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
@@ -1427,7 +1442,7 @@ export const ar: TranslationResources = {
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
host: "نظرة عامة",
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
@@ -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: "الافتراضي للنظام",
|
||||
|
||||
@@ -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",
|
||||
@@ -1412,6 +1427,7 @@ export const en = {
|
||||
},
|
||||
backToWorkspace: "Back",
|
||||
addHost: "Add host",
|
||||
enableBuiltInDaemon: "Enable built-in daemon",
|
||||
projects: "Projects",
|
||||
projectList: {
|
||||
hostLoadFailed: "Couldn't load projects from host {{hostName}}: {{message}}",
|
||||
@@ -1420,7 +1436,6 @@ export const en = {
|
||||
groupInfo: "About {{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
daemon: "Daemon",
|
||||
appearance: "Appearance",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
@@ -1435,7 +1450,7 @@ export const en = {
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
host: "Overview",
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1443,6 +1458,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
backToWorkspace: "Atrás",
|
||||
addHost: "Agregar anfitrión",
|
||||
enableBuiltInDaemon: "Activar el demonio integrado",
|
||||
projects: "Proyectos",
|
||||
projectList: {
|
||||
hostLoadFailed: "No se pudieron cargar proyectos desde el host{{hostName}}:{{message}}",
|
||||
@@ -1451,7 +1467,6 @@ export const es: TranslationResources = {
|
||||
groupInfo: "Acerca de{{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
daemon: "Daemon",
|
||||
appearance: "Apariencia",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
@@ -1466,7 +1481,7 @@ export const es: TranslationResources = {
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
host: "Resumen",
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1446,6 +1461,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
backToWorkspace: "Dos",
|
||||
addHost: "Ajouter un hôte",
|
||||
enableBuiltInDaemon: "Activer le démon intégré",
|
||||
projects: "Projets",
|
||||
projectList: {
|
||||
hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}",
|
||||
@@ -1454,7 +1470,6 @@ export const fr: TranslationResources = {
|
||||
groupInfo: "À propos de{{title}}",
|
||||
sections: {
|
||||
general: "Général",
|
||||
daemon: "Daemon",
|
||||
appearance: "Apparence",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
@@ -1469,7 +1484,7 @@ export const fr: TranslationResources = {
|
||||
providers: "Fournisseurs",
|
||||
usage: "Utilisation",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
host: "Aperçu",
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "パスを入力してください",
|
||||
@@ -1421,6 +1436,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
backToWorkspace: "戻る",
|
||||
addHost: "ホストを追加",
|
||||
enableBuiltInDaemon: "組み込みデーモンを有効にする",
|
||||
projects: "プロジェクト",
|
||||
projectList: {
|
||||
hostLoadFailed: "ホスト{{hostName}}からプロジェクトを読み込めませんでした: {{message}}",
|
||||
@@ -1429,7 +1445,6 @@ export const ja: TranslationResources = {
|
||||
groupInfo: "{{title}}について",
|
||||
sections: {
|
||||
general: "一般",
|
||||
daemon: "デーモン",
|
||||
appearance: "外観",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
@@ -1444,7 +1459,7 @@ export const ja: TranslationResources = {
|
||||
providers: "プロバイダー",
|
||||
usage: "使用状況",
|
||||
terminals: "ターミナル",
|
||||
host: "ホスト",
|
||||
host: "概要",
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
@@ -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: "システムデフォルト",
|
||||
|
||||
@@ -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",
|
||||
@@ -1429,6 +1444,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
backToWorkspace: "Voltar",
|
||||
addHost: "Adicionar host",
|
||||
enableBuiltInDaemon: "Ativar o daemon integrado",
|
||||
projects: "Projetos",
|
||||
projectList: {
|
||||
hostLoadFailed: "Não foi possível carregar projetos do host {{hostName}}: {{message}}",
|
||||
@@ -1437,7 +1453,6 @@ export const ptBR: TranslationResources = {
|
||||
groupInfo: "Sobre {{title}}",
|
||||
sections: {
|
||||
general: "Geral",
|
||||
daemon: "Daemon",
|
||||
appearance: "Aparência",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
@@ -1452,7 +1467,7 @@ export const ptBR: TranslationResources = {
|
||||
providers: "Provedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminais",
|
||||
host: "Host",
|
||||
host: "Visão geral",
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Начните вводить путь",
|
||||
@@ -1435,6 +1450,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
backToWorkspace: "Назад",
|
||||
addHost: "Добавить хост",
|
||||
enableBuiltInDaemon: "Включить встроенный демон",
|
||||
projects: "Проекты",
|
||||
projectList: {
|
||||
hostLoadFailed: "Не удалось загрузить проекты с хоста{{hostName}}:{{message}}.",
|
||||
@@ -1443,7 +1459,6 @@ export const ru: TranslationResources = {
|
||||
groupInfo: "О{{title}}",
|
||||
sections: {
|
||||
general: "Общий",
|
||||
daemon: "Daemon",
|
||||
appearance: "Появление",
|
||||
shortcuts: "Ярлыки",
|
||||
integrations: "Интеграции",
|
||||
@@ -1458,7 +1473,7 @@ export const ru: TranslationResources = {
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
host: "Обзор",
|
||||
},
|
||||
general: {
|
||||
title: "Общий",
|
||||
@@ -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: "Система по умолчанию",
|
||||
|
||||
@@ -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: "开始输入路径",
|
||||
@@ -1387,6 +1402,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
backToWorkspace: "返回",
|
||||
addHost: "添加主机",
|
||||
enableBuiltInDaemon: "启用内置 daemon",
|
||||
projects: "项目",
|
||||
projectList: {
|
||||
hostLoadFailed: "无法从 Host {{hostName}} 加载 projects:{{message}}",
|
||||
@@ -1395,7 +1411,6 @@ export const zhCN: TranslationResources = {
|
||||
groupInfo: "关于 {{title}}",
|
||||
sections: {
|
||||
general: "通用",
|
||||
daemon: "Daemon",
|
||||
appearance: "外观",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
@@ -1410,7 +1425,7 @@ export const zhCN: TranslationResources = {
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
host: "概览",
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
@@ -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: "系统默认",
|
||||
|
||||
422
packages/app/src/mobile-panels/gestures.ts
Normal file
422
packages/app/src/mobile-panels/gestures.ts
Normal 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;
|
||||
}
|
||||
183
packages/app/src/mobile-panels/model.test.ts
Normal file
183
packages/app/src/mobile-panels/model.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
155
packages/app/src/mobile-panels/model.ts
Normal file
155
packages/app/src/mobile-panels/model.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
115
packages/app/src/mobile-panels/presentation.tsx
Normal file
115
packages/app/src/mobile-panels/presentation.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
260
packages/app/src/mobile-panels/provider.tsx
Normal file
260
packages/app/src/mobile-panels/provider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user