Compare commits

..

18 Commits

Author SHA1 Message Date
Mohamed Boudra
603b380321 fix(app): preserve workspace-root link tooltips
Workspace file resolution deliberately rejects the root as an openable file, so tooltip formatting preserves its display-only dot form separately.
2026-07-11 09:21:12 +00:00
Mohamed Boudra
ac45b2f2ae fix(app): show Windows file links relative to workspace
Tooltip formatting only handled matching POSIX separators, so Windows paths could remain absolute. Reuse the workspace file-path resolver for normalized relative paths.
2026-07-10 18:49:06 +00:00
paseo-ai[bot]
860fcb2e35 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-10 11:03:47 +00:00
Mohamed Boudra
4c72bf0209 chore(release): cut 0.1.105 2026-07-10 12:58:31 +02:00
Mohamed Boudra
8c639fd796 fix(app): stabilize project picker E2E selectors
Project picker tests had depended on a placeholder changed for fuzzy search. Use a dedicated input test ID so product copy no longer breaks the selectors.
2026-07-10 12:39:40 +02:00
Mohamed Boudra
aa656772ef docs: add 0.1.105 changelog 2026-07-10 12:24:58 +02:00
Mohamed Boudra
4dd783c36a docs: date Pi stats compatibility shim 2026-07-10 12:24:45 +02:00
Mohamed Boudra
e6a316456d chore: refresh ACP provider catalog 2026-07-10 12:24:35 +02:00
Mohamed Boudra
3c2339f841 fix(app): clarify fuzzy project search 2026-07-10 12:01:50 +02:00
Mohamed Boudra
61a9117a6b Restore fuzzy project folder search and desktop browsing (#1968)
* fix(projects): restore folder search and browsing

* fix(projects): preserve fuzzy search edge cases

* test(projects): move picker cleanup into fixture

* fix(projects): preserve absolute path descendants

* test(server): compare canonical search paths

* fix(projects): anchor root path suggestions

* fix(projects): anchor Windows root suggestions

* fix(projects): anchor directory search paths

* test(server): canonicalize directory search root

* fix(app): anchor rooted basename suggestions

* refactor(search): unify directory suggestions

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

* fix(search): preserve filesystem root semantics

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* test(providers): cover wrapped ACP command lookup

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

* fix(app): preserve parameterized model preferences

* fix(providers): preserve Cursor ACP feature params

* fix(providers): keep Cursor legacy model fallbacks

* fix(providers): preserve Cursor model parameter switches

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

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

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

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

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

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

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

* fix(pi): require explicit vision support

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

* fix(pi): harden materialized image temp dirs

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

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

* fix(server): refresh missing image temp cache

* test(server): clean image temp dirs
2026-07-10 01:34:34 +08:00
119 changed files with 5994 additions and 4171 deletions

View File

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

View File

@@ -1,5 +1,30 @@
# Changelog
## 0.1.105 - 2026-07-10
### Added
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
### Improved
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
### Fixed
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
## 0.1.104 - 2026-07-08
### Added

View File

@@ -45,6 +45,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |

View File

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

View File

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

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

@@ -0,0 +1,88 @@
# Mobile panels
Compact layouts have three mutually exclusive destinations:
- `agent-list` on the left
- `agent` in the center
- `file-explorer` on the right
They are one interaction, not two independent drawers. The implementation lives in
`packages/app/src/mobile-panels/`.
## Ownership
React/Zustand owns the durable intent:
```ts
interface MobilePanelSelection {
target: "agent-list" | "agent" | "file-explorer";
revision: number;
}
```
Every semantic target change increments `revision`. Repeating the current target is idempotent.
Compact panel selection is not persisted; a cold start begins at `agent`.
The UI worklet owns transient motion:
- one normalized position (`-1` left, `0` center, `1` right)
- the current motion target
- the active gesture's starting revision
- the last settled target
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
receive pointer events. Worklets never own `display` or `pointerEvents`.
## Why one position
Both transforms and both backdrop opacities are derived from the same normalized position. Window
width is only a projection input. Rotation changes the projection, not the panel state.
This makes these invalid states unrepresentable:
- a panel and its backdrop disagreeing
- left and right drawers both claiming to be open
- a width-sync effect resetting an active drag
- one animation context settling a transition owned by the other
Do not add another panel translate shared value, backdrop shared value, or width synchronization
effect.
## Ordering and interruption
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
its target and revision still match the canonical command.
Manual gesture arbitration has two phases:
1. Before activation, determine whether horizontal intent may begin.
2. After activation, stop running begin checks and let the active revision own updates.
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
definition, no longer eligible to begin.
## Integration rules
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
so its injected `collapsable={false}` reaches Android/Fabric.
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
styles in sidebar components.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
prop commit from resurrecting a closed overlay.
## Tests
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
sequence there whenever ownership or ordering changes.

View File

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

View File

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

View File

@@ -1 +1 @@
sha256-RtZABQl17BlUyvFiFw9W5g2E/dpNlrOWpuCdV8wZk5o=
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=

42
package-lock.json generated
View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
@@ -50,7 +51,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPath);
await page.keyboard.press("Enter");
@@ -74,6 +75,26 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
}
test.describe("Project picker search", () => {
test("opens a project from a fuzzy directory-name search", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPickerFixture.fuzzyQuery);
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
await expect(suggestion).toBeVisible({ timeout: 30_000 });
await suggestion.click();
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
});
test("shows a loading state after typing while directory suggestions are pending", async ({
page,
}) => {
@@ -81,7 +102,7 @@ test.describe("Project picker search", () => {
await waitForSidebarProjectListReady(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
const input = page.getByTestId("project-picker-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill("paseo-loading-state-no-match");

View File

@@ -1,16 +1,30 @@
import { test as base, expect, type Page } from "@playwright/test";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import {
createProjectPickerFixture,
removeProjectPickerFixture,
type ProjectPickerFixture,
} from "./helpers/project-picker-fixture";
import { connectSeedClient } from "./helpers/seed-client";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
rememberProjectId: (projectId: string) => void;
}
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
// reliably for every test that uses this `test` object.
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
const test = base.extend<{
paseoE2ESetup: void;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -102,6 +116,30 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
{ auto: true },
],
projectPickerFixture: async ({}, provide) => {
const resource = await createProjectPickerFixture();
const { fixture } = resource;
let projectId: string | null = null;
try {
await provide({
...fixture,
rememberProjectId: (openedProjectId) => {
projectId = openedProjectId;
},
});
} finally {
try {
const client = await connectSeedClient();
try {
await removeProjectPickerFixture(client, fixture, projectId);
} finally {
await client.close();
}
} finally {
await resource.removeDirectory();
}
}
},
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";
@@ -100,13 +88,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import {
@@ -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);
@@ -477,26 +453,22 @@ function AppContainer({
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && <LeftSidebar />}
{isCompactLayout && chromeEnabled ? (
<ExplorerSidebarAnimationProvider>
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
</CompactExplorerSidebarHost>
</ExplorerSidebarAnimationProvider>
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
</CompactExplorerSidebarHost>
) : (
<View style={flexStyle}>{children}</View>
)}
</View>
);
const content = (
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{isCompactLayout && chromeEnabled && <LeftSidebar />}
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
@@ -512,11 +484,13 @@ function AppContainer({
</View>
);
if (!isCompactLayout) {
return content;
}
const content = isCompactLayout ? (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
) : (
surface
);
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
return <SidebarModelProvider>{content}</SidebarModelProvider>;
}
function MobileGestureWrapper({
@@ -526,127 +500,7 @@ function MobileGestureWrapper({
children: ReactNode;
chromeEnabled: boolean;
}) {
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
setOverlayPeek,
isGesturing,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const openGestureEnabled = chromeEnabled;
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
showMobileAgentList();
}, [showMobileAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
.failOffsetY([-10, 10])
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) return;
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
}
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
stateManager.fail();
return;
}
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
if (deltaX > 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
// The overlay is display:none while closed; reveal it for the drag.
runOnJS(setOverlayPeek)(true);
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
runOnJS(setOverlayPeek)(false);
}),
[
openGestureEnabled,
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
setOverlayPeek,
handleGestureOpen,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
touchStartY,
],
);
const openGesture = useOpenAgentListGesture(chromeEnabled);
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
@@ -882,7 +736,6 @@ function OpenProjectListener() {
function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
const storeReady = useStoreReady();
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
@@ -896,30 +749,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/schedules" ||
routeHasKnownHost);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (workspaceServerId && openIntent?.kind === "agent") {
const agentId = openIntent.agentId.trim();
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
}
const match = parseHostAgentRouteFromPathname(pathname);
return match ? `${match.serverId}:${match.agentId}` : undefined;
}, [params.open, pathname]);
return (
<AppContainer
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
chromeEnabled={shouldShowAppChrome}
>
{children}
</AppContainer>
);
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
}
function FaviconStatusSync() {
@@ -974,7 +804,7 @@ function WorkspaceRouteNavigationBridge() {
function AppShell() {
return (
<SidebarAnimationProvider>
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
@@ -982,7 +812,7 @@ function AppShell() {
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</MobilePanelsProvider>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,17 +38,13 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import {
Archive,
CircleAlert,
CircleCheck,
ChevronDown,
ChevronRight,
Copy,
ExternalLink,
GitPullRequest,
Settings,
MoreVertical,
Pencil,
Plus,
Trash2,
} from "lucide-react-native";
@@ -68,7 +64,6 @@ import {
shouldShowSidebarHostLabels,
useSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
@@ -93,6 +88,8 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
@@ -142,15 +139,11 @@ const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedPlus = withUnistyles(Plus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
@@ -228,7 +221,7 @@ function selectionForSelectedWorkspace(
}
interface SidebarWorkspaceListProps {
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
statusGroups: StatusGroup[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
collapsedProjectKeys: ReadonlySet<string>;
@@ -358,12 +351,6 @@ function projectKebabStyle({
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
}
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getProjectWorkspaceRowStyle({
isDragging,
selected,
@@ -540,12 +527,6 @@ function ProjectRowTrailingActions({
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
);
@@ -693,7 +674,7 @@ function WorkspaceRowRightGroup({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -713,97 +694,6 @@ function WorkspaceRowRightGroup({
);
}
function WorkspaceKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProjectIcon({
iconDataUri,
placeholderInitial,
@@ -2166,7 +2056,7 @@ function areProjectBlockSelectionsEqual(
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
export function SidebarWorkspaceList({
statusWorkspacePlacements,
statusGroups,
projects,
projectNamesByKey,
collapsedProjectKeys,
@@ -2196,7 +2086,7 @@ export function SidebarWorkspaceList({
const content =
groupMode === "status" ? (
<SidebarStatusModeWrapper
statusWorkspacePlacements={statusWorkspacePlacements}
statusGroups={statusGroups}
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
@@ -2224,14 +2114,14 @@ export function SidebarWorkspaceList({
}
function SidebarStatusModeWrapper({
statusWorkspacePlacements,
statusGroups,
projectNamesByKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
}: {
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
statusGroups: StatusGroup[];
projectNamesByKey: Map<string, string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
@@ -2242,7 +2132,7 @@ function SidebarStatusModeWrapper({
return (
<SidebarStatusWorkspaceList
workspaces={statusWorkspacePlacements}
groups={statusGroups}
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
@@ -2268,7 +2158,7 @@ function ProjectModeList({
supportsMultiplicityByServerId,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
> & {
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
@@ -2872,14 +2762,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,

View File

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

View File

@@ -9,11 +9,7 @@ import {
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
type StatusGroup,
} from "@/hooks/sidebar-status-view-model";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
@@ -25,18 +21,8 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -47,7 +33,6 @@ import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -60,9 +45,9 @@ import {
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -77,20 +62,8 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarStatusWorkspacePlacement[];
groups: StatusGroup[];
projectNamesByKey: Map<string, string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
@@ -100,31 +73,19 @@ interface StatusWorkspaceListProps {
}
export function SidebarStatusWorkspaceList({
workspaces,
groups,
projectNamesByKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
}: StatusWorkspaceListProps) {
const groups = useMemo(
() => buildStatusGroups(workspaces, projectNamesByKey),
[workspaces, projectNamesByKey],
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const statusShortcutIndex = useMemo(
() =>
showShortcutBadges
? buildStatusShortcutIndex(
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
)
: new Map<string, number>(),
[collapsedStatusGroupKeys, groups, showShortcutBadges],
);
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
return (
<View style={styles.container}>
@@ -657,7 +618,7 @@ function StatusWorkspaceActionSlot({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -675,105 +636,6 @@ function StatusWorkspaceActionSlot({
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
@@ -876,12 +738,4 @@ const styles = StyleSheet.create((theme) => ({
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

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

View File

@@ -1,22 +1,13 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { Theme } from "@/styles/theme";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -28,8 +19,9 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { isNative as platformIsNative } from "@/constants/platform";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
@@ -38,31 +30,6 @@ import {
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function noop() {}
interface SidebarWorkspaceRowProps {
@@ -458,7 +425,7 @@ function WorkspaceRowTrailingActions({
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -478,103 +445,6 @@ function WorkspaceRowTrailingActions({
);
}
function WorkspaceKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getWorkspaceRowStyle({
isDragging,
selected,
@@ -630,12 +500,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1093,7 +1093,8 @@ export const ar: TranslationResources = {
},
},
projectPicker: {
placeholder: "اكتب مسار الدليل...",
placeholder: "اكتب للبحث...",
browse: "استعراض…",
opening: "افتتاح المشروع...",
searching: "جارٍ البحث...",
empty: "ابدأ بكتابة المسار",

View File

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

View File

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

View File

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

View File

@@ -1107,7 +1107,8 @@ export const ja: TranslationResources = {
},
},
projectPicker: {
placeholder: "ディレクトリパスを入力...",
placeholder: "入力して検索...",
browse: "参照…",
opening: "プロジェクトを開いています...",
searching: "検索中...",
empty: "パスを入力してください",

View File

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

View File

@@ -1119,7 +1119,8 @@ export const ru: TranslationResources = {
},
},
projectPicker: {
placeholder: "Введите путь к каталогу...",
placeholder: "Введите текст для поиска...",
browse: "Обзор…",
opening: "Открытие проекта...",
searching: "Идет поиск...",
empty: "Начните вводить путь",

View File

@@ -1078,7 +1078,8 @@ export const zhCN: TranslationResources = {
},
},
projectPicker: {
placeholder: "输入目录路径...",
placeholder: "输入以搜索...",
browse: "浏览…",
opening: "正在打开 project...",
searching: "正在搜索...",
empty: "开始输入路径",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,13 +10,14 @@ import {
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility,
type PanelCoreState,
} from "./state";
function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState {
return {
mobileView: "agent",
mobilePanel: { target: "agent", revision: 0 },
desktop: {
agentListOpen: false,
fileExplorerOpen: false,
@@ -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");
});

View File

@@ -8,6 +8,11 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
export interface MobilePanelSelection {
target: MobilePanelView;
revision: number;
}
export interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
@@ -43,7 +48,7 @@ export interface ExplorerPanelIntent extends PanelLayoutInput {
}
export interface PanelCoreState {
mobileView: MobilePanelView;
mobilePanel: MobilePanelSelection;
desktop: DesktopSidebarState;
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
@@ -74,8 +79,8 @@ export function selectPanelVisibility(
): PanelVisibilityState {
if (input.isCompact) {
return {
isAgentListOpen: state.mobileView === "agent-list",
isFileExplorerOpen: state.mobileView === "file-explorer",
isAgentListOpen: state.mobilePanel.target === "agent-list",
isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
};
}
return {
@@ -92,6 +97,16 @@ export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayo
return selectPanelVisibility(state, input).isFileExplorerOpen;
}
export function setMobilePanelTarget(
selection: MobilePanelSelection,
target: MobilePanelView,
): MobilePanelSelection {
if (selection.target === target) {
return selection;
}
return { target, revision: selection.revision + 1 };
}
function resolveExplorerTabFromCheckout(
state: PanelCoreState,
checkout: ExplorerCheckoutContext,
@@ -105,7 +120,7 @@ function resolveExplorerTabFromCheckout(
}
export interface OpenFileExplorerPatch {
mobileView?: MobilePanelView;
mobilePanel?: MobilePanelSelection;
desktop?: DesktopSidebarState;
explorerTab: ExplorerTab;
}
@@ -117,7 +132,7 @@ export function buildOpenFileExplorerPatch(
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) {
return {
mobileView: "file-explorer",
mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
explorerTab: resolvedTab,
};
}
@@ -129,7 +144,7 @@ export function buildOpenFileExplorerPatch(
export type ToggleFileExplorerPatch =
| OpenFileExplorerPatch
| { mobileView: MobilePanelView }
| { mobilePanel: MobilePanelSelection }
| { desktop: DesktopSidebarState };
export function buildToggleFileExplorerPatch(
@@ -141,7 +156,7 @@ export function buildToggleFileExplorerPatch(
return buildOpenFileExplorerPatch(state, input);
}
if (input.isCompact) {
return { mobileView: "agent" };
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}
@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]),
});

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -0,0 +1,142 @@
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", { status: "needs_input", enteredAt: new Date("2026-06-01T10:01:00.000Z") }],
["workspace-b", { 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({
status: "running",
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
});
});
});

View File

@@ -0,0 +1,37 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
export interface WorkspaceAgentActivity {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
export function buildWorkspaceAgentActivityIndex(
agents: ReadonlyMap<string, Agent>,
): Map<string, WorkspaceAgentActivity> {
const activityByWorkspaceId = new Map<string, WorkspaceAgentActivity>();
for (const agent of agents.values()) {
if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) {
continue;
}
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
const current = activityByWorkspaceId.get(agent.workspaceId);
if (current && enteredAt <= (current.enteredAt ?? new Date(0))) {
continue;
}
activityByWorkspaceId.set(agent.workspaceId, {
status: deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
}),
enteredAt,
});
}
return activityByWorkspaceId;
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.104",
"version": "0.1.105",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"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": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.104",
"version": "0.1.105",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104",
"version": "0.1.105",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.104",
"version": "0.1.105",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.104",
"version": "0.1.105",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.104",
"version": "0.1.105",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.104",
"version": "0.1.105",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -66,10 +66,10 @@
"@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",

View File

@@ -185,6 +185,46 @@ second line'`,
]);
});
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Before first child." },
toolCallItem({
callId: "child-1",
name: "Sub-agent",
detail: {
type: "sub_agent",
subAgentType: "Child one",
description: "First investigation",
log: "[Assistant] First child result.",
},
}),
{ type: "assistant_message", text: "Between children." },
toolCallItem({
callId: "child-2",
name: "Sub-agent",
detail: {
type: "sub_agent",
subAgentType: "Child two",
description: "Second investigation",
log: "[Assistant] Second child result.",
},
}),
{ type: "assistant_message", text: "After second child." },
];
const result = curateAgentActivity(timeline, { labelAssistantMessages: true });
expect(result.split("\n")).toEqual([
"[Assistant] Before first child.",
"[Child one] First investigation",
"[Assistant] First child result.",
"[Assistant] Between children.",
"[Child two] Second investigation",
"[Assistant] Second child result.",
"[Assistant] After second child.",
]);
});
it("renders todo/error/compaction entries", () => {
const timeline: AgentTimelineItem[] = [
{

View File

@@ -165,6 +165,9 @@ function curateProjectedActivityEntries(
case "tool_call": {
flushBuffers(entries, buffers, options);
entries.push(formatToolCallEntry(item, options));
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
entries.push(activityEntry(item.detail.log.trim()));
}
break;
}
case "todo":

View File

@@ -45,9 +45,17 @@ function assistant(
options?: {
provider?: AgentProvider;
turnId?: string;
messageId?: string;
},
): Extract<AgentStreamEvent, { type: "timeline" }> {
return timeline({ type: "assistant_message", text }, options);
return timeline(
{
type: "assistant_message",
text,
...(options?.messageId !== undefined ? { messageId: options.messageId } : {}),
},
options,
);
}
function reasoning(
@@ -284,6 +292,23 @@ describe("AgentStreamCoalescer", () => {
]);
});
test("does not splice concurrent assistant message ids together", async () => {
const { coalescer, flushes } = createHarness();
coalescer.handle("agent-1", assistant("The", { turnId: "turn-1", messageId: "message-a" }));
coalescer.handle("agent-1", assistant("0po", { turnId: "turn-1", messageId: "message-b" }));
coalescer.handle("agent-1", assistant(" exact", { turnId: "turn-1", messageId: "message-a" }));
coalescer.handle("agent-1", assistant("7/fr", { turnId: "turn-1", messageId: "message-b" }));
await vi.advanceTimersByTimeAsync(60);
expect(flushes.map((flush) => flush.item)).toEqual([
{ type: "assistant_message", messageId: "message-a", text: "The" },
{ type: "assistant_message", messageId: "message-b", text: "0po" },
{ type: "assistant_message", messageId: "message-a", text: " exact" },
{ type: "assistant_message", messageId: "message-b", text: "7/fr" },
]);
});
test("drops empty text chunks", async () => {
const { coalescer, flushes } = createHarness();

View File

@@ -73,6 +73,16 @@ function isTerminalToolCall(item: CoalescableTimelineItem): boolean {
);
}
function isSameTextStream(previous: PendingTextEntry, next: PendingTextEntry): boolean {
if (previous.item.type !== next.item.type) {
return false;
}
if (previous.item.type === "assistant_message" && next.item.type === "assistant_message") {
return previous.item.messageId === next.item.messageId;
}
return true;
}
export class AgentStreamCoalescer {
private readonly buffers = new Map<string, PendingAgentStreamBuffer>();
private readonly onFlush: (payload: AgentStreamCoalescerFlush) => void;
@@ -241,7 +251,7 @@ export class AgentStreamCoalescer {
previous &&
previous.kind === "text" &&
entry.kind === "text" &&
previous.item.type === entry.item.type &&
isSameTextStream(previous, entry) &&
previous.provider === entry.provider &&
previous.turnId === entry.turnId
) {

View File

@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type {
AgentClient,
AgentFeature,
AgentModelDefinition,
AgentMode,
AgentSessionConfig,
ProviderCatalog,
} from "./agent-sdk-types.js";
@@ -41,6 +43,7 @@ const mockState = vi.hoisted(() => {
},
isCommandAvailable: vi.fn(async (_command: string) => false),
runtimeModels: new Map<string, AgentModelDefinition[]>(),
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
reset() {
this.constructorArgs.claude = [];
this.constructorArgs.codex = [];
@@ -52,6 +55,7 @@ const mockState = vi.hoisted(() => {
this.isCommandAvailable.mockReset();
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
this.runtimeModels.clear();
this.cursorListFeaturesConfigs = [];
},
};
});
@@ -378,6 +382,19 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
async isAvailable(): Promise<boolean> {
return true;
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
mockState.cursorListFeaturesConfigs.push(config);
return [
{
type: "select",
id: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
},
];
}
},
}));
@@ -697,6 +714,41 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
expect(mockState.constructorArgs.genericAcp).toEqual([]);
});
test("wrapped cursor client lists ACP features through the inner provider", async () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
cursor: {
extends: "acp",
label: "Cursor",
command: ["cursor-agent", "acp"],
},
},
});
const client = registry.cursor.createClient(logger);
await expect(
client.listFeatures?.({
provider: "cursor",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "select",
id: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
},
]);
expect(mockState.cursorListFeaturesConfigs).toEqual([
{
provider: "acp",
cwd: "/tmp/cursor",
},
]);
});
test("traecli provider extending acp uses TraeACPAgentClient", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {

View File

@@ -398,6 +398,7 @@ function wrapClientProvider(
): AgentClient {
const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner);
const listFeatures = inner.listFeatures?.bind(inner);
return {
provider,
@@ -441,6 +442,9 @@ function wrapClientProvider(
},
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listFeatures: listFeatures
? async (config) => await listFeatures({ ...config, provider: inner.provider })
: undefined,
listImportableSessions: listImportableSessions
? async (options) => await listImportableSessions(options)
: undefined,

View File

@@ -229,7 +229,7 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsRewindBoth: false,
};
const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
fs: {
readTextFile: true,
writeTextFile: true,
@@ -237,6 +237,18 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true,
};
export type ACPClientCapabilityMeta = Record<string, unknown>;
export function buildACPClientCapabilities(meta?: ACPClientCapabilityMeta): ACPClientCapabilities {
if (!meta || Object.keys(meta).length === 0) {
return BASE_ACP_CLIENT_CAPABILITIES;
}
return {
...BASE_ACP_CLIENT_CAPABILITIES,
_meta: meta,
};
}
// Suppress interactive auth side-effects (e.g. Gemini CLI opening a Google
// sign-in URL in the browser) when probing an ACP agent for models/modes.
// NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it.
@@ -359,6 +371,7 @@ interface ACPAgentClientOptions {
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
configFeatureOptions?: ACPConfigFeatureOption[];
clientCapabilityMeta?: ACPClientCapabilityMeta;
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
@@ -387,6 +400,7 @@ interface ACPAgentSessionOptions {
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
configFeatureOptions?: ACPConfigFeatureOption[];
clientCapabilityMeta?: ACPClientCapabilityMeta;
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
@@ -486,7 +500,7 @@ interface ConfigOptionSelector {
export interface ACPConfigFeatureOption {
id: string;
configId: string;
category: string;
category?: string;
label: string;
description?: string;
tooltip?: string;
@@ -694,6 +708,7 @@ export class ACPAgentClient implements AgentClient {
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly configFeatureOptions: ACPConfigFeatureOption[];
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
@@ -727,6 +742,7 @@ export class ACPAgentClient implements AgentClient {
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.configFeatureOptions = options.configFeatureOptions ?? [];
this.clientCapabilityMeta = options.clientCapabilityMeta;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
@@ -754,6 +770,7 @@ export class ACPAgentClient implements AgentClient {
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions,
clientCapabilityMeta: this.clientCapabilityMeta,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
@@ -802,6 +819,7 @@ export class ACPAgentClient implements AgentClient {
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions,
clientCapabilityMeta: this.clientCapabilityMeta,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
@@ -1039,7 +1057,7 @@ export class ACPAgentClient implements AgentClient {
Promise.race([
transport.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES,
clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
clientInfo: { name: "Paseo", version: "dev" },
}),
transport.spawnError,
@@ -1251,6 +1269,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly configFeatureOptions: ACPConfigFeatureOption[];
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
@@ -1315,6 +1334,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.configFeatureOptions = options.configFeatureOptions ?? [];
this.clientCapabilityMeta = options.clientCapabilityMeta;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
@@ -1890,16 +1910,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}: {
response: { configOptions: SessionConfigOption[] };
configId: string;
category: string;
category?: string;
requestedValue: string;
label: string;
}): string {
this.configOptions = this.transformConfigOptions(response.configOptions);
const responseOption = findSelectConfigOption({
configOptions: this.configOptions,
category,
id: configId,
});
const responseOption =
category === undefined
? findSelectConfigOptionById({ configOptions: this.configOptions, id: configId })
: findSelectConfigOption({
configOptions: this.configOptions,
category,
id: configId,
});
if (responseOption?.currentValue != null) {
return responseOption.currentValue;
}
@@ -2297,7 +2320,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const initialize = await this.runACPRequest(() =>
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES,
clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
clientInfo: { name: "Paseo", version: "dev" },
}),
);
@@ -2743,6 +2766,19 @@ function findSelectConfigOption({
return option ?? null;
}
function findSelectConfigOptionById({
configOptions,
id,
}: {
configOptions: SessionConfigOption[] | null | undefined;
id: string;
}): SelectConfigOption | null {
const option = configOptions?.find(
(entry): entry is SelectConfigOption => entry.type === "select" && entry.id === id,
);
return option ?? null;
}
function findSelectConfigFeatureOption(
configOptions: SessionConfigOption[] | null | undefined,
featureOption: ACPConfigFeatureOption,
@@ -2751,7 +2787,7 @@ function findSelectConfigFeatureOption(
(entry): entry is SelectConfigOption =>
entry.type === "select" &&
entry.id === featureOption.configId &&
entry.category === featureOption.category,
(featureOption.category === undefined || entry.category === featureOption.category),
);
return option ?? null;
}

View File

@@ -9,6 +9,8 @@ import { ClaudeAgentClient } from "./agent.js";
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
const MATERIALIZED_PNG_PATH_PATTERN =
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
interface ClaudeImageTestSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
@@ -163,12 +165,17 @@ describe("Claude tool_result image rendering", () => {
const [imageMessage, ...extraImages] = imageMessages(timelineItems);
expect(extraImages).toEqual([]);
const source = markdownImageSource(imageMessage);
expect(source).toMatch(/paseo-attachments[\\/][0-9a-f]{64}\.png$/);
expect(existsSync(source)).toBe(true);
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
rmSync(source, { force: true });
let source: string | undefined;
try {
source = markdownImageSource(imageMessage);
expect(source).toMatch(MATERIALIZED_PNG_PATH_PATTERN);
expect(existsSync(source)).toBe(true);
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
} finally {
if (source) {
rmSync(source, { force: true });
}
}
});
test("replays the image as assistant markdown through history conversion", async () => {
@@ -179,12 +186,17 @@ describe("Claude tool_result image rendering", () => {
const [imageMessage, ...extraImages] = imageMessages(items);
expect(extraImages).toEqual([]);
const source = markdownImageSource(imageMessage);
expect(source).toMatch(/paseo-attachments[\\/][0-9a-f]{64}\.png$/);
expect(existsSync(source)).toBe(true);
expect(JSON.stringify(items)).not.toContain(ONE_BY_ONE_PNG_BASE64);
rmSync(source, { force: true });
let source: string | undefined;
try {
source = markdownImageSource(imageMessage);
expect(source).toMatch(MATERIALIZED_PNG_PATH_PATTERN);
expect(existsSync(source)).toBe(true);
expect(JSON.stringify(items)).not.toContain(ONE_BY_ONE_PNG_BASE64);
} finally {
if (source) {
rmSync(source, { force: true });
}
}
});
test("keeps base64 out of an errored tool_result that carries an image", async () => {
@@ -198,12 +210,17 @@ describe("Claude tool_result image rendering", () => {
const [imageMessage, ...extraImages] = imageMessages(timelineItems);
expect(extraImages).toEqual([]);
const source = markdownImageSource(imageMessage);
expect(existsSync(source)).toBe(true);
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
expect(JSON.stringify(events)).toContain("[image]");
rmSync(source, { force: true });
let source: string | undefined;
try {
source = markdownImageSource(imageMessage);
expect(existsSync(source)).toBe(true);
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
expect(JSON.stringify(events)).toContain("[image]");
} finally {
if (source) {
rmSync(source, { force: true });
}
}
});
test("emits one image message per image block in a multi-image tool_result", async () => {
@@ -216,12 +233,16 @@ describe("Claude tool_result image rendering", () => {
.map((event) => (event as { item: AgentTimelineItem }).item);
const sources = imageMessages(timelineItems).map(markdownImageSource);
expect(sources).toHaveLength(2);
// Identical bytes materialize to one content-hashed file (idempotent), one message per block.
expect(new Set(sources).size).toBe(1);
expect(existsSync(sources[0])).toBe(true);
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
rmSync(sources[0], { force: true });
try {
expect(sources).toHaveLength(2);
// Identical bytes materialize to one content-hashed file (idempotent), one message per block.
expect(new Set(sources).size).toBe(1);
expect(existsSync(sources[0])).toBe(true);
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
} finally {
for (const source of sources) {
rmSync(source, { force: true });
}
}
});
});

View File

@@ -1,47 +1,120 @@
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { beforeAll, describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
canRunRealProvider,
createRealProviderClient,
getRealProviderConfig,
} from "../../daemon-e2e/real-provider-test-config.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
describe("Codex app-server provider (real)", () => {
let canRun = false;
let canRunOpenRouter = false;
beforeAll(async () => {
canRun = await canRunRealProvider("codex");
canRunOpenRouter = await canRunRealProvider("codex");
});
beforeEach((context) => {
if (!canRun) {
test("lists models and runs a simple prompt", async (context) => {
if (!canRunOpenRouter) {
context.skip();
}
});
test("lists models and runs a simple prompt", async () => {
const client = createRealProviderClient("codex", createTestLogger());
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-"));
const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false });
expect(models.length).toBeGreaterThan(0);
const session = await client.createSession({
...getRealProviderConfig("codex"),
cwd,
modeId: "auto",
});
try {
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false });
expect(models.length).toBeGreaterThan(0);
const session = await client.createSession({
...getRealProviderConfig("codex"),
cwd,
modeId: "auto",
});
try {
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0);
const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0);
} finally {
await session.close();
}
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("keeps a real MultiAgentV2 child inside its parent turn", async () => {
const client = new CodexAppServerAgentClient(createTestLogger());
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-multi-agent-v2-e2e-"));
try {
const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false });
const model = models.find((candidate) => candidate.isDefault) ?? models[0];
if (!model) {
throw new Error("Native Codex app-server returned no models");
}
const session = await client.createSession({
provider: "codex",
modeId: "full-access",
model: model.id,
cwd,
thinkingOptionId: "medium",
extra: { codex: { features: { multi_agent_v2: true } } },
});
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
try {
const result = await session.run(`
Use collaboration.spawn_agent exactly once with task_name "sentinel_child", fork_turns "none",
and this task: "Reply with exactly CHILD_SENTINEL and do nothing else."
Wait for that child to finish with collaboration.wait_agent. Do not emit any assistant text before
the child finishes. After it finishes, reply with exactly ROOT_SENTINEL. Never repeat
CHILD_SENTINEL in your own response.
`);
const completedChildIndex = events.findIndex(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed" &&
event.item.detail.type === "sub_agent" &&
event.item.detail.log.includes("CHILD_SENTINEL"),
);
const rootMessageIndexes = events.flatMap((event, index) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
);
const terminalIndexes = events.flatMap((event, index) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
? [index]
: [],
);
expect(result.finalText.trim()).toBe("ROOT_SENTINEL");
expect(
result.timeline.findLast(
(item) => item.type === "tool_call" && item.detail.type === "sub_agent",
),
).toMatchObject({ status: "completed" });
const topLevelAssistantText = result.timeline
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.join("");
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
expect(terminalIndexes).toHaveLength(1);
expect(terminalIndexes[0]).toBeGreaterThan(rootMessageIndexes.at(-1) ?? -1);
} finally {
unsubscribe();
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});

View File

@@ -1527,6 +1527,599 @@ describe("Codex app-server provider", () => {
});
});
test("keeps the parent running when a MultiAgentV2 sub-agent finishes", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation, then report the result.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "spawn-child-1",
threadId: "child-thread-1",
agentPath: "/root/child",
});
appServer.says({
threadId: "child-thread-1",
itemId: "child-message-1",
text: "Child findings.",
});
appServer.completeTurn({ threadId: "child-thread-1" });
appServer.says({
threadId: "thread-1",
itemId: "parent-message-1",
text: "Parent report.",
chunks: ["Parent ", "report."],
});
appServer.completeTurn();
const result = await resultPromise;
expect(result.finalText).toBe("Parent report.");
const assistantMessages = result.timeline.filter((item) => item.type === "assistant_message");
expect(assistantMessages.map((item) => item.messageId)).toEqual([
"parent-message-1",
"parent-message-1",
]);
expect(assistantMessages.map((item) => item.text).join("")).toBe("Parent report.");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("returns only the latest assistant item without its visual boundary", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Report twice, then finish.");
await appServer.waitForTurnStart();
appServer.says({
threadId: "thread-1",
itemId: "first-parent-message",
text: "First report.",
});
appServer.says({
threadId: "thread-1",
itemId: "second-parent-message",
text: "Second report.",
chunks: ["", "Second report."],
});
appServer.completeTurn();
const result = await resultPromise;
expect(result.finalText).toBe("Second report.");
expect(result.finalText).not.toContain("---");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("returns only the latest id-less assistant item", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Report twice, then finish.");
await appServer.waitForTurnStart();
appServer.says({ threadId: "thread-1", text: "First report." });
appServer.says({ threadId: "thread-1", text: "Second report." });
appServer.completeTurn();
const result = await resultPromise;
expect(result.finalText).toBe("Second report.");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("replays MultiAgentV2 child activity that arrives before its parent mapping", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation, then report the result.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "child-thread-early" });
appServer.says({
threadId: "child-thread-early",
itemId: "child-message-early",
text: "Early child findings.",
});
appServer.completeTurn({ threadId: "child-thread-early" });
appServer.startsSubAgent({
callId: "spawn-child-early",
threadId: "child-thread-early",
agentPath: "/root/early-child",
});
appServer.says({
threadId: "thread-1",
itemId: "parent-message-after-early-child",
text: "Parent report after replay.",
});
appServer.completeTurn();
const result = await resultPromise;
expect(result.finalText).toBe("Parent report after replay.");
expect(result.timeline.filter((item) => item.type === "assistant_message")).toEqual([
{
type: "assistant_message",
messageId: "parent-message-after-early-child",
text: "Parent report after replay.",
},
]);
expect(result.timeline.findLast((item) => item.type === "tool_call")).toMatchObject({
type: "tool_call",
callId: "spawn-child-early",
status: "completed",
detail: {
type: "sub_agent",
log: "[Assistant] Early child findings.",
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps MultiAgentV2 interaction and interruption on the original child card", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "spawn-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
});
appServer.beginsSubAgentActivity({
callId: "message-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interacted",
});
appServer.completesSubAgentActivity({
callId: "message-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interacted",
});
appServer.says({
threadId: "child-thread-stable",
itemId: "stable-child-message",
text: "Still on the same card.",
});
appServer.beginsSubAgentActivity({
callId: "interrupt-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interrupted",
});
appServer.completesSubAgentActivity({
callId: "interrupt-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interrupted",
});
appServer.completeTurn();
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(
new Set(["spawn-child-stable"]),
);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-stable",
status: "canceled",
detail: {
type: "sub_agent",
log: "[Assistant] Still on the same card.",
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not reopen a completed MultiAgentV2 child on activity completion", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.completeTurn({ threadId: "child-thread-fast" });
const activity = {
callId: "spawn-child-fast",
threadId: "child-thread-fast",
agentPath: "/root/fast-child",
kind: "started" as const,
};
appServer.beginsSubAgentActivity(activity);
appServer.completesSubAgentActivity(activity);
appServer.completeTurn();
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-fast",
status: "completed",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("preserves a completed child status when replaying a late compaction", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.completeTurn({ threadId: "child-late-compaction" });
appServer.completesCompaction({
threadId: "child-late-compaction",
itemId: "late-child-compaction",
});
appServer.startsSubAgent({
callId: "spawn-child-late-compaction",
threadId: "child-late-compaction",
agentPath: "/root/late-compaction",
});
appServer.completeTurn();
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-late-compaction",
status: "completed",
detail: { type: "sub_agent", log: "[Compacted]" },
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("projects legacy child tools into one stable sub-agent log", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the implementation.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "spawn-legacy-tool-child",
threadId: "legacy-tool-child",
agentPath: "/root/legacy-tool-child",
});
const command = {
threadId: "legacy-tool-child",
callId: "legacy-child-command",
command: "printf child",
output: "child output",
};
appServer.runsLegacyCommand(command);
appServer.completesCommand(command);
appServer.appliesLegacyPatch({
threadId: "legacy-tool-child",
callId: "legacy-child-patch",
path: "/workspace/project/src/child.ts",
diff: "@@\n-old\n+new\n",
});
appServer.completeTurn({ threadId: "legacy-tool-child" });
appServer.completeTurn();
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(
new Set(["spawn-legacy-tool-child"]),
);
const finalToolCall = toolCalls.at(-1);
expect(finalToolCall).toMatchObject({
callId: "spawn-legacy-tool-child",
status: "completed",
detail: { type: "sub_agent" },
});
if (finalToolCall?.detail.type === "sub_agent") {
expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1);
expect(finalToolCall.detail.log).toContain("[Edit]");
}
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps nested MultiAgentV2 output inside the root sub-agent card", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-root",
kind: "started",
agentThreadId: "child-thread-root",
agentPath: "/root/child",
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-root",
item: {
type: "subAgentActivity",
id: "spawn-grandchild",
kind: "started",
agentThreadId: "grandchild-thread",
agentPath: "/root/child/grandchild",
},
});
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "grandchild-thread",
itemId: "grandchild-message",
delta: "Grandchild findings.",
});
asInternals(session).handleNotification("turn/completed", {
threadId: "grandchild-thread",
turn: { status: "completed" },
});
const beforeParentCompletes = events
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
.map((event) => event.item);
expect(new Set(beforeParentCompletes.map((item) => item.callId))).toEqual(
new Set(["spawn-child-root"]),
);
expect(beforeParentCompletes.at(-1)).toMatchObject({
callId: "spawn-child-root",
status: "running",
detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") },
});
asInternals(session).handleNotification("turn/completed", {
threadId: "child-thread-root",
turn: { status: "completed" },
});
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: { callId: "spawn-child-root", status: "completed" },
});
});
test("never treats an unmapped foreign terminal as the root terminal", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("turn/completed", {
threadId: "unmapped-child-thread",
turn: { status: "completed" },
});
expect(events).toEqual([]);
asInternals(session).handleNotification("turn/completed", {
threadId: "test-thread",
turn: { status: "completed" },
});
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
asInternals(session).handleNotification("turn/started", {
threadId: "test-thread",
turn: { id: "next-root-turn" },
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-reused-foreign-thread",
kind: "started",
agentThreadId: "unmapped-child-thread",
agentPath: "/root/reused-child",
},
});
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: { callId: "spawn-reused-foreign-thread", status: "running" },
});
});
test("routes msg-scoped legacy Codex events to their child thread", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-legacy-envelope-child",
kind: "started",
agentThreadId: "legacy-envelope-child",
agentPath: "/root/legacy-envelope-child",
},
});
asInternals(session).handleNotification("codex/event/exec_command_begin", {
msg: {
type: "exec_command_begin",
threadId: "legacy-envelope-child",
call_id: "child-command",
command: "pwd",
},
});
asInternals(session).handleNotification("codex/event/task_complete", {
msg: {
type: "task_complete",
thread_id: "legacy-envelope-child",
},
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "child-command",
),
).toBe(false);
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0);
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "spawn-legacy-envelope-child",
status: "completed",
},
});
asInternals(session).handleNotification("codex/event/task_complete", {
msg: { type: "task_complete" },
});
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
});
test("never replaces the root identity with an early child thread start", () => {
const session = createSession();
asInternals(session).handleNotification("thread/started", {
thread: { id: "child-thread-started-early" },
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-thread-started-early",
kind: "started",
agentThreadId: "child-thread-started-early",
agentPath: "/root/early-thread",
},
});
expect(session.currentThreadId).toBe("test-thread");
});
test("does not leak aggregate child telemetry into the root timeline", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-telemetry",
kind: "started",
agentThreadId: "child-thread-telemetry",
agentPath: "/root/telemetry-child",
},
});
const eventCountAfterSpawn = events.length;
asInternals(session).handleNotification("turn/plan/updated", {
threadId: "child-thread-telemetry",
plan: [{ step: "Child-only plan", status: "inProgress" }],
});
expect(events).toHaveLength(eventCountAfterSpawn);
});
test("keeps child context compaction inside the child card", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-compaction",
kind: "started",
agentThreadId: "child-thread-compaction",
agentPath: "/root/compacting-child",
},
});
asInternals(session).handleNotification("item/started", {
threadId: "child-thread-compaction",
item: { type: "contextCompaction", id: "child-compaction" },
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-compaction",
item: { type: "contextCompaction", id: "child-compaction" },
});
const timelineItems = events.flatMap((event) =>
event.type === "timeline" ? [event.item] : [],
);
expect(timelineItems.every((item) => item.type === "tool_call")).toBe(true);
expect(
timelineItems.every(
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
),
).toBe(true);
expect(timelineItems.at(-1)).toMatchObject({
type: "tool_call",
detail: { type: "sub_agent", log: "[Compacted]" },
});
});
test("keeps the parent sub-agent running when a child command fails during the child turn", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
@@ -1673,6 +2266,178 @@ describe("Codex app-server provider", () => {
]);
});
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string) => {
if (method !== "thread/read") {
return {};
}
return {
thread: {
turns: [
{
items: [
{
type: "collabAgentToolCall",
id: "legacy-spawn-history",
tool: "spawnAgent",
status: "completed",
prompt: "Legacy child",
receiverThreadIds: ["legacy-child-thread"],
agentsStates: { "legacy-child-thread": { status: "completed" } },
},
{
type: "subAgentActivity",
id: "v2-spawn-history",
kind: "started",
agentThreadId: "v2-child-thread",
agentPath: "/root/v2-child",
},
],
},
],
},
};
}),
};
await asInternals(session).loadPersistedHistory();
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(
history
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
.map((event) => event.item),
).toMatchObject([
{
callId: "legacy-spawn-history",
status: "completed",
detail: { type: "sub_agent", description: "Legacy child" },
},
{
callId: "v2-spawn-history",
status: "completed",
detail: { type: "sub_agent", description: "/root/v2-child" },
},
]);
const liveEvents: AgentStreamEvent[] = [];
session.subscribe((event) => liveEvents.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "v2-interaction-after-resume",
kind: "interacted",
agentThreadId: "v2-child-thread",
agentPath: "/root/v2-child",
},
});
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "v2-child-thread",
itemId: "v2-child-message-after-resume",
delta: "More findings after resume.",
});
const liveToolCalls = liveEvents.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item] : [],
);
expect(new Set(liveToolCalls.map((item) => item.callId))).toEqual(
new Set(["v2-spawn-history"]),
);
expect(liveToolCalls.at(-1)).toMatchObject({
status: "running",
detail: { type: "sub_agent", log: "[Assistant] More findings after resume." },
});
liveEvents.length = 0;
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "legacy-child-thread",
itemId: "legacy-child-message-after-resume",
delta: "Legacy findings after resume.",
});
expect(liveEvents.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "legacy-spawn-history",
status: "running",
detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." },
},
});
});
test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string) => {
if (method !== "thread/read") {
return {};
}
return {
thread: {
turns: [
{
items: [
{
type: "subAgentActivity",
id: "child-started-history",
kind: "started",
agentThreadId: "history-child-thread",
agentPath: "/root/history-child",
timestamp: "2026-07-09T10:00:00.000Z",
},
{
type: "subAgentActivity",
id: "child-interacted-history",
kind: "interacted",
agentThreadId: "history-child-thread",
agentPath: "/root/history-child",
timestamp: "2026-07-09T10:01:00.000Z",
},
{
type: "subAgentActivity",
id: "child-interrupted-history",
kind: "interrupted",
agentThreadId: "history-child-thread",
agentPath: "/root/history-child",
timestamp: "2026-07-09T10:02:00.000Z",
},
],
},
],
},
};
}),
};
await asInternals(session).loadPersistedHistory();
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: "codex",
timestamp: "2026-07-09T10:00:00.000Z",
item: expect.objectContaining({
type: "tool_call",
callId: "child-started-history",
status: "canceled",
detail: expect.objectContaining({
type: "sub_agent",
description: "/root/history-child",
}),
}),
},
]);
});
test("uses Codex turn timestamps for timestamp-less persisted history items", async () => {
const session = createSession();
session.client = {
@@ -2354,7 +3119,7 @@ describe("Codex app-server provider", () => {
expect(event.item.text).not.toContain("data:image");
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
const source = markdownImageSource(event.item.text);
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
expect(source).toMatch(/paseo-attachments(?:-[^\\/]+)?[\\/].+\.png$/);
expect(existsSync(source)).toBe(true);
rmSync(source, { force: true });
});
@@ -2470,7 +3235,7 @@ describe("Codex app-server provider", () => {
}
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
const source = markdownImageSource(imageEvent.item.text);
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
expect(source).toMatch(/paseo-attachments(?:-[^\\/]+)?[\\/].+\.png$/);
expect(existsSync(source)).toBe(true);
rmSync(source, { force: true });
appServer.assertNoErrors();

View File

@@ -6,6 +6,25 @@ import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
interface FakeSubAgentActivity {
callId: string;
threadId: string;
agentPath: string;
kind: "started" | "interacted" | "interrupted";
parentThreadId?: string;
}
interface FakeLegacyCommand {
threadId: string;
callId: string;
command: string;
output: string;
}
interface FakeLegacyPatch {
threadId: string;
callId: string;
path: string;
diff: string;
}
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
@@ -18,7 +37,21 @@ export interface FakeCodexAppServer {
assertNoErrors(): void;
waitForTurnStart(): Promise<JsonObject>;
nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void;
completeTurn(params?: { threadId?: string }): void;
startsSubAgent(params: {
callId: string;
threadId: string;
agentPath: string;
parentThreadId?: string;
}): void;
beginsSubAgentActivity(params: FakeSubAgentActivity): void;
completesSubAgentActivity(params: FakeSubAgentActivity): void;
completesCompaction(params: { threadId: string; itemId: string }): void;
runsLegacyCommand(params: FakeLegacyCommand): void;
appliesLegacyPatch(params: FakeLegacyPatch): void;
completesCommand(params: FakeLegacyCommand): void;
says(params: { threadId: string; itemId?: string; text: string; chunks?: string[] }): void;
requestCommandApproval(params: {
itemId: string;
threadId: string;
@@ -195,6 +228,34 @@ export function createFakeCodexAppServer(
});
}
function writeSubAgentActivity(
method: "item/started" | "item/completed",
params: FakeSubAgentActivity,
): void {
writeNotification(method, {
threadId: params.parentThreadId ?? "thread-1",
item: {
type: "subAgentActivity",
id: params.callId,
kind: params.kind,
agentThreadId: params.threadId,
agentPath: params.agentPath,
},
});
}
function writeNotification(method: string, params: JsonObject): void {
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
}
function completeItem(threadId: string, item: JsonObject): void {
writeNotification("item/completed", { threadId, item });
}
function writeLegacyEvent(threadId: string, method: string, msg: JsonObject): void {
writeNotification(method, { threadId, msg });
}
return {
child,
recordedRollbacks,
@@ -215,6 +276,17 @@ export function createFakeCodexAppServer(
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
},
startsTurn(params) {
child.stdout.write(
`${JSON.stringify({
method: "turn/started",
params: {
threadId: params.threadId,
turn: { id: params.turnId ?? `turn-${params.threadId}` },
},
})}\n`,
);
},
completeTurn(params = {}) {
child.stdout.write(
`${JSON.stringify({
@@ -223,6 +295,88 @@ export function createFakeCodexAppServer(
})}\n`,
);
},
startsSubAgent(params) {
writeSubAgentActivity("item/completed", { ...params, kind: "started" });
},
beginsSubAgentActivity(params) {
writeSubAgentActivity("item/started", params);
},
completesSubAgentActivity(params) {
writeSubAgentActivity("item/completed", params);
},
completesCompaction(params) {
completeItem(params.threadId, { type: "contextCompaction", id: params.itemId });
},
runsLegacyCommand(params) {
writeLegacyEvent(params.threadId, "codex/event/exec_command_begin", {
type: "exec_command_begin",
call_id: params.callId,
command: params.command,
});
writeLegacyEvent(params.threadId, "codex/event/exec_command_output_delta", {
type: "exec_command_output_delta",
call_id: params.callId,
chunk: params.output,
});
writeLegacyEvent(params.threadId, "codex/event/exec_command_end", {
type: "exec_command_end",
call_id: params.callId,
command: params.command,
exit_code: 0,
success: true,
});
},
appliesLegacyPatch(params) {
const changes = [
{
path: params.path,
kind: "modify",
unified_diff: params.diff,
},
];
for (const [method, type] of [
["codex/event/patch_apply_begin", "patch_apply_begin"],
["codex/event/patch_apply_end", "patch_apply_end"],
] as const) {
writeLegacyEvent(params.threadId, method, {
type,
call_id: params.callId,
changes,
...(type === "patch_apply_end" ? { success: true } : {}),
});
}
},
completesCommand(params) {
completeItem(params.threadId, {
type: "commandExecution",
id: params.callId,
status: "completed",
command: params.command,
aggregatedOutput: params.output,
exitCode: 0,
});
},
says(params) {
if (params.itemId) {
for (const chunk of params.chunks ?? [params.text]) {
child.stdout.write(
`${JSON.stringify({
method: "item/agentMessage/delta",
params: {
threadId: params.threadId,
itemId: params.itemId,
delta: chunk,
},
})}\n`,
);
}
}
completeItem(params.threadId, {
type: "agentMessage",
...(params.itemId ? { id: params.itemId } : {}),
text: params.text,
});
},
requestCommandApproval(params) {
const requestId = nextServerRequestId;
nextServerRequestId += 1;

View File

@@ -221,6 +221,49 @@ describe("codex tool-call mapper", () => {
});
});
it.each([
["started", "running"],
["interacted", "running"],
["interrupted", "canceled"],
] as const)("maps subAgentActivity %s into canonical sub-agent detail", (kind, status) => {
const item = mapCodexToolCallFromThreadItem({
type: "subAgentActivity",
id: `activity-${kind}`,
kind,
agentThreadId: "child-thread-1",
agentPath: "/root/investigator",
});
expect(item).toEqual({
type: "tool_call",
callId: `activity-${kind}`,
name: "Sub-agent",
status,
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "/root/investigator",
log: "",
actions: [],
},
});
});
it("preserves an empty subAgentActivity path as an empty description", () => {
const item = mapCodexToolCallFromThreadItem({
type: "subAgentActivity",
id: "activity-empty-path",
kind: "started",
agentThreadId: "child-thread-empty-path",
agentPath: "",
});
expect(item).toMatchObject({
detail: { type: "sub_agent", description: "" },
});
});
it("does not fail a collabAgentToolCall from child error state alone", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",

View File

@@ -193,6 +193,16 @@ const CodexCollabAgentToolCallItemSchema = z
})
.passthrough();
const CodexSubAgentActivityItemSchema = z
.object({
type: z.literal("subAgentActivity"),
id: z.string().min(1),
kind: z.enum(["started", "interacted", "interrupted"]),
agentThreadId: z.string().min(1),
agentPath: z.string(),
})
.passthrough();
const CodexToolThreadItemSchema = z.discriminatedUnion("type", [
CodexCommandExecutionItemSchema,
CodexFileChangeItemSchema,
@@ -206,6 +216,7 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
CodexMcpToolCallItemSchema,
CodexWebSearchItemSchema,
CodexCollabAgentToolCallItemSchema,
CodexSubAgentActivityItemSchema,
]);
function maybeUnwrapShellWrapperCommand(command: string): string {
@@ -977,6 +988,25 @@ function mapCollabAgentToolCallItem(
};
}
function mapSubAgentActivityItem(
item: z.infer<typeof CodexSubAgentActivityItemSchema>,
): ToolCallTimelineItem {
return {
type: "tool_call",
callId: item.id,
name: "Sub-agent",
status: item.kind === "interrupted" ? "canceled" : "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: item.agentPath,
log: "",
actions: [],
},
};
}
function mapThreadItemToNormalizedEnvelope(
item: z.infer<typeof CodexToolThreadItemSchema>,
options?: CodexMapperOptions,
@@ -1012,6 +1042,9 @@ export function mapCodexToolCallFromThreadItem(
if (parsed.data.type === "collabAgentToolCall") {
return mapCollabAgentToolCallItem(parsed.data);
}
if (parsed.data.type === "subAgentActivity") {
return mapSubAgentActivityItem(parsed.data);
}
const envelope = mapThreadItemToNormalizedEnvelope(parsed.data, options);
if (!envelope) {
return null;

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