mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ae53c7e55 | ||
|
|
c800c6a3f1 | ||
|
|
adc9b3b43c | ||
|
|
5d18edd31e | ||
|
|
6c764f211a | ||
|
|
28a27b02d3 | ||
|
|
860fcb2e35 | ||
|
|
4c72bf0209 | ||
|
|
8c639fd796 | ||
|
|
aa656772ef | ||
|
|
4dd783c36a | ||
|
|
e6a316456d | ||
|
|
3c2339f841 | ||
|
|
61a9117a6b | ||
|
|
1f0285c06b | ||
|
|
a689ccb0af | ||
|
|
cac71ee8f5 | ||
|
|
a743a22496 | ||
|
|
b2c6476817 | ||
|
|
6af1379357 | ||
|
|
15d3091104 | ||
|
|
ebaecace2b |
@@ -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()
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.105 - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
|
||||
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
### Improved
|
||||
|
||||
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
|
||||
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
|
||||
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
|
||||
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
|
||||
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
|
||||
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
|
||||
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
|
||||
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
|
||||
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
|
||||
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
|
||||
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
|
||||
|
||||
## 0.1.104 - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -84,6 +84,10 @@ For testing rules, see [testing.md](testing.md).
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
|
||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Mobile panels
|
||||
|
||||
Compact layouts have three mutually exclusive destinations:
|
||||
|
||||
- `agent-list` on the left
|
||||
- `agent` in the center
|
||||
- `file-explorer` on the right
|
||||
|
||||
They are one interaction, not two independent drawers. The implementation lives in
|
||||
`packages/app/src/mobile-panels/`.
|
||||
|
||||
## Ownership
|
||||
|
||||
React/Zustand owns the durable intent:
|
||||
|
||||
```ts
|
||||
interface MobilePanelSelection {
|
||||
target: "agent-list" | "agent" | "file-explorer";
|
||||
revision: number;
|
||||
}
|
||||
```
|
||||
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
The UI worklet owns transient motion:
|
||||
|
||||
- one normalized position (`-1` left, `0` center, `1` right)
|
||||
- the current motion target
|
||||
- the active gesture's starting revision
|
||||
- the last settled target
|
||||
|
||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||
|
||||
## Why one position
|
||||
|
||||
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
||||
width is only a projection input. Rotation changes the projection, not the panel state.
|
||||
|
||||
This makes these invalid states unrepresentable:
|
||||
|
||||
- a panel and its backdrop disagreeing
|
||||
- left and right drawers both claiming to be open
|
||||
- a width-sync effect resetting an active drag
|
||||
- one animation context settling a transition owned by the other
|
||||
|
||||
Do not add another panel translate shared value, backdrop shared value, or width synchronization
|
||||
effect.
|
||||
|
||||
## Ordering and interruption
|
||||
|
||||
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
|
||||
while that revision still owns the gesture.
|
||||
|
||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
||||
its target and revision still match the canonical command.
|
||||
|
||||
Manual gesture arbitration has two phases:
|
||||
|
||||
1. Before activation, determine whether horizontal intent may begin.
|
||||
2. After activation, stop running begin checks and let the active revision own updates.
|
||||
|
||||
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
|
||||
definition, no longer eligible to begin.
|
||||
|
||||
## Integration rules
|
||||
|
||||
- Callers request semantic targets through `panel-store`; they never write shared values.
|
||||
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
||||
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
||||
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
||||
so its injected `collapsable={false}` reaches Android/Fabric.
|
||||
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
||||
styles in sidebar components.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
same active signal without adding another layout root. Persistent animations, timers, polling, and
|
||||
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
|
||||
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
|
||||
while its retained panel is active so hidden animated styles remain mounted without receiving clock
|
||||
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
||||
sequence there whenever ownership or ordering changes.
|
||||
@@ -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.
|
||||
|
||||
@@ -90,6 +90,19 @@ When a reusable component has a prop whose whole job is dynamic geometry, make t
|
||||
|
||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||
|
||||
Do not register an existing Unistyles style inside another `StyleSheet.create` either. That also combines two metadata identities into one object. Reuse the original style directly at the component:
|
||||
|
||||
```tsx
|
||||
// Wrong: sharedStyles.row already carries Unistyles metadata.
|
||||
const styles = StyleSheet.create({ row: sharedStyles.row });
|
||||
<View style={styles.row} />;
|
||||
|
||||
// Right: one registered style identity reaches the native view.
|
||||
<View style={sharedStyles.row} />;
|
||||
```
|
||||
|
||||
This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-RtZABQl17BlUyvFiFw9W5g2E/dpNlrOWpuCdV8wZk5o=
|
||||
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35152,7 +35152,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36170,12 +36170,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/server": "0.1.104",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36421,10 +36421,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/relay": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36435,7 +36435,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36678,7 +36678,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37574,7 +37574,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37806,7 +37806,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37819,7 +37819,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38037,15 +38037,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.104",
|
||||
"@getpaseo/highlight": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/relay": "0.1.104",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/highlight": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38582,7 +38582,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -37,6 +37,91 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the active Markdown root mounted across streamed text updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-markdown-root-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for Markdown root stability test.",
|
||||
});
|
||||
try {
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
|
||||
|
||||
const activeBlock = assistantMessage.locator(":scope > *").last();
|
||||
const initialText = (await activeBlock.textContent()) ?? "";
|
||||
const activeBlockHandle = await activeBlock.elementHandle();
|
||||
if (!activeBlockHandle) {
|
||||
throw new Error("Expected the active assistant message to contain a block");
|
||||
}
|
||||
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
|
||||
if (!markdownRoot) {
|
||||
throw new Error("Expected the active assistant block to contain a Markdown root");
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
Object.assign(window, {
|
||||
__markdownRootEvidence: evidence,
|
||||
__markdownRootObserver: observer,
|
||||
});
|
||||
}, activeBlockHandle);
|
||||
|
||||
await expect
|
||||
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
|
||||
.toBeGreaterThan(initialText.length + 80);
|
||||
|
||||
const evidence = await page.evaluate((root) => {
|
||||
const state = window as typeof window & {
|
||||
__markdownRootEvidence?: {
|
||||
addedNodes: number;
|
||||
characterDataMutations: number;
|
||||
removedNodes: number;
|
||||
};
|
||||
__markdownRootObserver?: MutationObserver;
|
||||
};
|
||||
state.__markdownRootObserver?.disconnect();
|
||||
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
|
||||
const message = messages.item(messages.length - 1);
|
||||
const block = message?.lastElementChild;
|
||||
return {
|
||||
...state.__markdownRootEvidence,
|
||||
connected: root.isConnected,
|
||||
sameRoot: block?.firstElementChild === root,
|
||||
};
|
||||
}, markdownRoot);
|
||||
|
||||
await testInfo.attach("markdown-root-stability", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(evidence.connected).toBe(true);
|
||||
expect(evidence.sameRoot).toBe(true);
|
||||
expect(
|
||||
evidence.removedNodes,
|
||||
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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-", "");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -22,6 +22,7 @@
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
|
||||
@@ -152,6 +152,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
@@ -98,6 +99,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
}: {
|
||||
inFlightTurnStartedAt?: Date | null;
|
||||
}) {
|
||||
const active = useRetainedPanelActive();
|
||||
return (
|
||||
<View style={stylesheet.turnFooterContent}>
|
||||
<View style={stylesheet.workingLoader}>
|
||||
@@ -106,6 +108,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
{inFlightTurnStartedAt ? (
|
||||
<LiveElapsed
|
||||
startedAt={inFlightTurnStartedAt}
|
||||
active={active}
|
||||
style={stylesheet.workingElapsed}
|
||||
testID="turn-working-elapsed"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
@@ -88,7 +87,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
@@ -509,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const frozenStreamItemsRef = useRef(streamItems);
|
||||
const frozenStreamHeadRef = useRef(streamHead);
|
||||
if (isActive) {
|
||||
|
||||
@@ -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,6 +27,7 @@ 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";
|
||||
@@ -44,16 +38,8 @@ 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";
|
||||
@@ -82,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";
|
||||
@@ -96,17 +84,11 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import {
|
||||
@@ -405,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);
|
||||
@@ -475,28 +451,31 @@ function AppContainer({
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
{!isCompactLayout ? sidebarChrome : null}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = (
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
@@ -505,18 +484,38 @@ function AppContainer({
|
||||
<HostChooserModal />
|
||||
<ProjectPickerModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<QuittingOverlay />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isCompactLayout) {
|
||||
return content;
|
||||
}
|
||||
const content = isCompactLayout ? (
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
||||
) : (
|
||||
surface
|
||||
);
|
||||
|
||||
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||
return content;
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
showSidebar,
|
||||
keyboardShortcutsEnabled,
|
||||
}: {
|
||||
showSidebar: boolean;
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
return (
|
||||
<SidebarModelProvider active={showSidebar && isOpen}>
|
||||
{showSidebar ? <LeftSidebar /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileGestureWrapper({
|
||||
@@ -526,131 +525,13 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
setOverlayPeek,
|
||||
isGesturing,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled;
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
showMobileAgentList();
|
||||
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(openGestureRef)
|
||||
.enabled(openGestureEnabled)
|
||||
.manualActivation(true)
|
||||
.failOffsetY([-10, 10])
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (touch) {
|
||||
touchStartX.value = touch.absoluteX;
|
||||
touchStartY.value = touch.absoluteY;
|
||||
}
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || event.numberOfTouches !== 1) return;
|
||||
|
||||
const deltaX = touch.absoluteX - touchStartX.value;
|
||||
const deltaY = touch.absoluteY - touchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX > 15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
// The overlay is display:none while closed; reveal it for the drag.
|
||||
runOnJS(setOverlayPeek)(true);
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||
translateX.value = newTranslateX;
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
animateToClose();
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
runOnJS(setOverlayPeek)(false);
|
||||
}),
|
||||
[
|
||||
openGestureEnabled,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobilePanelState,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
setOverlayPeek,
|
||||
handleGestureOpen,
|
||||
isGesturing,
|
||||
openGestureRef,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
],
|
||||
);
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
{children}
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||
{children}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
@@ -882,7 +763,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]);
|
||||
@@ -896,30 +776,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() {
|
||||
@@ -974,7 +831,7 @@ function WorkspaceRouteNavigationBridge() {
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
@@ -982,7 +839,7 @@ function AppShell() {
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</MobilePanelsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
useActiveWorkspaceSelection,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
areWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
getWorkspaceSelectionKey,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
@@ -185,22 +187,25 @@ function WorkspaceDeck() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
const next = pruneMountedWorkspaceSelections({
|
||||
currentSelections: current,
|
||||
const nextMountedSelections = useMemo(
|
||||
() =>
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
activeSelection,
|
||||
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
});
|
||||
if (areWorkspaceSelectionListsEqual(current, next)) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [activeSelection]);
|
||||
}),
|
||||
[activeSelection, mountedSelections],
|
||||
);
|
||||
const renderedSelections = useMemo(
|
||||
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
|
||||
[nextMountedSelections],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
|
||||
setMountedSelections(nextMountedSelections);
|
||||
}
|
||||
}, [mountedSelections, nextMountedSelections]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
@@ -208,7 +213,7 @@ function WorkspaceDeck() {
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{mountedSelections.map((selection) => {
|
||||
{renderedSelections.map((selection) => {
|
||||
return (
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
@@ -251,8 +256,8 @@ function WorkspaceDeckEntry({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
<RetainedPanel
|
||||
active={isActive}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
@@ -260,7 +265,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
</RetainedPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,11 +273,4 @@ const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,16 +29,16 @@ import {
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
|
||||
@@ -91,25 +91,11 @@ export function CompactExplorerSidebar({
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: true,
|
||||
});
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
overlayVisible,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const { gesture: closeGesture } = useCloseFileExplorerGesture();
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
@@ -122,184 +108,40 @@ export function CompactExplorerSidebar({
|
||||
[isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
mobilePanelGestureAnimatingRef.current = true;
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(true)
|
||||
// Use manual activation so child views keep touch streams
|
||||
// unless we detect an intentional right-swipe close.
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX;
|
||||
closeTouchStartY.value = touch.absoluteY;
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate only on intentional rightward movement.
|
||||
if (deltaX >= 15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Right sidebar: swipe right to close (positive translationX)
|
||||
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
|
||||
translateX.value = newTranslateX;
|
||||
const progress = 1 - newTranslateX / windowWidth;
|
||||
backdropOpacity.value = Math.max(0, Math.min(1, progress));
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldClose,
|
||||
windowWidth,
|
||||
});
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobilePanelState,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleCloseFromGesture,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
}));
|
||||
|
||||
const backdropCombinedStyle = useMemo(
|
||||
() => [
|
||||
explorerStaticStyles.backdrop,
|
||||
backdropAnimatedStyle,
|
||||
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
||||
// touches it, so a stale animated-prop revert can't wedge an invisible
|
||||
// tap-eating backdrop.
|
||||
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
||||
],
|
||||
[backdropAnimatedStyle, isOpen],
|
||||
);
|
||||
const mobileSidebarStyle = useMemo(
|
||||
() => [
|
||||
explorerStaticStyles.mobileSidebar,
|
||||
{
|
||||
width: windowWidth,
|
||||
paddingTop: insets.top,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
],
|
||||
[
|
||||
windowWidth,
|
||||
insets.top,
|
||||
theme.colors.surfaceSidebar,
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
],
|
||||
[insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle],
|
||||
);
|
||||
// display is React-owned on the plain wrapper View (no animated styles), so
|
||||
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
||||
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
||||
const overlayStyle = useMemo(
|
||||
() => [
|
||||
StyleSheet.absoluteFillObject,
|
||||
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
||||
],
|
||||
[overlayVisible],
|
||||
);
|
||||
|
||||
// Mobile: full-screen overlay with gesture.
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
let overlayPointerEvents: "auto" | "none" | "box-none";
|
||||
if (!isWeb) overlayPointerEvents = "box-none";
|
||||
else if (isOpen) overlayPointerEvents = "auto";
|
||||
else overlayPointerEvents = "none";
|
||||
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropCombinedStyle} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarStyle}
|
||||
>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -604,17 +446,6 @@ function PrTabContent({
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const explorerStaticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
|
||||
@@ -414,7 +414,7 @@ export function FilePane({
|
||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
||||
// gate itself lives in isFileQueryEnabled.
|
||||
const isActive = useContext(MountedTabActiveContext);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isAppVisible = useAppVisible();
|
||||
|
||||
const query = useQuery({
|
||||
|
||||
@@ -21,13 +21,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -38,19 +32,18 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -62,7 +55,8 @@ import {
|
||||
usePanelStore,
|
||||
} from "@/stores/panel-store";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
@@ -79,25 +73,21 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string;
|
||||
}
|
||||
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
statusGroups: StatusGroup[];
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
handleRefresh: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
@@ -123,7 +113,6 @@ interface SidebarLabels {
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
isOpen: boolean;
|
||||
closeSidebar: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
handleViewSchedulesNavigate: () => void;
|
||||
@@ -136,11 +125,7 @@ interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
handleViewSchedules: () => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
selectedAgentId: _selectedAgentId,
|
||||
}: LeftSidebarProps) {
|
||||
void _selectedAgentId;
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -151,22 +136,19 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const {
|
||||
workspacePlacements,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useSidebarWorkspacesList({
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: workspacePlacements,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
groupMode,
|
||||
shortcutModel,
|
||||
} = useSidebarModel();
|
||||
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
@@ -257,8 +239,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
statusWorkspacePlacements,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -274,36 +257,39 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -556,8 +542,9 @@ function SidebarFooter({
|
||||
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
statusWorkspacePlacements,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -576,7 +563,6 @@ function MobileSidebar({
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
handleViewSchedulesNavigate,
|
||||
@@ -584,269 +570,121 @@ function MobileSidebar({
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
overlayVisible,
|
||||
isGesturing,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeSidebar();
|
||||
}, [closeSidebar, gestureAnimatingRef]);
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewMoreNavigate();
|
||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||
}, [closeSidebar, handleViewMoreNavigate]);
|
||||
|
||||
const handleViewSchedules = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewSchedulesNavigate();
|
||||
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
||||
}, [closeSidebar, handleViewSchedulesNavigate]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(true)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX;
|
||||
closeTouchStartY.value = touch.absoluteY;
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
||||
translateX.value = newTranslateX;
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
isGesturing,
|
||||
mobilePanelState,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
animateToClose,
|
||||
animateToOpen,
|
||||
handleCloseFromGesture,
|
||||
],
|
||||
);
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
||||
[windowWidth, insetsTop, insetsBottom],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
}));
|
||||
|
||||
let overlayPointerEvents: "auto" | "none" | "box-none";
|
||||
if (!isWeb) overlayPointerEvents = "box-none";
|
||||
else if (isOpen) overlayPointerEvents = "auto";
|
||||
else overlayPointerEvents = "none";
|
||||
|
||||
const backdropStyle = useMemo(
|
||||
() => [
|
||||
staticStyles.backdrop,
|
||||
backdropAnimatedStyle,
|
||||
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
||||
// touches it, so a stale animated-prop revert can't wedge an invisible
|
||||
// tap-eating backdrop.
|
||||
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
||||
],
|
||||
[backdropAnimatedStyle, isOpen],
|
||||
);
|
||||
const mobileSidebarStyle = useMemo(
|
||||
() => [
|
||||
staticStyles.mobileSidebar,
|
||||
mobileSidebarInsetStyle,
|
||||
sidebarAnimatedStyle,
|
||||
{ backgroundColor: theme.colors.surfaceSidebar },
|
||||
],
|
||||
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
||||
);
|
||||
// display is React-owned on the plain wrapper View (no animated styles), so
|
||||
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
||||
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
||||
const overlayStyle = useMemo(
|
||||
() => [
|
||||
StyleSheet.absoluteFillObject,
|
||||
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
||||
],
|
||||
[overlayVisible],
|
||||
() => ({
|
||||
paddingTop: insetsTop,
|
||||
paddingBottom: insetsBottom,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
}),
|
||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropStyle} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projects={projects}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarInsetStyle}
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
</View>
|
||||
</MobilePanelOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
statusWorkspacePlacements,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -970,8 +808,9 @@ function DesktopSidebar({
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
statusGroups={statusGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -1059,17 +898,6 @@ function WorkspacesSectionHeader() {
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
|
||||
@@ -692,33 +692,39 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
interface LiveElapsedProps {
|
||||
startedAt: Date;
|
||||
active?: boolean;
|
||||
style?: StyleProp<TextStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
|
||||
* Ticks every second to render an elapsed duration. Isolated from parents so
|
||||
* only this component re-renders on each tick.
|
||||
*/
|
||||
export const LiveElapsed = memo(function LiveElapsed({
|
||||
startedAt,
|
||||
active = true,
|
||||
style,
|
||||
testID,
|
||||
}: LiveElapsedProps) {
|
||||
const startedAtMs = startedAt.getTime();
|
||||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
||||
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
const handle = setInterval(() => {
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
}, 100);
|
||||
}, 1000);
|
||||
return () => clearInterval(handle);
|
||||
}, [startedAtMs]);
|
||||
}, [active, startedAtMs]);
|
||||
|
||||
return (
|
||||
<Text style={style} testID={testID}>
|
||||
{formatDuration(elapsedMs)}
|
||||
{formatDuration(visibleElapsedMs)}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,22 +9,16 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function MarkdownInlineText({
|
||||
textKey,
|
||||
inheritedStyle,
|
||||
ruleStyle,
|
||||
children,
|
||||
}: {
|
||||
textKey: string;
|
||||
inheritedStyle: StyleProp<TextStyle>;
|
||||
ruleStyle: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
|
||||
return (
|
||||
<Text key={textKey} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
return <Text style={style}>{children}</Text>;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({
|
||||
@@ -39,12 +33,10 @@ function MarkdownListItemContent({
|
||||
}
|
||||
|
||||
function MarkdownParagraph({
|
||||
textKey,
|
||||
paragraphStyle,
|
||||
isLastChild,
|
||||
children,
|
||||
}: {
|
||||
textKey: string;
|
||||
paragraphStyle: StyleProp<ViewStyle>;
|
||||
isLastChild: boolean;
|
||||
children: ReactNode;
|
||||
@@ -53,11 +45,7 @@ function MarkdownParagraph({
|
||||
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
|
||||
[paragraphStyle, isLastChild],
|
||||
);
|
||||
return (
|
||||
<View key={textKey} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
function createPlanMarkdownRules() {
|
||||
@@ -69,11 +57,7 @@ function createPlanMarkdownRules() {
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.text}
|
||||
>
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
@@ -85,7 +69,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
key={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.textgroup}
|
||||
>
|
||||
@@ -100,7 +84,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
key={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_block}
|
||||
>
|
||||
@@ -114,11 +98,7 @@ function createPlanMarkdownRules() {
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.fence}
|
||||
>
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
@@ -130,7 +110,7 @@ function createPlanMarkdownRules() {
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
textKey={node.key}
|
||||
key={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_inline}
|
||||
>
|
||||
@@ -183,7 +163,7 @@ function createPlanMarkdownRules() {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<MarkdownParagraph
|
||||
textKey={node.key}
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
isLastChild={isLastChild}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, memo, type ReactNode, useContext } from "react";
|
||||
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
const RetainedPanelActiveContext = createContext(true);
|
||||
|
||||
export function useRetainedPanelActive(): boolean {
|
||||
return useContext(RetainedPanelActiveContext);
|
||||
}
|
||||
|
||||
interface RetainedPanelProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
interface RetainedPanelActivityProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RetainedPanelActivity({ active, children }: RetainedPanelActivityProps) {
|
||||
const parentActive = useRetainedPanelActive();
|
||||
return (
|
||||
<RetainedPanelActiveContext value={parentActive && active}>
|
||||
{children}
|
||||
</RetainedPanelActiveContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps expensive panel state mounted without letting an inactive panel render
|
||||
* on screen. The stable, non-collapsible native root is intentional: retained
|
||||
* panels must not detach or reparent their native descendants when visibility
|
||||
* changes.
|
||||
*/
|
||||
export const RetainedPanel = memo(function RetainedPanel({
|
||||
active,
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: RetainedPanelProps) {
|
||||
const visibleStyle = StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(styles.root, style);
|
||||
const panelStyle = active
|
||||
? visibleStyle
|
||||
: StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(visibleStyle, styles.hidden);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={active}>
|
||||
<View
|
||||
collapsable={false}
|
||||
pointerEvents={active ? "auto" : "none"}
|
||||
style={panelStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
hidden: {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
@@ -38,17 +38,13 @@ import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
Archive,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
@@ -66,9 +62,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
shouldShowSidebarHostLabels,
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -93,6 +87,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 +138,11 @@ const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
@@ -228,8 +220,9 @@ function selectionForSelectedWorkspace(
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
statusGroups: StatusGroup[];
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||
@@ -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,
|
||||
@@ -1723,6 +1613,7 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
interface WorkspaceRowItemProps {
|
||||
workspace: SidebarWorkspacePlacement;
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
subtitle?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -1738,6 +1629,7 @@ interface WorkspaceRowItemProps {
|
||||
|
||||
function WorkspaceRowItem({
|
||||
workspace,
|
||||
workspaceEntry,
|
||||
subtitle,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -1760,7 +1652,7 @@ function WorkspaceRowItem({
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntry}
|
||||
subtitle={subtitle}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -1798,6 +1690,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
});
|
||||
return (
|
||||
previous.workspace === next.workspace &&
|
||||
previous.workspaceEntry === next.workspaceEntry &&
|
||||
previous.subtitle === next.subtitle &&
|
||||
previous.shortcutNumber === next.shortcutNumber &&
|
||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||
@@ -1814,7 +1707,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
|
||||
|
||||
function WorkspaceRow({
|
||||
workspace,
|
||||
workspaceEntry,
|
||||
subtitle,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
@@ -1826,7 +1719,7 @@ function WorkspaceRow({
|
||||
isCreating = false,
|
||||
selected,
|
||||
}: {
|
||||
workspace: SidebarWorkspacePlacement;
|
||||
workspaceEntry: SidebarWorkspaceEntry | null;
|
||||
subtitle?: string | null;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
@@ -1838,15 +1731,13 @@ function WorkspaceRow({
|
||||
isCreating?: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
|
||||
if (!hydratedWorkspace) {
|
||||
if (!workspaceEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={hydratedWorkspace}
|
||||
workspace={workspaceEntry}
|
||||
subtitle={subtitle}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -1863,6 +1754,7 @@ function WorkspaceRow({
|
||||
|
||||
function ProjectBlock({
|
||||
project,
|
||||
workspaceEntriesByKey,
|
||||
collapsed,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
@@ -1885,6 +1777,7 @@ function ProjectBlock({
|
||||
supportsMultiplicityByServerId,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
collapsed: boolean;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
@@ -1934,6 +1827,7 @@ function ProjectBlock({
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={item}
|
||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||
subtitle={
|
||||
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
|
||||
}
|
||||
@@ -1960,6 +1854,7 @@ function ProjectBlock({
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2114,6 +2009,7 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
return (
|
||||
previous.project === next.project &&
|
||||
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
|
||||
previous.collapsed === next.collapsed &&
|
||||
previous.displayName === next.displayName &&
|
||||
previous.iconDataUri === next.iconDataUri &&
|
||||
@@ -2166,8 +2062,9 @@ function areProjectBlockSelectionsEqual(
|
||||
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||
|
||||
export function SidebarWorkspaceList({
|
||||
statusWorkspacePlacements,
|
||||
statusGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
@@ -2196,7 +2093,7 @@ export function SidebarWorkspaceList({
|
||||
const content =
|
||||
groupMode === "status" ? (
|
||||
<SidebarStatusModeWrapper
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
statusGroups={statusGroups}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -2206,6 +2103,7 @@ export function SidebarWorkspaceList({
|
||||
) : (
|
||||
<ProjectModeList
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
@@ -2224,14 +2122,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 +2140,7 @@ function SidebarStatusModeWrapper({
|
||||
|
||||
return (
|
||||
<SidebarStatusWorkspaceList
|
||||
workspaces={statusWorkspacePlacements}
|
||||
groups={statusGroups}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
@@ -2255,6 +2153,7 @@ function SidebarStatusModeWrapper({
|
||||
|
||||
function ProjectModeList({
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -2268,7 +2167,7 @@ function ProjectModeList({
|
||||
supportsMultiplicityByServerId,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
> & {
|
||||
pathname: string;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
@@ -2443,6 +2342,7 @@ function ProjectModeList({
|
||||
return (
|
||||
<MemoProjectBlock
|
||||
project={item}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
||||
displayName={item.projectName}
|
||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||
@@ -2481,6 +2381,7 @@ function ProjectModeList({
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
workspaceEntriesByKey,
|
||||
creatingWorkspaceIds,
|
||||
],
|
||||
);
|
||||
@@ -2872,14 +2773,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,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacesListResult,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
|
||||
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
buildStatusSidebarShortcutModel,
|
||||
type SidebarShortcutModel,
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
|
||||
interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
groupMode: SidebarGroupMode;
|
||||
statusGroups: StatusGroup[];
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
shortcutModel: SidebarShortcutModel;
|
||||
}
|
||||
|
||||
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
|
||||
export function SidebarModelProvider({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const list = useSidebarWorkspacesList();
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleProjectCollapsed,
|
||||
);
|
||||
const isStatusMode = groupMode === "status";
|
||||
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||
list.workspacePlacements,
|
||||
active !== false || isStatusMode,
|
||||
);
|
||||
const statusGroups = useMemo(
|
||||
() =>
|
||||
isStatusMode
|
||||
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
|
||||
: [],
|
||||
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
|
||||
);
|
||||
const shortcutModel = useMemo(() => {
|
||||
if (isStatusMode) {
|
||||
return buildStatusSidebarShortcutModel({
|
||||
groups: statusGroups,
|
||||
collapsedStatusGroupKeys,
|
||||
});
|
||||
}
|
||||
return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys });
|
||||
}, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]);
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...list,
|
||||
workspaceEntriesByKey,
|
||||
groupMode,
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
shortcutModel,
|
||||
}),
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
groupMode,
|
||||
list,
|
||||
shortcutModel,
|
||||
statusGroups,
|
||||
toggleProjectCollapsed,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSidebarModel(): SidebarModel {
|
||||
const model = useContext(SidebarModelContext);
|
||||
if (!model) throw new Error("SidebarModelProvider is required");
|
||||
return model;
|
||||
}
|
||||
@@ -4,16 +4,8 @@ import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } fr
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
buildStatusGroups,
|
||||
buildStatusShortcutIndex,
|
||||
type StatusGroup,
|
||||
} from "@/hooks/sidebar-status-view-model";
|
||||
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -25,18 +17,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 +29,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 +41,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 +58,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 +69,19 @@ interface StatusWorkspaceListProps {
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
workspaces,
|
||||
groups,
|
||||
projectNamesByKey,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const groups = useMemo(
|
||||
() => buildStatusGroups(workspaces, projectNamesByKey),
|
||||
[workspaces, projectNamesByKey],
|
||||
);
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
|
||||
const statusShortcutIndex = useMemo(
|
||||
() =>
|
||||
showShortcutBadges
|
||||
? buildStatusShortcutIndex(
|
||||
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
|
||||
)
|
||||
: new Map<string, number>(),
|
||||
[collapsedStatusGroupKeys, groups, showShortcutBadges],
|
||||
);
|
||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -325,13 +282,12 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
showShortcutBadge,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarStatusWorkspacePlacement;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
@@ -343,11 +299,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
if (!workspaceEntry) return null;
|
||||
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
workspace={workspaceEntry}
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -657,7 +611,7 @@ function StatusWorkspaceActionSlot({
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||
{onArchive ? (
|
||||
<StatusKebabMenu
|
||||
<SidebarWorkspaceMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -675,105 +629,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 +731,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -142,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
</Text>
|
||||
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>{children}</View>
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
|
||||
@@ -502,7 +502,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
|
||||
shortcutBadgeOverlay: {
|
||||
position: "absolute",
|
||||
top: 1,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Fragment,
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -32,6 +31,7 @@ import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -74,10 +74,6 @@ import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
// true = this tab slot is the active (visible) tab; false = mounted but hidden.
|
||||
// Defaults to true so consumers outside a slot (e.g. web preview) are unaffected.
|
||||
export const MountedTabActiveContext = createContext<boolean>(true);
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
workspaceKey: string;
|
||||
@@ -214,26 +210,20 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
const wrapperStyle = useMemo(() => {
|
||||
const display: "flex" | "none" = isVisible ? "flex" : "none";
|
||||
return { display, flex: 1 };
|
||||
}, [isVisible]);
|
||||
const handleFocusPane = useCallback(() => {
|
||||
onFocusPane(paneId);
|
||||
}, [onFocusPane, paneId]);
|
||||
|
||||
return (
|
||||
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<MountedTabActiveContext value={isVisible}>
|
||||
<View style={wrapperStyle}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
</MountedTabActiveContext>
|
||||
<RetainedPanel active={isVisible}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getSyncedLoaderDotOpacity, getSyncedLoaderStep } from "./synced-loader-state";
|
||||
|
||||
describe("synced loader state", () => {
|
||||
test("advances through six wall-clock-aligned steps every 950 milliseconds", () => {
|
||||
const sampleTimes = [0, 158, 159, 316, 317, 474, 475, 633, 634, 791, 792, 949, 950];
|
||||
|
||||
const steps = sampleTimes.map(getSyncedLoaderStep);
|
||||
|
||||
expect(steps).toEqual([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 0]);
|
||||
});
|
||||
|
||||
test("preserves the six visible snake states", () => {
|
||||
const states: number[][] = [];
|
||||
for (let step = 0; step < 6; step += 1) {
|
||||
const dotOpacities: number[] = [];
|
||||
for (let dot = 0; dot < 6; dot += 1) {
|
||||
dotOpacities.push(getSyncedLoaderDotOpacity(step, dot));
|
||||
}
|
||||
states.push(dotOpacities);
|
||||
}
|
||||
|
||||
expect(states).toEqual([
|
||||
[1, 0, 0.78, 0, 0.56, 0.34],
|
||||
[0.78, 1, 0.56, 0, 0.34, 0],
|
||||
[0.56, 0.78, 0.34, 1, 0, 0],
|
||||
[0.34, 0.56, 0, 0.78, 0, 1],
|
||||
[0, 0.34, 0, 0.56, 1, 0.78],
|
||||
[0, 0, 1, 0.34, 0.78, 0.56],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const SYNCED_LOADER_DURATION_MS = 950;
|
||||
export const SYNCED_LOADER_DOT_COUNT = 6;
|
||||
|
||||
const SYNCED_LOADER_OPACITY_STATES = [
|
||||
[1, 0, 0.78, 0, 0.56, 0.34],
|
||||
[0.78, 1, 0.56, 0, 0.34, 0],
|
||||
[0.56, 0.78, 0.34, 1, 0, 0],
|
||||
[0.34, 0.56, 0, 0.78, 0, 1],
|
||||
[0, 0.34, 0, 0.56, 1, 0.78],
|
||||
[0, 0, 1, 0.34, 0.78, 0.56],
|
||||
] as const;
|
||||
|
||||
export function getSyncedLoaderStep(nowMs: number): number {
|
||||
"worklet";
|
||||
const elapsedMs = nowMs % SYNCED_LOADER_DURATION_MS;
|
||||
return Math.floor((elapsedMs * SYNCED_LOADER_DOT_COUNT) / SYNCED_LOADER_DURATION_MS);
|
||||
}
|
||||
|
||||
export function getSyncedLoaderDotOpacity(step: number, dot: number): number {
|
||||
"worklet";
|
||||
return SYNCED_LOADER_OPACITY_STATES[step]?.[dot] ?? 0;
|
||||
}
|
||||
@@ -1,65 +1,101 @@
|
||||
import { useLayoutEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
makeMutable,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { scheduleOnUI } from "react-native-worklets";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
SYNCED_LOADER_DOT_COUNT,
|
||||
getSyncedLoaderDotOpacity,
|
||||
getSyncedLoaderStep,
|
||||
} from "@/components/synced-loader-state";
|
||||
|
||||
const SYNCED_LOADER_DURATION_MS = 950;
|
||||
const SYNCED_LOADER_EPOCH_MS = 0;
|
||||
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
|
||||
const DOT_COUNT = DOT_SEQUENCE.length;
|
||||
const GRID_COLUMNS = 2;
|
||||
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
|
||||
const DOT_KEYS = Array.from({ length: DOT_COUNT }, (_, i) => `dot-${i}`);
|
||||
const sharedStepProgress = makeMutable(0);
|
||||
let sharedLoopStarted = false;
|
||||
const DOT_KEYS = Array.from({ length: SYNCED_LOADER_DOT_COUNT }, (_, i) => `dot-${i}`);
|
||||
const sharedStep = makeMutable(0);
|
||||
const activeLoaderCount = makeMutable(0);
|
||||
const clockRunning = makeMutable(false);
|
||||
let nextStepListenerId = 1;
|
||||
|
||||
function ensureSharedStepLoopStarted(): void {
|
||||
if (sharedLoopStarted) {
|
||||
function advanceSharedStep(): void {
|
||||
"worklet";
|
||||
if (activeLoaderCount.value === 0) {
|
||||
clockRunning.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
sharedLoopStarted = true;
|
||||
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
|
||||
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
|
||||
sharedStepProgress.value = withTiming(
|
||||
DOT_COUNT,
|
||||
{
|
||||
duration: Math.max(1, Math.round(SYNCED_LOADER_DURATION_MS - elapsedMs)),
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (!finished) {
|
||||
sharedLoopStarted = false;
|
||||
return;
|
||||
}
|
||||
sharedStepProgress.value = 0;
|
||||
sharedStepProgress.value = withRepeat(
|
||||
withTiming(DOT_COUNT, {
|
||||
duration: SYNCED_LOADER_DURATION_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
const nextStep = getSyncedLoaderStep(Date.now());
|
||||
if (sharedStep.value !== nextStep) {
|
||||
sharedStep.value = nextStep;
|
||||
}
|
||||
requestAnimationFrame(advanceSharedStep);
|
||||
}
|
||||
|
||||
function registerStepListener(
|
||||
step: SharedValue<number>,
|
||||
registered: SharedValue<boolean>,
|
||||
listenerId: number,
|
||||
): void {
|
||||
"worklet";
|
||||
if (registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
step.value = getSyncedLoaderStep(Date.now());
|
||||
sharedStep.addListener(listenerId, (nextStep) => {
|
||||
step.value = nextStep;
|
||||
});
|
||||
activeLoaderCount.value += 1;
|
||||
|
||||
if (!clockRunning.value) {
|
||||
clockRunning.value = true;
|
||||
sharedStep.value = step.value;
|
||||
requestAnimationFrame(advanceSharedStep);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterStepListener(registered: SharedValue<boolean>, listenerId: number): void {
|
||||
"worklet";
|
||||
if (!registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = false;
|
||||
sharedStep.removeListener(listenerId);
|
||||
activeLoaderCount.value -= 1;
|
||||
}
|
||||
|
||||
function useSyncedLoaderStep(active: boolean, reduceMotion: boolean): SharedValue<number> {
|
||||
// The local value lets retained loaders detach from the app-wide clock without
|
||||
// unmounting their animated views or leaving hidden style worklets subscribed.
|
||||
const step = useSharedValue(reduceMotion ? 0 : getSyncedLoaderStep(Date.now()));
|
||||
const registered = useSharedValue(false);
|
||||
const [listenerId] = useState(() => nextStepListenerId++);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || reduceMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleOnUI(registerStepListener, step, registered, listenerId);
|
||||
return () => {
|
||||
scheduleOnUI(unregisterStepListener, registered, listenerId);
|
||||
};
|
||||
}, [active, listenerId, reduceMotion, registered, step]);
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
|
||||
useEffect(() => {
|
||||
ensureSharedStepLoopStarted();
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: 1,
|
||||
}));
|
||||
const active = useRetainedPanelActive();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const step = useSyncedLoaderStep(active, reduceMotion);
|
||||
|
||||
const gap = Math.max(1, Math.round(size * 0.12));
|
||||
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
|
||||
@@ -67,10 +103,9 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
||||
const gridHeight = dotSize * 3 + gap * 2;
|
||||
|
||||
const gridStyle = useMemo(
|
||||
() => [animatedStyle, { width: gridWidth, height: gridHeight }],
|
||||
[animatedStyle, gridWidth, gridHeight],
|
||||
() => ({ width: gridWidth, height: gridHeight }),
|
||||
[gridHeight, gridWidth],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
@@ -84,25 +119,24 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Animated.View style={gridStyle}>
|
||||
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
|
||||
<View style={gridStyle}>
|
||||
{Array.from({ length: SYNCED_LOADER_DOT_COUNT }).map((_, dotIndex) => {
|
||||
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
||||
const columnIndex = dotIndex % GRID_COLUMNS;
|
||||
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
|
||||
|
||||
return (
|
||||
<SpinnerDot
|
||||
key={DOT_KEYS[dotIndex]}
|
||||
color={color}
|
||||
dotSize={dotSize}
|
||||
sequenceIndex={sequenceIndex}
|
||||
progress={sharedStepProgress}
|
||||
dotIndex={dotIndex}
|
||||
step={step}
|
||||
left={columnIndex * (dotSize + gap)}
|
||||
top={rowIndex * (dotSize + gap)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -110,35 +144,21 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
|
||||
function SpinnerDot({
|
||||
color,
|
||||
dotSize,
|
||||
sequenceIndex,
|
||||
progress,
|
||||
dotIndex,
|
||||
step,
|
||||
left,
|
||||
top,
|
||||
}: {
|
||||
color: string;
|
||||
dotSize: number;
|
||||
sequenceIndex: number;
|
||||
progress: SharedValue<number>;
|
||||
dotIndex: number;
|
||||
step: SharedValue<number>;
|
||||
left: number;
|
||||
top: number;
|
||||
}) {
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const headIndex = Math.floor(progress.value) % DOT_COUNT;
|
||||
let opacity = 0;
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
|
||||
const activeSequenceIndex =
|
||||
(headIndex + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
|
||||
if (sequenceIndex === activeSequenceIndex) {
|
||||
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: getSyncedLoaderDotOpacity(step.value, dotIndex),
|
||||
}));
|
||||
|
||||
const dotStyle = useMemo(
|
||||
() => [
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
||||
|
||||
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
|
||||
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
||||
|
||||
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
|
||||
"needs_input",
|
||||
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
||||
export interface StatusGroup {
|
||||
bucket: StatusBucket;
|
||||
label: string;
|
||||
rows: SidebarStatusWorkspacePlacement[];
|
||||
rows: SidebarWorkspaceEntry[];
|
||||
}
|
||||
|
||||
export function buildStatusGroups(
|
||||
workspaces: SidebarStatusWorkspacePlacement[],
|
||||
workspaces: SidebarWorkspaceEntry[],
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): StatusGroup[] {
|
||||
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
|
||||
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
||||
|
||||
for (const ws of workspaces) {
|
||||
const bucket: StatusBucket = ws.statusBucket;
|
||||
@@ -54,8 +54,8 @@ export function buildStatusGroups(
|
||||
}
|
||||
|
||||
function compareStatusRows(
|
||||
a: SidebarStatusWorkspacePlacement,
|
||||
b: SidebarStatusWorkspacePlacement,
|
||||
a: SidebarWorkspaceEntry,
|
||||
b: SidebarWorkspaceEntry,
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): number {
|
||||
const aTime = a.statusEnteredAt?.getTime() ?? null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
buildSidebarWorkspaceEntries,
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
buildSidebarProjectsFromStructure,
|
||||
computeSidebarOrderUpdates,
|
||||
@@ -226,11 +226,12 @@ describe("shared sidebar workspace model", () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
const statusRows = buildSidebarStatusWorkspacePlacements({
|
||||
const workspaceEntries = buildSidebarWorkspaceEntries({
|
||||
placements: model.workspaces,
|
||||
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",
|
||||
@@ -288,14 +290,66 @@ describe("shared sidebar workspace model", () => {
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
|
||||
[
|
||||
["host-a:main", "done", "main"],
|
||||
["host-b:feature", "running", "feature/status-flow"],
|
||||
],
|
||||
);
|
||||
expect(
|
||||
Array.from(workspaceEntries.values()).map((entry) => [
|
||||
entry.workspaceKey,
|
||||
entry.statusBucket,
|
||||
entry.name,
|
||||
]),
|
||||
).toEqual([
|
||||
["host-a:main", "done", "main"],
|
||||
["host-b:feature", "running", "feature/status-flow"],
|
||||
]);
|
||||
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
|
||||
});
|
||||
|
||||
it("preserves unchanged row identities when another workspace updates", () => {
|
||||
const model = buildSidebarWorkspacePlacementModel({
|
||||
projects: [project({ projectKey: "project", workspaceKeys: ["srv:one", "srv:two"] })],
|
||||
});
|
||||
const one = workspace({
|
||||
id: "one",
|
||||
name: "one",
|
||||
projectId: "project",
|
||||
projectDisplayName: "project",
|
||||
});
|
||||
const two = workspace({
|
||||
id: "two",
|
||||
name: "two",
|
||||
projectId: "project",
|
||||
projectDisplayName: "project",
|
||||
});
|
||||
const previousEntries = buildSidebarWorkspaceEntries({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
serverId: "srv",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
["one", one],
|
||||
["two", two],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
const nextEntries = buildSidebarWorkspaceEntries({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
serverId: "srv",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
["one", one],
|
||||
["two", { ...two, status: "running" }],
|
||||
]),
|
||||
},
|
||||
],
|
||||
previousEntries,
|
||||
});
|
||||
|
||||
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
|
||||
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowSidebarHostLabels", () => {
|
||||
|
||||
@@ -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[] = [];
|
||||
@@ -63,10 +63,57 @@ export interface SidebarWorkspacePlacementModel {
|
||||
projectNamesByKey: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface SidebarStatusWorkspaceSession {
|
||||
export interface SidebarWorkspaceSession {
|
||||
serverId: string;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
agents?: Map<string, Agent>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceSessionSource {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
}
|
||||
|
||||
export function selectSidebarWorkspaceSessions(
|
||||
sessions: Record<string, SidebarWorkspaceSessionSource | undefined>,
|
||||
serverIds: readonly string[],
|
||||
): SidebarWorkspaceSession[] {
|
||||
const selected: SidebarWorkspaceSession[] = [];
|
||||
for (const serverId of serverIds) {
|
||||
const session = sessions[serverId];
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
selected.push({
|
||||
serverId,
|
||||
workspaces: session.workspaces,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function areSidebarWorkspaceSessionsEqual(
|
||||
left: readonly SidebarWorkspaceSession[],
|
||||
right: readonly SidebarWorkspaceSession[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftSession = left[index];
|
||||
const rightSession = right[index];
|
||||
if (
|
||||
!leftSession ||
|
||||
!rightSession ||
|
||||
leftSession.serverId !== rightSession.serverId ||
|
||||
leftSession.workspaces !== rightSession.workspaces ||
|
||||
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
@@ -74,8 +121,6 @@ interface EffectiveWorkspaceStatus {
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
|
||||
|
||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
||||
return (
|
||||
workspace.projectCustomName ??
|
||||
@@ -96,7 +141,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 +174,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 +189,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 +215,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 {
|
||||
@@ -272,17 +292,18 @@ function resolveStructuralWorkspaceIdentity(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarStatusWorkspacePlacements(input: {
|
||||
export function buildSidebarWorkspaceEntries(input: {
|
||||
placements: readonly SidebarWorkspacePlacement[];
|
||||
sessions: SidebarStatusWorkspaceSession[];
|
||||
sessions: SidebarWorkspaceSession[];
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
}): SidebarStatusWorkspacePlacement[] {
|
||||
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
}): Map<string, SidebarWorkspaceEntry> {
|
||||
if (input.placements.length === 0 || input.sessions.length === 0) {
|
||||
return [];
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
|
||||
const rows: SidebarStatusWorkspacePlacement[] = [];
|
||||
const entries = new Map<string, SidebarWorkspaceEntry>();
|
||||
|
||||
for (const placement of input.placements) {
|
||||
const session = sessionByServerId.get(placement.serverId);
|
||||
@@ -294,24 +315,46 @@ export function buildSidebarStatusWorkspacePlacements(input: {
|
||||
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
|
||||
if (!workspace) continue;
|
||||
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus({
|
||||
const entry = createSidebarWorkspaceEntry({
|
||||
serverId: placement.serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
agents: session.agents,
|
||||
});
|
||||
|
||||
rows.push({
|
||||
...placement,
|
||||
name: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
statusBucket: effectiveStatus.status,
|
||||
statusEnteredAt: effectiveStatus.enteredAt,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
|
||||
entries.set(
|
||||
placement.workspaceKey,
|
||||
previousEntry && areSidebarWorkspaceEntriesEqual(previousEntry, entry)
|
||||
? previousEntry
|
||||
: entry,
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
return entries;
|
||||
}
|
||||
|
||||
function areSidebarWorkspaceEntriesEqual(
|
||||
left: SidebarWorkspaceEntry,
|
||||
right: SidebarWorkspaceEntry,
|
||||
): boolean {
|
||||
const keys = Object.keys(left) as Array<keyof SidebarWorkspaceEntry>;
|
||||
if (keys.length !== Object.keys(right).length) return false;
|
||||
return keys.every((key) => {
|
||||
if (key !== "prHint") return Object.is(left[key], right[key]);
|
||||
const leftHint = left.prHint;
|
||||
const rightHint = right.prHint;
|
||||
return (
|
||||
leftHint === rightHint ||
|
||||
(leftHint !== null &&
|
||||
rightHint !== null &&
|
||||
leftHint.url === rightHint.url &&
|
||||
leftHint.number === rightHint.number &&
|
||||
leftHint.state === rightHint.state &&
|
||||
leftHint.checks === rightHint.checks &&
|
||||
leftHint.checksStatus === rightHint.checksStatus &&
|
||||
leftHint.reviewDecision === rightHint.reviewDecision)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromStructure(input: {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||
import {
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
selectSidebarWorkspaceSessions,
|
||||
type SidebarWorkspaceSession,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function activityMap(): Map<string, WorkspaceAgentActivity> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function sidebarSession(input?: Partial<Omit<SidebarWorkspaceSession, "serverId">>) {
|
||||
return {
|
||||
workspaces: input?.workspaces ?? workspaceMap(),
|
||||
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar workspace session selection", () => {
|
||||
it("selects only sessions needed by sidebar placements", () => {
|
||||
const hostA = sidebarSession();
|
||||
const hostB = sidebarSession();
|
||||
const unusedHost = sidebarSession();
|
||||
|
||||
expect(
|
||||
selectSidebarWorkspaceSessions(
|
||||
{
|
||||
"host-a": hostA,
|
||||
"host-b": hostB,
|
||||
unused: unusedHost,
|
||||
},
|
||||
["host-b", "missing", "host-a"],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
serverId: "host-b",
|
||||
workspaces: hostB.workspaces,
|
||||
workspaceAgentActivity: hostB.workspaceAgentActivity,
|
||||
},
|
||||
{
|
||||
serverId: "host-a",
|
||||
workspaces: hostA.workspaces,
|
||||
workspaceAgentActivity: hostA.workspaceAgentActivity,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores high-frequency session changes outside the sidebar indexes", () => {
|
||||
const workspaces = workspaceMap();
|
||||
const workspaceAgentActivity = activityMap();
|
||||
|
||||
const previous = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(previous).not.toBe(next);
|
||||
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects changes to a selected workspace or activity index", () => {
|
||||
const workspaceAgentActivity = activityMap();
|
||||
const previous = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectSidebarWorkspaceSessions(
|
||||
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
buildSidebarWorkspaceEntries,
|
||||
selectSidebarWorkspaceSessions,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacePlacement,
|
||||
type SidebarWorkspaceSession,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
const EMPTY_ENTRIES = new Map<string, SidebarWorkspaceEntry>();
|
||||
const EMPTY_SESSIONS: SidebarWorkspaceSession[] = [];
|
||||
const EMPTY_PENDING_CREATE_ATTEMPTS: Record<string, never> = {};
|
||||
|
||||
export function useSidebarWorkspaceEntries(
|
||||
placements: readonly SidebarWorkspacePlacement[],
|
||||
enabled = true,
|
||||
): ReadonlyMap<string, SidebarWorkspaceEntry> {
|
||||
const serverIds = useMemo(
|
||||
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
|
||||
[placements],
|
||||
);
|
||||
const sessions = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
enabled ? selectSidebarWorkspaceSessions(state.sessions, serverIds) : EMPTY_SESSIONS,
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
);
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
||||
);
|
||||
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
|
||||
|
||||
// Collection ownership is intentional: retained sidebars have one cheap
|
||||
// subscription to structurally shared indexes, never one session-store
|
||||
// subscription per mounted row.
|
||||
return useMemo(() => {
|
||||
if (!enabled || placements.length === 0 || sessions.length === 0) {
|
||||
previousEntriesRef.current = EMPTY_ENTRIES;
|
||||
return EMPTY_ENTRIES;
|
||||
}
|
||||
const entries = buildSidebarWorkspaceEntries({
|
||||
placements,
|
||||
sessions,
|
||||
pendingCreateAttempts,
|
||||
previousEntries: previousEntriesRef.current,
|
||||
});
|
||||
previousEntriesRef.current = entries;
|
||||
return entries;
|
||||
}, [enabled, pendingCreateAttempts, placements, sessions]);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -14,7 +11,6 @@ import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-works
|
||||
import {
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveSidebarLoadingState,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
@@ -26,10 +22,9 @@ export {
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromHostProjects,
|
||||
buildSidebarProjectsFromStructure,
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
createSidebarWorkspaceEntry,
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveSidebarLoadingState,
|
||||
shouldShowSidebarHostLabels,
|
||||
type SidebarLoadingState,
|
||||
@@ -42,37 +37,6 @@ export {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
export function useSidebarWorkspaceEntry(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): SidebarWorkspaceEntry | null {
|
||||
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
|
||||
const pendingCreateAttempts = useStoreWithEqualityFn(
|
||||
useCreateFlowStore,
|
||||
(state) => state.pendingByDraftId,
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
|
||||
// Single subscription: reads workspace + agents together, computes the full entry, and
|
||||
// deep-compares the output. Agents-Map identity churn (setAgents replaces the Map on every
|
||||
// status transition) never causes a React re-render unless the derived entry actually changes.
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
const workspace = selectWorkspace(state, serverId, workspaceId);
|
||||
if (!workspace) return null;
|
||||
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
|
||||
return createSidebarWorkspaceEntry({
|
||||
serverId: serverId ?? "",
|
||||
workspace,
|
||||
pendingCreateAttempts,
|
||||
agents,
|
||||
});
|
||||
},
|
||||
equal,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
areStatusModeSessionsEqual,
|
||||
selectStatusModeSessions,
|
||||
type StatusModeSession,
|
||||
} from "./use-status-mode-workspaces";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function agentMap(): Map<string, Agent> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
|
||||
return {
|
||||
workspaces: input?.workspaces ?? workspaceMap(),
|
||||
agents: input?.agents ?? agentMap(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("status mode session selection", () => {
|
||||
it("selects only sessions needed by visible placements", () => {
|
||||
const hostA = statusSession();
|
||||
const hostB = statusSession();
|
||||
const unusedHost = statusSession();
|
||||
|
||||
expect(
|
||||
selectStatusModeSessions(
|
||||
{
|
||||
"host-a": hostA,
|
||||
"host-b": hostB,
|
||||
unused: unusedHost,
|
||||
},
|
||||
["host-b", "missing", "host-a"],
|
||||
),
|
||||
).toEqual([
|
||||
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
|
||||
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps selector output equal when only wrapper objects change", () => {
|
||||
const workspaces = workspaceMap();
|
||||
const agents = agentMap();
|
||||
|
||||
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
||||
"host-a",
|
||||
]);
|
||||
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
||||
"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();
|
||||
const previous = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import {
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
|
||||
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
|
||||
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
|
||||
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
|
||||
typeof useCreateFlowStore.getState
|
||||
>["pendingByDraftId"] = {};
|
||||
|
||||
interface StatusModeSessionSource {
|
||||
workspaces: SessionState["workspaces"];
|
||||
agents: SessionState["agents"];
|
||||
}
|
||||
|
||||
export interface StatusModeSession {
|
||||
serverId: string;
|
||||
workspaces: SessionState["workspaces"];
|
||||
agents: SessionState["agents"];
|
||||
}
|
||||
|
||||
export function selectStatusModeSessions(
|
||||
sessions: Record<string, StatusModeSessionSource | undefined>,
|
||||
serverIds: readonly string[],
|
||||
): StatusModeSession[] {
|
||||
const statusSessions: StatusModeSession[] = [];
|
||||
for (const serverId of serverIds) {
|
||||
const session = sessions[serverId];
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
statusSessions.push({
|
||||
serverId,
|
||||
workspaces: session.workspaces,
|
||||
agents: session.agents,
|
||||
});
|
||||
}
|
||||
return statusSessions;
|
||||
}
|
||||
|
||||
export function areStatusModeSessionsEqual(
|
||||
left: readonly StatusModeSession[],
|
||||
right: readonly StatusModeSession[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftSession = left[index];
|
||||
const rightSession = right[index];
|
||||
if (
|
||||
!leftSession ||
|
||||
!rightSession ||
|
||||
leftSession.serverId !== rightSession.serverId ||
|
||||
leftSession.workspaces !== rightSession.workspaces ||
|
||||
leftSession.agents !== rightSession.agents
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useStatusModeWorkspacePlacements(input: {
|
||||
placements: SidebarWorkspacePlacement[];
|
||||
enabled?: boolean;
|
||||
}): SidebarStatusWorkspacePlacement[] {
|
||||
const isEnabled = input.enabled !== false && input.placements.length > 0;
|
||||
const serverIds = useMemo(
|
||||
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
|
||||
[input.placements],
|
||||
);
|
||||
const statusSessions = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
|
||||
areStatusModeSessionsEqual,
|
||||
);
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isEnabled) {
|
||||
return EMPTY_WORKSPACES;
|
||||
}
|
||||
|
||||
return buildSidebarStatusWorkspacePlacements({
|
||||
placements: input.placements,
|
||||
sessions: statusSessions,
|
||||
pendingCreateAttempts,
|
||||
});
|
||||
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1093,7 +1093,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "اكتب مسار الدليل...",
|
||||
placeholder: "اكتب للبحث...",
|
||||
browse: "استعراض…",
|
||||
opening: "افتتاح المشروع...",
|
||||
searching: "جارٍ البحث...",
|
||||
empty: "ابدأ بكتابة المسار",
|
||||
|
||||
@@ -1100,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",
|
||||
|
||||
@@ -1129,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",
|
||||
|
||||
@@ -1131,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",
|
||||
|
||||
@@ -1107,7 +1107,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "ディレクトリパスを入力...",
|
||||
placeholder: "入力して検索...",
|
||||
browse: "参照…",
|
||||
opening: "プロジェクトを開いています...",
|
||||
searching: "検索中...",
|
||||
empty: "パスを入力してください",
|
||||
|
||||
@@ -1115,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",
|
||||
|
||||
@@ -1119,7 +1119,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Введите путь к каталогу...",
|
||||
placeholder: "Введите текст для поиска...",
|
||||
browse: "Обзор…",
|
||||
opening: "Открытие проекта...",
|
||||
searching: "Идет поиск...",
|
||||
empty: "Начните вводить путь",
|
||||
|
||||
@@ -1078,7 +1078,8 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "输入目录路径...",
|
||||
placeholder: "输入以搜索...",
|
||||
browse: "浏览…",
|
||||
opening: "正在打开 project...",
|
||||
searching: "正在搜索...",
|
||||
empty: "开始输入路径",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
pruneMountedWorkspaceSelections,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
@@ -14,6 +15,17 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
|
||||
}
|
||||
|
||||
describe("pruneMountedWorkspaceSelections", () => {
|
||||
it("retains the deck while an app-wide route temporarily clears the active workspace", () => {
|
||||
const mountedSelections = [workspace("A"), workspace("B")];
|
||||
|
||||
expect(
|
||||
pruneMountedWorkspaceSelections({
|
||||
currentSelections: mountedSelections,
|
||||
activeSelection: null,
|
||||
}),
|
||||
).toBe(mountedSelections);
|
||||
});
|
||||
|
||||
it("keeps the active workspace and the two most recent inactive workspaces", () => {
|
||||
const mountedAfterA = pruneMountedWorkspaceSelections({
|
||||
currentSelections: [],
|
||||
@@ -64,6 +76,22 @@ describe("pruneMountedWorkspaceSelections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderWorkspaceSelectionsForStableRender", () => {
|
||||
it("does not move retained native roots when the active LRU order changes", () => {
|
||||
const activeA = [workspace("A"), workspace("B")];
|
||||
const activeB = [workspace("B"), workspace("A")];
|
||||
|
||||
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeA))).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeB))).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldKeepWorkspaceDeckEntryMounted", () => {
|
||||
it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => {
|
||||
expect(
|
||||
|
||||
@@ -43,7 +43,7 @@ export function pruneMountedWorkspaceSelections({
|
||||
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
|
||||
if (!activeSelection) {
|
||||
return [];
|
||||
return currentSelections;
|
||||
}
|
||||
|
||||
const maxSelections = Math.max(1, maxMountedWorkspaces);
|
||||
@@ -74,6 +74,14 @@ export function pruneMountedWorkspaceSelections({
|
||||
return nextSelections;
|
||||
}
|
||||
|
||||
export function orderWorkspaceSelectionsForStableRender(
|
||||
selections: ActiveWorkspaceSelection[],
|
||||
): ActiveWorkspaceSelection[] {
|
||||
return [...selections].sort((left, right) =>
|
||||
getWorkspaceSelectionKey(left).localeCompare(getWorkspaceSelectionKey(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldKeepWorkspaceDeckEntryMounted({
|
||||
isActive,
|
||||
hasHydratedWorkspaces,
|
||||
|
||||
@@ -59,7 +59,8 @@ import {
|
||||
FloatingPanelPortalHostNameProvider,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { MountedTabActiveContext, SplitContainer } from "@/components/split-container";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
@@ -847,21 +848,15 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
const slotStyle = isVisible
|
||||
? styles.mobileMountedTabSlotVisible
|
||||
: styles.mobileMountedTabSlotHidden;
|
||||
|
||||
return (
|
||||
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<MountedTabActiveContext value={isVisible}>
|
||||
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
</MountedTabActiveContext>
|
||||
<RetainedPanel active={isVisible} style={styles.mobileMountedTabSlot}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
@@ -3965,13 +3960,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
position: "relative",
|
||||
},
|
||||
mobileMountedTabSlotVisible: {
|
||||
mobileMountedTabSlot: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: 1,
|
||||
},
|
||||
mobileMountedTabSlotHidden: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: 0,
|
||||
},
|
||||
contentPlaceholder: {
|
||||
flex: 1,
|
||||
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
migratePanelState,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
setMobilePanelTarget,
|
||||
selectPanelVisibility,
|
||||
type DesktopSidebarState,
|
||||
type ExplorerPanelIntent,
|
||||
type MobilePanelView,
|
||||
type MobilePanelSelection,
|
||||
type PanelLayoutInput,
|
||||
type PanelVisibilityState,
|
||||
type SortOption,
|
||||
@@ -41,6 +43,7 @@ export type {
|
||||
DesktopSidebarState,
|
||||
ExplorerPanelIntent,
|
||||
MobilePanelView,
|
||||
MobilePanelSelection,
|
||||
PanelLayoutInput,
|
||||
PanelVisibilityState,
|
||||
SortOption,
|
||||
@@ -61,8 +64,8 @@ export {
|
||||
};
|
||||
|
||||
export interface PanelState {
|
||||
// Mobile: which panel is currently shown
|
||||
mobileView: MobilePanelView;
|
||||
// Mobile: React's durable target plus the generation that owns it.
|
||||
mobilePanel: MobilePanelSelection;
|
||||
|
||||
// Desktop: independent sidebar toggles
|
||||
desktop: DesktopSidebarState;
|
||||
@@ -114,11 +117,19 @@ export interface PanelState {
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = isWeb;
|
||||
|
||||
function setMobilePanelTargetPatch(
|
||||
state: PanelState,
|
||||
target: MobilePanelView,
|
||||
): PanelState | Pick<PanelState, "mobilePanel"> {
|
||||
const mobilePanel = setMobilePanelTarget(state.mobilePanel, target);
|
||||
return mobilePanel === state.mobilePanel ? state : { mobilePanel };
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Mobile always starts at agent view
|
||||
mobileView: "agent",
|
||||
mobilePanel: { target: "agent", revision: 0 },
|
||||
|
||||
// Desktop defaults based on platform
|
||||
desktop: {
|
||||
@@ -144,26 +155,17 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
|
||||
})),
|
||||
|
||||
showMobileAgent: () =>
|
||||
set((state) => {
|
||||
if (state.mobileView === "agent") {
|
||||
return state;
|
||||
}
|
||||
return { mobileView: "agent" as const };
|
||||
}),
|
||||
showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")),
|
||||
|
||||
showMobileAgentList: () =>
|
||||
set((state) => {
|
||||
if (state.mobileView === "agent-list") {
|
||||
return state;
|
||||
}
|
||||
return { mobileView: "agent-list" as const };
|
||||
}),
|
||||
showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")),
|
||||
|
||||
toggleMobileAgentList: () =>
|
||||
set((state) => ({
|
||||
mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list",
|
||||
})),
|
||||
set((state) =>
|
||||
setMobilePanelTargetPatch(
|
||||
state,
|
||||
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
|
||||
),
|
||||
),
|
||||
|
||||
openDesktopAgentList: () =>
|
||||
set((state) => {
|
||||
@@ -197,9 +199,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
openAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return state.mobileView === "agent-list"
|
||||
? state
|
||||
: { mobileView: "agent-list" as const };
|
||||
return setMobilePanelTargetPatch(state, "agent-list");
|
||||
}
|
||||
return state.desktop.agentListOpen
|
||||
? state
|
||||
@@ -209,7 +209,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
closeAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return state.mobileView === "agent" ? state : { mobileView: "agent" as const };
|
||||
return setMobilePanelTargetPatch(state, "agent");
|
||||
}
|
||||
return state.desktop.agentListOpen
|
||||
? { desktop: { ...state.desktop, agentListOpen: false } }
|
||||
@@ -219,7 +219,10 @@ export const usePanelStore = create<PanelState>()(
|
||||
toggleAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" };
|
||||
return setMobilePanelTargetPatch(
|
||||
state,
|
||||
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
|
||||
);
|
||||
}
|
||||
return {
|
||||
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
|
||||
@@ -295,7 +298,6 @@ export const usePanelStore = create<PanelState>()(
|
||||
migrate: (persistedState, version) =>
|
||||
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
|
||||
partialize: (state) => ({
|
||||
mobileView: state.mobileView,
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
@@ -315,7 +317,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
/**
|
||||
* Hook that provides platform-aware panel state.
|
||||
*
|
||||
* On mobile, uses the state machine (mobileView).
|
||||
* On mobile, uses the revisioned mobile panel target.
|
||||
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
|
||||
*
|
||||
* @param isMobile - Whether the current breakpoint is mobile
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
migratePanelState,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
setMobilePanelTarget,
|
||||
selectPanelVisibility,
|
||||
type PanelCoreState,
|
||||
} from "./state";
|
||||
|
||||
function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState {
|
||||
return {
|
||||
mobileView: "agent",
|
||||
mobilePanel: { target: "agent", revision: 0 },
|
||||
desktop: {
|
||||
agentListOpen: false,
|
||||
fileExplorerOpen: false,
|
||||
@@ -117,12 +118,33 @@ describe("panel-store migration", () => {
|
||||
|
||||
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
|
||||
});
|
||||
|
||||
it("drops persisted compact panel state so cold starts return to content", () => {
|
||||
const state = migratePanelState(
|
||||
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
|
||||
11,
|
||||
{ isWeb: false },
|
||||
);
|
||||
|
||||
expect(state.mobileView).toBeUndefined();
|
||||
expect(state.mobilePanel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel-store visibility selectors", () => {
|
||||
it("uses mobileView for compact layout visibility", () => {
|
||||
it("increments the mobile panel revision only when the target changes", () => {
|
||||
const initial = { target: "agent" as const, revision: 4 };
|
||||
|
||||
expect(setMobilePanelTarget(initial, "agent")).toBe(initial);
|
||||
expect(setMobilePanelTarget(initial, "agent-list")).toEqual({
|
||||
target: "agent-list",
|
||||
revision: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the mobile panel target for compact layout visibility", () => {
|
||||
const state = makePanelState({
|
||||
mobileView: "file-explorer",
|
||||
mobilePanel: { target: "file-explorer", revision: 1 },
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
|
||||
});
|
||||
|
||||
@@ -136,7 +158,7 @@ describe("panel-store visibility selectors", () => {
|
||||
|
||||
it("uses desktop flags for expanded layout visibility", () => {
|
||||
const state = makePanelState({
|
||||
mobileView: "file-explorer",
|
||||
mobilePanel: { target: "file-explorer", revision: 1 },
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
|
||||
});
|
||||
|
||||
@@ -160,7 +182,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout });
|
||||
|
||||
expect(patch.mobileView).toBe("file-explorer");
|
||||
expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
|
||||
expect(patch.desktop).toBeUndefined();
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
});
|
||||
@@ -175,7 +197,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
|
||||
|
||||
expect(patch.mobileView).toBeUndefined();
|
||||
expect(patch.mobilePanel).toBeUndefined();
|
||||
expect(patch.desktop?.fileExplorerOpen).toBe(true);
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
@@ -8,6 +8,11 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
|
||||
|
||||
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
|
||||
|
||||
export interface MobilePanelSelection {
|
||||
target: MobilePanelView;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface DesktopSidebarState {
|
||||
agentListOpen: boolean;
|
||||
fileExplorerOpen: boolean;
|
||||
@@ -43,7 +48,7 @@ export interface ExplorerPanelIntent extends PanelLayoutInput {
|
||||
}
|
||||
|
||||
export interface PanelCoreState {
|
||||
mobileView: MobilePanelView;
|
||||
mobilePanel: MobilePanelSelection;
|
||||
desktop: DesktopSidebarState;
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
@@ -74,8 +79,8 @@ export function selectPanelVisibility(
|
||||
): PanelVisibilityState {
|
||||
if (input.isCompact) {
|
||||
return {
|
||||
isAgentListOpen: state.mobileView === "agent-list",
|
||||
isFileExplorerOpen: state.mobileView === "file-explorer",
|
||||
isAgentListOpen: state.mobilePanel.target === "agent-list",
|
||||
isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -92,6 +97,16 @@ export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayo
|
||||
return selectPanelVisibility(state, input).isFileExplorerOpen;
|
||||
}
|
||||
|
||||
export function setMobilePanelTarget(
|
||||
selection: MobilePanelSelection,
|
||||
target: MobilePanelView,
|
||||
): MobilePanelSelection {
|
||||
if (selection.target === target) {
|
||||
return selection;
|
||||
}
|
||||
return { target, revision: selection.revision + 1 };
|
||||
}
|
||||
|
||||
function resolveExplorerTabFromCheckout(
|
||||
state: PanelCoreState,
|
||||
checkout: ExplorerCheckoutContext,
|
||||
@@ -105,7 +120,7 @@ function resolveExplorerTabFromCheckout(
|
||||
}
|
||||
|
||||
export interface OpenFileExplorerPatch {
|
||||
mobileView?: MobilePanelView;
|
||||
mobilePanel?: MobilePanelSelection;
|
||||
desktop?: DesktopSidebarState;
|
||||
explorerTab: ExplorerTab;
|
||||
}
|
||||
@@ -117,7 +132,7 @@ export function buildOpenFileExplorerPatch(
|
||||
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
|
||||
if (input.isCompact) {
|
||||
return {
|
||||
mobileView: "file-explorer",
|
||||
mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
|
||||
explorerTab: resolvedTab,
|
||||
};
|
||||
}
|
||||
@@ -129,7 +144,7 @@ export function buildOpenFileExplorerPatch(
|
||||
|
||||
export type ToggleFileExplorerPatch =
|
||||
| OpenFileExplorerPatch
|
||||
| { mobileView: MobilePanelView }
|
||||
| { mobilePanel: MobilePanelSelection }
|
||||
| { desktop: DesktopSidebarState };
|
||||
|
||||
export function buildToggleFileExplorerPatch(
|
||||
@@ -141,7 +156,7 @@ export function buildToggleFileExplorerPatch(
|
||||
return buildOpenFileExplorerPatch(state, input);
|
||||
}
|
||||
if (input.isCompact) {
|
||||
return { mobileView: "agent" };
|
||||
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
|
||||
}
|
||||
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||
}
|
||||
@@ -255,6 +270,12 @@ export function migratePanelState(
|
||||
if (typeof state.explorerShowHiddenFiles !== "boolean") {
|
||||
state.explorerShowHiddenFiles = true;
|
||||
}
|
||||
if (version < 12) {
|
||||
// Compact panel position is transient UI state. Cold starts always begin
|
||||
// at content, regardless of what an older version persisted.
|
||||
delete state.mobileView;
|
||||
delete state.mobilePanel;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
createAgentLastActivityCoalescer,
|
||||
type AgentLastActivityCommitter,
|
||||
} from "@/runtime/activity";
|
||||
import {
|
||||
buildWorkspaceAgentActivityIndex,
|
||||
type WorkspaceAgentActivity,
|
||||
} from "@/utils/workspace-agent-activity";
|
||||
|
||||
// Re-export types that were in session-context
|
||||
export type MessageEntry =
|
||||
@@ -353,6 +357,7 @@ export interface SessionState {
|
||||
|
||||
// Agents
|
||||
agents: Map<string, Agent>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
agentDetails: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
@@ -560,6 +565,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agentAuthoritativeHistoryApplied: new Map(),
|
||||
initializingAgents: new Map(),
|
||||
agents: new Map(),
|
||||
workspaceAgentActivity: new Map(),
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
@@ -1194,7 +1200,14 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agents: nextAgents },
|
||||
[serverId]: {
|
||||
...session,
|
||||
agents: nextAgents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
|
||||
nextAgents,
|
||||
session.workspaceAgentActivity,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -376,6 +376,142 @@ describe("stream reducer canonical tool calls", () => {
|
||||
assert.strictEqual(first.messageId, "msg-same");
|
||||
});
|
||||
|
||||
it("keeps row identities unique when an assistant message resumes after a tool", () => {
|
||||
const messageId = "msg-resumed";
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: assistantTimeline("Before the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:00Z"),
|
||||
},
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId: "tool-between-assistant-segments",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:02:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("After the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:02Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = state.filter(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
item.kind === "assistant_message",
|
||||
);
|
||||
expect(messages.map((message) => message.text)).toEqual([
|
||||
"Before the tool.",
|
||||
"After the tool.",
|
||||
]);
|
||||
expect(messages.map((message) => message.messageId)).toEqual([messageId, messageId]);
|
||||
expect(new Set(messages.map((message) => message.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps resumed live assistant rows when the turn completes", () => {
|
||||
const messageId = "msg-live-resumed";
|
||||
let tail: StreamItem[] = [];
|
||||
let head: StreamItem[] = [];
|
||||
|
||||
for (const update of [
|
||||
{
|
||||
event: assistantTimeline("Before the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:00Z"),
|
||||
},
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
callId: "live-tool-between-assistant-segments",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:02:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("After the tool.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:02Z"),
|
||||
},
|
||||
{
|
||||
event: { type: "turn_completed" as const, provider: "codex" as const },
|
||||
timestamp: new Date("2025-01-01T10:02:03Z"),
|
||||
},
|
||||
]) {
|
||||
const result = applyStreamEvent({
|
||||
tail,
|
||||
head,
|
||||
event: update.event,
|
||||
timestamp: update.timestamp,
|
||||
});
|
||||
tail = result.tail;
|
||||
head = result.head;
|
||||
}
|
||||
|
||||
const messages = tail.filter(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
item.kind === "assistant_message",
|
||||
);
|
||||
expect(head).toEqual([]);
|
||||
expect(messages.map((message) => message.text)).toEqual([
|
||||
"Before the tool.",
|
||||
"After the tool.",
|
||||
]);
|
||||
expect(messages.map((message) => message.messageId)).toEqual([messageId, messageId]);
|
||||
expect(new Set(messages.map((message) => message.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps every promoted block when an assistant message resumes after a tool", () => {
|
||||
const messageId = "msg-promoted-resume";
|
||||
let tail: StreamItem[] = [];
|
||||
let head: StreamItem[] = [];
|
||||
|
||||
for (const update of [
|
||||
{
|
||||
event: assistantTimeline("Before one.\n\nBefore two.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:00Z"),
|
||||
},
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "codex" as const,
|
||||
callId: "tool-between-promoted-segments",
|
||||
name: "shell",
|
||||
status: "completed" as const,
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:02:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("After one.\n\nAfter two.", "codex", messageId),
|
||||
timestamp: new Date("2025-01-01T10:02:02Z"),
|
||||
},
|
||||
{
|
||||
event: { type: "turn_completed" as const, provider: "codex" as const },
|
||||
timestamp: new Date("2025-01-01T10:02:03Z"),
|
||||
},
|
||||
]) {
|
||||
const result = applyStreamEvent({
|
||||
tail,
|
||||
head,
|
||||
event: update.event,
|
||||
timestamp: update.timestamp,
|
||||
});
|
||||
tail = result.tail;
|
||||
head = result.head;
|
||||
}
|
||||
|
||||
const messages = tail.filter(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
item.kind === "assistant_message",
|
||||
);
|
||||
expect(messages.map((message) => message.text)).toEqual([
|
||||
"Before one.",
|
||||
"Before two.",
|
||||
"After one.",
|
||||
"After two.",
|
||||
]);
|
||||
expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length);
|
||||
});
|
||||
|
||||
it("preserves old assistant merge behavior when message ids are absent", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
|
||||
@@ -43,6 +43,35 @@ function createUniqueTimelineId(
|
||||
return `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
function createAssistantItemId(
|
||||
state: StreamItem[],
|
||||
messageId: string | undefined,
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
): string {
|
||||
if (!messageId) {
|
||||
return createUniqueTimelineId(state, "assistant", text, timestamp);
|
||||
}
|
||||
|
||||
const isOccupied = (id: string) =>
|
||||
reservedItemIds?.has(id) === true || state.some((item) => item.id === id);
|
||||
if (!isOccupied(messageId)) {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
const segmentId = `${messageId}:segment:${timestamp.getTime().toString(36)}`;
|
||||
if (!isOccupied(segmentId)) {
|
||||
return segmentId;
|
||||
}
|
||||
|
||||
let suffix = 1;
|
||||
while (isOccupied(`${segmentId}:${suffix.toString(36)}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
return `${segmentId}:${suffix.toString(36)}`;
|
||||
}
|
||||
|
||||
export type StreamItem =
|
||||
| UserMessageItem
|
||||
| AssistantMessageItem
|
||||
@@ -170,6 +199,11 @@ export interface TodoListItem {
|
||||
|
||||
export type StreamUpdateSource = "live" | "canonical";
|
||||
|
||||
interface StreamUpdateOptions {
|
||||
source?: StreamUpdateSource;
|
||||
reservedItemIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -320,6 +354,7 @@ function appendAssistantMessage(
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
messageId?: string,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
@@ -362,7 +397,7 @@ function appendAssistantMessage(
|
||||
}
|
||||
|
||||
const idSeed = chunk.trim() || chunk;
|
||||
const entryId = messageId ?? createUniqueTimelineId(state, "assistant", idSeed, timestamp);
|
||||
const entryId = createAssistantItemId(state, messageId, idSeed, timestamp, reservedItemIds);
|
||||
const item: AssistantMessageItem = {
|
||||
kind: "assistant_message",
|
||||
id: entryId,
|
||||
@@ -751,6 +786,7 @@ function reduceTimelineEvent(
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
): StreamItem[] {
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
@@ -758,7 +794,14 @@ function reduceTimelineEvent(
|
||||
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
|
||||
case "assistant_message":
|
||||
return finalizeActiveThoughts(
|
||||
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
|
||||
appendAssistantMessage(
|
||||
state,
|
||||
item.text,
|
||||
timestamp,
|
||||
source,
|
||||
item.messageId,
|
||||
reservedItemIds,
|
||||
),
|
||||
);
|
||||
case "reasoning":
|
||||
return appendThought(state, item.text, timestamp);
|
||||
@@ -798,12 +841,12 @@ export function reduceStreamUpdate(
|
||||
state: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
timestamp: Date,
|
||||
options?: { source?: StreamUpdateSource },
|
||||
options?: StreamUpdateOptions,
|
||||
): StreamItem[] {
|
||||
const source = options?.source ?? "live";
|
||||
switch (event.type) {
|
||||
case "timeline":
|
||||
return reduceTimelineEvent(state, event, timestamp, source);
|
||||
return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds);
|
||||
case "thread_started":
|
||||
case "turn_started":
|
||||
case "turn_completed":
|
||||
@@ -1173,7 +1216,17 @@ export function applyStreamEvent(params: {
|
||||
|
||||
// For streamable kinds, apply to head
|
||||
if (incomingKind !== null && isStreamableKind(incomingKind)) {
|
||||
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source });
|
||||
const reservedItemIds =
|
||||
incomingKind === "assistant_message" && getActiveAssistantHeadIndex(nextHead) < 0
|
||||
? new Set(
|
||||
nextTail.flatMap((item) =>
|
||||
item.kind === "assistant_message" && item.blockGroupId
|
||||
? [item.id, item.blockGroupId]
|
||||
: [item.id],
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds });
|
||||
if (reduced !== nextHead) {
|
||||
nextHead = reduced;
|
||||
changedHead = true;
|
||||
|
||||
@@ -27,6 +27,39 @@ const SERVER_ID = "server-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
function agent(archivedAt: Date | null): Agent {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: createdAt,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
archivedAt,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
|
||||
return (
|
||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||
@@ -44,11 +77,7 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
|
||||
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date(),
|
||||
} as unknown as Agent);
|
||||
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -95,11 +124,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: undefined,
|
||||
} as unknown as Agent);
|
||||
next.set(AGENT_ID, agent(null));
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canCloseLeftSidebarGesture,
|
||||
canCloseRightSidebarGesture,
|
||||
canOpenLeftSidebarGesture,
|
||||
canOpenRightSidebarGesture,
|
||||
getLeftSidebarAnimationTargets,
|
||||
getRightSidebarAnimationTargets,
|
||||
MOBILE_PANEL_STATE_AGENT,
|
||||
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
|
||||
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
|
||||
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
|
||||
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
|
||||
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
|
||||
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
|
||||
MOBILE_PANEL_TARGET_AGENT,
|
||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
||||
MOBILE_PANEL_TARGET_FILE_EXPLORER,
|
||||
shouldSettleMobilePanelTransition,
|
||||
shouldSyncSidebarAnimation,
|
||||
} from "./sidebar-animation-state";
|
||||
|
||||
describe("sidebar-animation-state", () => {
|
||||
it("requests a sync when the open state changes", () => {
|
||||
expect(
|
||||
shouldSyncSidebarAnimation({
|
||||
previousIsOpen: false,
|
||||
nextIsOpen: true,
|
||||
previousWindowWidth: 390,
|
||||
nextWindowWidth: 390,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requests a sync when the viewport width changes", () => {
|
||||
expect(
|
||||
shouldSyncSidebarAnimation({
|
||||
previousIsOpen: false,
|
||||
nextIsOpen: false,
|
||||
previousWindowWidth: 390,
|
||||
nextWindowWidth: 430,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the left sidebar fully off-screen when closed", () => {
|
||||
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
|
||||
translateX: -430,
|
||||
backdropOpacity: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the right sidebar fully off-screen when closed", () => {
|
||||
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
|
||||
translateX: 430,
|
||||
backdropOpacity: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the left open gesture only after the app is settled on the agent panel", () => {
|
||||
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -430, 430)).toBe(true);
|
||||
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -240, 430)).toBe(false);
|
||||
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, -430, 430)).toBe(false);
|
||||
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING, -430, 430)).toBe(false);
|
||||
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, -430, 430)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the left close gesture only while the left sidebar is settled open", () => {
|
||||
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN)).toBe(true);
|
||||
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING)).toBe(false);
|
||||
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING)).toBe(false);
|
||||
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the right open gesture only after the app is settled on the agent panel", () => {
|
||||
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 430, 430)).toBe(true);
|
||||
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 240, 430)).toBe(false);
|
||||
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, 430, 430)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, 430, 430)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN, 430, 430)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the right close gesture only while the right sidebar is settled open", () => {
|
||||
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN)).toBe(true);
|
||||
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING)).toBe(false);
|
||||
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING)).toBe(false);
|
||||
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects stale settle callbacks from a panel that is no longer the destination", () => {
|
||||
expect(
|
||||
shouldSettleMobilePanelTransition(
|
||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSettleMobilePanelTransition(MOBILE_PANEL_TARGET_AGENT_LIST, MOBILE_PANEL_TARGET_AGENT),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSettleMobilePanelTransition(
|
||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
||||
MOBILE_PANEL_TARGET_FILE_EXPLORER,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
interface SidebarAnimationSyncInput {
|
||||
previousIsOpen: boolean;
|
||||
nextIsOpen: boolean;
|
||||
previousWindowWidth: number;
|
||||
nextWindowWidth: number;
|
||||
}
|
||||
|
||||
interface SidebarAnimationTargetInput {
|
||||
isOpen: boolean;
|
||||
windowWidth: number;
|
||||
}
|
||||
|
||||
interface SidebarAnimationTargets {
|
||||
translateX: number;
|
||||
backdropOpacity: number;
|
||||
}
|
||||
|
||||
export const MOBILE_PANEL_STATE_AGENT = 0;
|
||||
export const MOBILE_PANEL_STATE_AGENT_LIST_OPENING = 1;
|
||||
export const MOBILE_PANEL_STATE_AGENT_LIST_OPEN = 2;
|
||||
export const MOBILE_PANEL_STATE_AGENT_LIST_CLOSING = 3;
|
||||
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING = 4;
|
||||
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN = 5;
|
||||
export const MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING = 6;
|
||||
|
||||
export const MOBILE_PANEL_TARGET_AGENT = 0;
|
||||
export const MOBILE_PANEL_TARGET_AGENT_LIST = 1;
|
||||
export const MOBILE_PANEL_TARGET_FILE_EXPLORER = 2;
|
||||
|
||||
const CLOSED_POSITION_TOLERANCE = 1;
|
||||
|
||||
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
|
||||
return (
|
||||
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
|
||||
);
|
||||
}
|
||||
|
||||
export function getLeftSidebarAnimationTargets(
|
||||
input: SidebarAnimationTargetInput,
|
||||
): SidebarAnimationTargets {
|
||||
return {
|
||||
translateX: input.isOpen ? 0 : -input.windowWidth,
|
||||
backdropOpacity: input.isOpen ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRightSidebarAnimationTargets(
|
||||
input: SidebarAnimationTargetInput,
|
||||
): SidebarAnimationTargets {
|
||||
return {
|
||||
translateX: input.isOpen ? 0 : input.windowWidth,
|
||||
backdropOpacity: input.isOpen ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function canOpenLeftSidebarGesture(
|
||||
mobilePanelState: number,
|
||||
translateX: number,
|
||||
windowWidth: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
return (
|
||||
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
|
||||
translateX <= -windowWidth + CLOSED_POSITION_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function canCloseLeftSidebarGesture(mobilePanelState: number): boolean {
|
||||
"worklet";
|
||||
return mobilePanelState === MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
|
||||
}
|
||||
|
||||
export function canOpenRightSidebarGesture(
|
||||
mobilePanelState: number,
|
||||
translateX: number,
|
||||
windowWidth: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
return (
|
||||
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
|
||||
translateX >= windowWidth - CLOSED_POSITION_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function canCloseRightSidebarGesture(mobilePanelState: number): boolean {
|
||||
"worklet";
|
||||
return mobilePanelState === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
|
||||
}
|
||||
|
||||
export function shouldSettleMobilePanelTransition(
|
||||
activeTarget: number,
|
||||
settledTarget: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
return activeTarget === settledTarget;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
@@ -202,11 +203,13 @@ describe("buildStatusSidebarShortcutModel", () => {
|
||||
];
|
||||
|
||||
const model = buildStatusSidebarShortcutModel({
|
||||
workspaces,
|
||||
projectNamesByKey: new Map([
|
||||
["p1", "Project 1"],
|
||||
["p2", "Project 2"],
|
||||
]),
|
||||
groups: buildStatusGroups(
|
||||
workspaces,
|
||||
new Map([
|
||||
["p1", "Project 1"],
|
||||
["p2", "Project 2"],
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toEqual([
|
||||
@@ -242,8 +245,7 @@ describe("buildStatusSidebarShortcutModel", () => {
|
||||
];
|
||||
|
||||
const model = buildStatusSidebarShortcutModel({
|
||||
workspaces,
|
||||
projectNamesByKey: new Map([["p1", "Project 1"]]),
|
||||
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])),
|
||||
collapsedStatusGroupKeys: new Set(["needs_input"]),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarStatusWorkspacePlacement,
|
||||
SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string;
|
||||
@@ -53,17 +52,15 @@ export function buildSidebarShortcutModel(input: {
|
||||
}
|
||||
|
||||
export function buildStatusSidebarShortcutModel(input: {
|
||||
workspaces: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
groups: readonly StatusGroup[];
|
||||
collapsedStatusGroupKeys?: ReadonlySet<string>;
|
||||
shortcutLimit?: number;
|
||||
}): SidebarShortcutModel {
|
||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
|
||||
const groups = buildStatusGroups(input.workspaces, input.projectNamesByKey);
|
||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>();
|
||||
|
||||
for (const group of groups) {
|
||||
for (const group of input.groups) {
|
||||
if (input.collapsedStatusGroupKeys?.has(group.bucket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -12,31 +12,40 @@ describe("buildWorkingDirectorySuggestions", () => {
|
||||
expect(results).toEqual(["/Users/me/projects/paseo"]);
|
||||
});
|
||||
|
||||
it("prioritizes matching recommended directories before server matches", () => {
|
||||
it("keeps fuzzy recommendation matches before de-duplicated daemon suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
|
||||
serverPaths: [
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/paseo",
|
||||
"/Users/me/projects/planbook",
|
||||
recommendedPaths: ["/Users/me/projects/paseo-desktop", "/Users/me/documents"],
|
||||
serverPaths: ["/Users/me/projects/paseo-plan", "/Users/me/projects/paseo-desktop"],
|
||||
query: "pso",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/paseo-desktop", "/Users/me/projects/paseo-plan"]);
|
||||
});
|
||||
|
||||
it("does not reinterpret daemon-ranked suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: [],
|
||||
serverPaths: ["/Users/me/projects/paseo-desktop"],
|
||||
query: "a-query-ranked-by-the-daemon",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/paseo-desktop"]);
|
||||
});
|
||||
|
||||
it("leaves path-query semantics to the daemon", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: [
|
||||
"/Users/me/archive/projects/paseo-desktop",
|
||||
"/Users/me/projects/paseo-desktop",
|
||||
],
|
||||
query: "pla",
|
||||
serverPaths: [],
|
||||
query: "~/projects/pso",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts matching recommended items first when they also match query", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"],
|
||||
serverPaths: ["/Users/me/projects/planbook", "/Users/me/projects/playground"],
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
|
||||
});
|
||||
|
||||
it("treats '~' as an active query and includes server suggestions", () => {
|
||||
it("treats '~' as an active query and includes daemon suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo"],
|
||||
serverPaths: ["/Users/me/documents", "/Users/me/projects"],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { scoreMatch } from "./score-match";
|
||||
|
||||
export interface BuildWorkingDirectorySuggestionsInput {
|
||||
recommendedPaths: string[];
|
||||
serverPaths: string[];
|
||||
@@ -7,40 +9,27 @@ export interface BuildWorkingDirectorySuggestionsInput {
|
||||
export function buildWorkingDirectorySuggestions(
|
||||
input: BuildWorkingDirectorySuggestionsInput,
|
||||
): string[] {
|
||||
const rawQuery = input.query.trim();
|
||||
const query = input.query.trim();
|
||||
const recommended = uniquePaths(input.recommendedPaths);
|
||||
if (!rawQuery) {
|
||||
if (!query) {
|
||||
return recommended;
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeQuery(rawQuery);
|
||||
const shouldFilterByQuery = normalizedQuery.length > 0;
|
||||
const matchingRecommended = recommended.filter((path) =>
|
||||
recommendedPathMatchesQuery(path, query),
|
||||
);
|
||||
|
||||
const recommendedMatches = shouldFilterByQuery
|
||||
? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery))
|
||||
: recommended;
|
||||
const seen = new Set(recommendedMatches);
|
||||
const ordered = [...recommendedMatches];
|
||||
|
||||
for (const entry of uniquePaths(input.serverPaths)) {
|
||||
if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
ordered.push(entry);
|
||||
seen.add(entry);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
// The request owner correlates these results with the current query. The
|
||||
// daemon owns filesystem query parsing, filtering, and ranking; doing it
|
||||
// again here creates a second search implementation that can disagree.
|
||||
return uniquePaths([...matchingRecommended, ...input.serverPaths]);
|
||||
}
|
||||
|
||||
function uniquePaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const pathEntry of paths) {
|
||||
const trimmed = pathEntry.trim();
|
||||
for (const path of paths) {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
@@ -50,23 +39,20 @@ function uniquePaths(paths: string[]): string[] {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
let normalized = query.trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.startsWith("~")) {
|
||||
normalized = normalized.slice(1);
|
||||
}
|
||||
normalized = normalized.replace(/^\/+/, "").toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pathMatchesQuery(candidatePath: string, query: string): boolean {
|
||||
const lowerPath = candidatePath.toLowerCase();
|
||||
if (lowerPath.includes(query)) {
|
||||
function recommendedPathMatchesQuery(path: string, query: string): boolean {
|
||||
const candidate = normalizePath(path);
|
||||
const normalizedQuery = normalizePath(query);
|
||||
if (["~", "~/"].includes(normalizedQuery)) {
|
||||
return true;
|
||||
}
|
||||
const segments = lowerPath.split("/");
|
||||
return (segments[segments.length - 1] ?? "").includes(query);
|
||||
if (normalizedQuery.includes("/") || normalizedQuery.startsWith("~")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const basename = candidate.split("/").at(-1) ?? "";
|
||||
return candidate.includes(normalizedQuery) || scoreMatch(normalizedQuery, basename) !== null;
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.trim().replace(/\\/g, "/").toLowerCase();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { buildWorkspaceAgentActivityIndex } from "./workspace-agent-activity";
|
||||
|
||||
function agent(input: {
|
||||
id: string;
|
||||
workspaceId?: string;
|
||||
status?: Agent["status"];
|
||||
updatedAt: string;
|
||||
attentionTimestamp?: string | null;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: Agent["attentionReason"];
|
||||
pendingPermissionCount?: number;
|
||||
archivedAt?: string | null;
|
||||
parentAgentId?: string | null;
|
||||
}): Agent {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
status: input.status ?? "idle",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date(input.updatedAt),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date(input.updatedAt),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: Array.from({ length: input.pendingPermissionCount ?? 0 }, (_, index) => ({
|
||||
id: `permission-${index}`,
|
||||
provider: "codex",
|
||||
name: "shell",
|
||||
kind: "tool",
|
||||
input: {},
|
||||
})),
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: input.workspaceId,
|
||||
model: null,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null,
|
||||
archivedAt: input.archivedAt ? new Date(input.archivedAt) : null,
|
||||
parentAgentId: input.parentAgentId ?? null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace agent activity index", () => {
|
||||
it("keeps the latest active root agent for each workspace", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"older",
|
||||
agent({
|
||||
id: "older",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"permission",
|
||||
agent({
|
||||
id: "permission",
|
||||
workspaceId: "workspace-a",
|
||||
updatedAt: "2026-06-01T10:01:00.000Z",
|
||||
pendingPermissionCount: 1,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"attention",
|
||||
agent({
|
||||
id: "attention",
|
||||
workspaceId: "workspace-b",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
attentionTimestamp: "2026-06-01T10:02:00.000Z",
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(index).toEqual(
|
||||
new Map([
|
||||
[
|
||||
"workspace-a",
|
||||
{
|
||||
agentId: "permission",
|
||||
status: "needs_input",
|
||||
enteredAt: new Date("2026-06-01T10:01:00.000Z"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"workspace-b",
|
||||
{
|
||||
agentId: "attention",
|
||||
status: "attention",
|
||||
enteredAt: new Date("2026-06-01T10:02:00.000Z"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let archived or child agents change root workspace activity", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"child",
|
||||
agent({
|
||||
id: "child",
|
||||
workspaceId: "workspace-a",
|
||||
updatedAt: "2026-06-01T10:03:00.000Z",
|
||||
pendingPermissionCount: 1,
|
||||
parentAgentId: "root",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"archived",
|
||||
agent({
|
||||
id: "archived",
|
||||
workspaceId: "workspace-a",
|
||||
updatedAt: "2026-06-01T10:04:00.000Z",
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
archivedAt: "2026-06-01T10:04:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(index.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "running",
|
||||
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the activity index while the same agent remains in the same status", () => {
|
||||
const previous = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const next = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
previous,
|
||||
);
|
||||
|
||||
expect(next).toBe(previous);
|
||||
expect(next.get("workspace-a")?.enteredAt).toEqual(new Date("2026-06-01T10:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("records a new entry time when an agent changes status", () => {
|
||||
const previous = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const next = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "idle",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
pendingPermissionCount: 1,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
previous,
|
||||
);
|
||||
|
||||
expect(next).not.toBe(previous);
|
||||
expect(next.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "needs_input",
|
||||
enteredAt: new Date("2026-06-01T10:05:00.000Z"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
|
||||
|
||||
export interface WorkspaceAgentActivity {
|
||||
agentId: string;
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceAgentActivityIndex(
|
||||
agents: ReadonlyMap<string, Agent>,
|
||||
previous?: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
): Map<string, WorkspaceAgentActivity> {
|
||||
const activityByWorkspaceId = new Map<string, WorkspaceAgentActivity>();
|
||||
const latestActivityAtByWorkspaceId = new Map<string, Date>();
|
||||
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
|
||||
const latestActivityAt = latestActivityAtByWorkspaceId.get(agent.workspaceId);
|
||||
if (latestActivityAt && enteredAt <= latestActivityAt) {
|
||||
continue;
|
||||
}
|
||||
latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt);
|
||||
|
||||
const status = deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
});
|
||||
activityByWorkspaceId.set(agent.workspaceId, {
|
||||
agentId: agent.id,
|
||||
status,
|
||||
enteredAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [workspaceId, activity] of activityByWorkspaceId) {
|
||||
const previousActivity = previous?.get(workspaceId);
|
||||
if (
|
||||
previousActivity?.agentId === activity.agentId &&
|
||||
previousActivity.status === activity.status
|
||||
) {
|
||||
activityByWorkspaceId.set(workspaceId, previousActivity);
|
||||
}
|
||||
}
|
||||
|
||||
if (previous && areWorkspaceAgentActivityIndexesIdentical(previous, activityByWorkspaceId)) {
|
||||
return previous instanceof Map ? previous : new Map(previous);
|
||||
}
|
||||
return activityByWorkspaceId;
|
||||
}
|
||||
|
||||
function areWorkspaceAgentActivityIndexesIdentical(
|
||||
previous: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
next: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
): boolean {
|
||||
if (previous.size !== next.size) {
|
||||
return false;
|
||||
}
|
||||
for (const [workspaceId, activity] of next) {
|
||||
if (previous.get(workspaceId) !== activity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.104",
|
||||
"version": "0.1.105",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/server": "0.1.104",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user