mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215566bcb6 | ||
|
|
bc534ad09c |
@@ -239,13 +239,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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 }})
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -281,19 +276,13 @@ jobs:
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.105 - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- Browse changed files as a collapsible folder tree or flat list ([#1918](https://github.com/getpaseo/paseo/pull/1918), [#1945](https://github.com/getpaseo/paseo/pull/1945) by [@cleiter](https://github.com/cleiter))
|
||||
- Always expand agent reasoning with a new appearance setting ([#1943](https://github.com/getpaseo/paseo/pull/1943) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
### Improved
|
||||
|
||||
- Project picker finds folders with fuzzy search and native desktop browsing ([#1968](https://github.com/getpaseo/paseo/pull/1968))
|
||||
- Large workspace sidebars stay responsive ([#1966](https://github.com/getpaseo/paseo/pull/1966))
|
||||
- Generated workspace names and Git text can use MiniMax M3 ([#1955](https://github.com/getpaseo/paseo/pull/1955) by [@octo-patch](https://github.com/octo-patch))
|
||||
- Cursor now exposes thinking and fast mode ([#1952](https://github.com/getpaseo/paseo/pull/1952))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex stays active and streams correctly while subagents run ([#1967](https://github.com/getpaseo/paseo/pull/1967))
|
||||
- Android audio interruptions no longer crash voice mode or leave dictation stuck ([#1941](https://github.com/getpaseo/paseo/pull/1941))
|
||||
- Mobile sidebars stay in sync and retain swipe-to-open gestures ([#1953](https://github.com/getpaseo/paseo/pull/1953), [#1976](https://github.com/getpaseo/paseo/pull/1976))
|
||||
- Pi text-only models accept image prompts without breaking the session ([#1960](https://github.com/getpaseo/paseo/pull/1960))
|
||||
- App render failures show a retryable recovery screen instead of a blank screen ([#1924](https://github.com/getpaseo/paseo/pull/1924))
|
||||
- Pi context usage remains visible with older Oh My Pi versions ([#1886](https://github.com/getpaseo/paseo/pull/1886) by [@theslava](https://github.com/theslava))
|
||||
- Provider usage popovers no longer error when opened and closed quickly ([#1885](https://github.com/getpaseo/paseo/pull/1885) by [@theslava](https://github.com/theslava))
|
||||
- Mobile workspace menus hide desktop-only shortcut badges ([#1964](https://github.com/getpaseo/paseo/pull/1964))
|
||||
|
||||
## 0.1.104 - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -45,7 +45,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
|
||||
@@ -185,15 +185,6 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
||||
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
|
||||
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
|
||||
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
|
||||
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
|
||||
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
|
||||
correlated daemon response. The legacy `directories` response field remains a projection of the
|
||||
typed `entries` list.
|
||||
|
||||
**Binary frames (terminal stream protocol):**
|
||||
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
|
||||
@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
| `qwen3-coder-next` | Optimized for coding |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M3` | MiniMax |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# 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.
|
||||
@@ -26,8 +26,6 @@ Pi is a process-backed provider. Paseo requires the user to have the `pi` binary
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
@@ -115,8 +115,6 @@ 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.
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35152,7 +35152,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"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.105",
|
||||
"version": "0.1.104",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"@getpaseo/client": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/server": "0.1.104",
|
||||
"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.105",
|
||||
"version": "0.1.104",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/relay": "0.1.104",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36435,7 +36435,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"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.105",
|
||||
"version": "0.1.104",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37574,7 +37574,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37806,7 +37806,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37819,7 +37819,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38037,15 +38037,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/highlight": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/client": "0.1.104",
|
||||
"@getpaseo/highlight": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/relay": "0.1.104",
|
||||
"@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.105",
|
||||
"version": "0.1.104",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -51,7 +50,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.getByTestId("project-picker-input");
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -75,26 +74,6 @@ 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,
|
||||
}) => {
|
||||
@@ -102,7 +81,7 @@ test.describe("Project picker search", () => {
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill("paseo-loading-state-no-match");
|
||||
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
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;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -116,30 +102,6 @@ const test = base.extend<{
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
let projectId: string | null = null;
|
||||
try {
|
||||
await provide({
|
||||
...fixture,
|
||||
rememberProjectId: (openedProjectId) => {
|
||||
projectId = openedProjectId;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
await removeProjectPickerFixture(client, fixture, projectId);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
await resource.removeDirectory();
|
||||
}
|
||||
}
|
||||
},
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
|
||||
@@ -71,7 +71,6 @@ 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;
|
||||
}
|
||||
@@ -97,7 +96,6 @@ export interface ConfirmDialogCall {
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -159,7 +157,6 @@ 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> };
|
||||
@@ -252,10 +249,6 @@ 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 },
|
||||
@@ -270,18 +263,10 @@ 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);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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-", "");
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
|
||||
|
||||
test("Browse opens the folder selected by the desktop dialog", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: projectPickerFixture.projectPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const browse = page.getByRole("button", { name: "Browse…" });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
});
|
||||
|
||||
test("Browse owns Enter without opening the active typed path", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: null,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPickerFixture.projectPath);
|
||||
|
||||
const browse = page.getByRole("button", { name: "Browse…" });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.press("Enter");
|
||||
|
||||
const dialogOptions = await waitForDirectoryDialog(page);
|
||||
expect(dialogOptions).toEqual({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
await expect(input).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectPickerFixture.projectName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -136,7 +136,7 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
|
||||
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByTestId("project-picker-input");
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -22,7 +22,6 @@
|
||||
"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",
|
||||
|
||||
@@ -4,7 +4,13 @@ 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, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -17,8 +23,9 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { Gesture, 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";
|
||||
@@ -27,7 +34,6 @@ 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";
|
||||
@@ -38,8 +44,16 @@ 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 } from "@/contexts/horizontal-scroll-context";
|
||||
import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} 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";
|
||||
@@ -68,8 +82,6 @@ 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";
|
||||
@@ -88,7 +100,13 @@ 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 { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import {
|
||||
@@ -387,16 +405,22 @@ 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, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||
function AppContainer({
|
||||
children,
|
||||
selectedAgentId,
|
||||
chromeEnabled: chromeEnabledOverride,
|
||||
}: AppContainerProps) {
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
@@ -453,22 +477,26 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && <LeftSidebar />}
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
<ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const surface = (
|
||||
const content = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar />}
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
@@ -484,13 +512,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = isCompactLayout ? (
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
||||
) : (
|
||||
surface
|
||||
);
|
||||
if (!isCompactLayout) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SidebarModelProvider>{content}</SidebarModelProvider>;
|
||||
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||
}
|
||||
|
||||
function MobileGestureWrapper({
|
||||
@@ -500,7 +526,127 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
@@ -736,6 +882,7 @@ 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]);
|
||||
@@ -749,7 +896,30 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
pathname === "/schedules" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
function FaviconStatusSync() {
|
||||
@@ -804,7 +974,7 @@ function WorkspaceRouteNavigationBridge() {
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<MobilePanelsProvider>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
@@ -812,7 +982,7 @@ function AppShell() {
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</MobilePanelsProvider>
|
||||
</SidebarAnimationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
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 = useOpenFileExplorerGesture({
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -30,7 +30,9 @@ export function DiffScroll({
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
const closeGestureRef = useFileExplorerCloseGestureRef();
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
const animation = useExplorerSidebarAnimationOptional();
|
||||
const closeGestureRef = animation?.closeGestureRef;
|
||||
|
||||
// Register/unregister scroll offset tracking
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,9 +29,10 @@ 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 { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
@@ -90,11 +91,25 @@ 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 { gesture: closeGesture } = useCloseFileExplorerGesture();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
overlayVisible,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
@@ -107,38 +122,184 @@ 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,
|
||||
],
|
||||
[insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle],
|
||||
[
|
||||
windowWidth,
|
||||
insets.top,
|
||||
theme.colors.surfaceSidebar,
|
||||
sidebarAnimatedStyle,
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +604,17 @@ 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,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,13 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
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";
|
||||
@@ -32,14 +38,19 @@ 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 } 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 {
|
||||
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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -51,8 +62,7 @@ import {
|
||||
usePanelStore,
|
||||
} from "@/stores/panel-store";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
@@ -69,20 +79,25 @@ 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;
|
||||
statusGroups: StatusGroup[];
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
handleRefresh: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
@@ -108,6 +123,7 @@ interface SidebarLabels {
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
isOpen: boolean;
|
||||
closeSidebar: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
handleViewSchedulesNavigate: () => void;
|
||||
@@ -120,7 +136,11 @@ interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
handleViewSchedules: () => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar() {
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
selectedAgentId: _selectedAgentId,
|
||||
}: LeftSidebarProps) {
|
||||
void _selectedAgentId;
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -131,18 +151,22 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const {
|
||||
workspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
groupMode,
|
||||
shortcutModel,
|
||||
} = useSidebarModel();
|
||||
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
||||
} = useSidebarWorkspacesList({
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: workspacePlacements,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
@@ -233,7 +257,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
@@ -254,6 +278,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
@@ -531,7 +556,7 @@ function SidebarFooter({
|
||||
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
@@ -551,6 +576,7 @@ function MobileSidebar({
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
handleViewSchedulesNavigate,
|
||||
@@ -558,118 +584,268 @@ function MobileSidebar({
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
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 handleViewMore = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewMoreNavigate();
|
||||
}, [closeSidebar, handleViewMoreNavigate]);
|
||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||
|
||||
const handleViewSchedules = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
handleViewSchedulesNavigate();
|
||||
}, [closeSidebar, handleViewSchedulesNavigate]);
|
||||
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
||||
|
||||
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(
|
||||
() => ({
|
||||
paddingTop: insetsTop,
|
||||
paddingBottom: insetsBottom,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
}),
|
||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||
() => ({ 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],
|
||||
);
|
||||
|
||||
return (
|
||||
<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 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}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
isInitialLoad,
|
||||
@@ -794,7 +970,7 @@ function DesktopSidebar({
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projects={projects}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
@@ -883,6 +1059,17 @@ 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,
|
||||
},
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
||||
|
||||
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface ProjectPickerBrowseButtonProps {
|
||||
serverId: string;
|
||||
disabled: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
onError: () => void;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ 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 {
|
||||
@@ -156,36 +155,31 @@ export function ProjectPickerModal() {
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return { query: debouncedQuery, paths: [] };
|
||||
}
|
||||
if (!client) return [];
|
||||
const result = await client.getDirectorySuggestions({
|
||||
query: debouncedQuery,
|
||||
includeDirectories: true,
|
||||
includeFiles: false,
|
||||
limit: 30,
|
||||
});
|
||||
return {
|
||||
query: debouncedQuery,
|
||||
paths:
|
||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
|
||||
[],
|
||||
};
|
||||
return (
|
||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
|
||||
);
|
||||
},
|
||||
enabled: Boolean(client) && isConnected && open,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const currentSuggestions =
|
||||
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
|
||||
return buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: currentSuggestions?.paths ?? [],
|
||||
query,
|
||||
});
|
||||
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
|
||||
const options = useMemo(
|
||||
() =>
|
||||
buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
}),
|
||||
[directorySuggestionsQuery.data, query, recommendedPaths],
|
||||
);
|
||||
const hasQuery = query.trim().length > 0;
|
||||
const isSearching =
|
||||
hasQuery &&
|
||||
@@ -219,8 +213,6 @@ export function ProjectPickerModal() {
|
||||
}
|
||||
|
||||
setOpenErrorReason(getOpenProjectFailureReason(result));
|
||||
} catch {
|
||||
setOpenErrorReason("open_failed");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -240,10 +232,6 @@ export function ProjectPickerModal() {
|
||||
setOpenErrorReason(null);
|
||||
}, []);
|
||||
|
||||
const handleBrowseError = useCallback(() => {
|
||||
setOpenErrorReason("open_failed");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
@@ -274,7 +262,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
function handler(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
|
||||
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -282,6 +270,12 @@ export function ProjectPickerModal() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitActiveOption();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (options.length === 0) return;
|
||||
event.preventDefault();
|
||||
@@ -297,7 +291,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [close, open, options.length]);
|
||||
}, [close, open, options.length, submitActiveOption]);
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() => [
|
||||
@@ -336,7 +330,6 @@ export function ProjectPickerModal() {
|
||||
<View style={panelStyle}>
|
||||
<View style={headerStyle}>
|
||||
<TextInput
|
||||
testID="project-picker-input"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={handleChangeQuery}
|
||||
@@ -350,12 +343,6 @@ export function ProjectPickerModal() {
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={submitActiveOption}
|
||||
/>
|
||||
<ProjectPickerBrowseButton
|
||||
serverId={serverId}
|
||||
disabled={isSubmitting}
|
||||
onSelect={handleSelectPath}
|
||||
onError={handleBrowseError}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ProjectPickerResults
|
||||
@@ -396,15 +383,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.lg,
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
|
||||
it("puts an absolute path row first", () => {
|
||||
const options = buildProjectPickerOptions({
|
||||
recommendedPaths: ["/repo/api"],
|
||||
serverPaths: ["/repo/api"],
|
||||
serverPaths: [],
|
||||
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: ["/Users/mo/src/api"],
|
||||
serverPaths: [],
|
||||
query: "~/src",
|
||||
});
|
||||
|
||||
|
||||
@@ -38,13 +38,17 @@ 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";
|
||||
@@ -64,6 +68,7 @@ import {
|
||||
shouldShowSidebarHostLabels,
|
||||
useSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -88,8 +93,6 @@ 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,
|
||||
@@ -139,11 +142,15 @@ 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,
|
||||
@@ -221,7 +228,7 @@ function selectionForSelectedWorkspace(
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
statusGroups: StatusGroup[];
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
@@ -351,6 +358,12 @@ 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,
|
||||
@@ -527,6 +540,12 @@ 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} />
|
||||
);
|
||||
@@ -674,7 +693,7 @@ function WorkspaceRowRightGroup({
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
<WorkspaceKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -694,6 +713,97 @@ 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,
|
||||
@@ -2056,7 +2166,7 @@ function areProjectBlockSelectionsEqual(
|
||||
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||
|
||||
export function SidebarWorkspaceList({
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
collapsedProjectKeys,
|
||||
@@ -2086,7 +2196,7 @@ export function SidebarWorkspaceList({
|
||||
const content =
|
||||
groupMode === "status" ? (
|
||||
<SidebarStatusModeWrapper
|
||||
statusGroups={statusGroups}
|
||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -2114,14 +2224,14 @@ export function SidebarWorkspaceList({
|
||||
}
|
||||
|
||||
function SidebarStatusModeWrapper({
|
||||
statusGroups,
|
||||
statusWorkspacePlacements,
|
||||
projectNamesByKey,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: {
|
||||
statusGroups: StatusGroup[];
|
||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onWorkspacePress?: () => void;
|
||||
@@ -2132,7 +2242,7 @@ function SidebarStatusModeWrapper({
|
||||
|
||||
return (
|
||||
<SidebarStatusWorkspaceList
|
||||
groups={statusGroups}
|
||||
workspaces={statusWorkspacePlacements}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
@@ -2158,7 +2268,7 @@ function ProjectModeList({
|
||||
supportsMultiplicityByServerId,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
> & {
|
||||
pathname: string;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
@@ -2762,6 +2872,14 @@ 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,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarWorkspacesListResult,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
buildStatusSidebarShortcutModel,
|
||||
type SidebarShortcutModel,
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
|
||||
interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
groupMode: SidebarGroupMode;
|
||||
statusGroups: StatusGroup[];
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
shortcutModel: SidebarShortcutModel;
|
||||
}
|
||||
|
||||
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
|
||||
export function SidebarModelProvider({ children }: { children: ReactNode }) {
|
||||
const list = useSidebarWorkspacesList();
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleProjectCollapsed,
|
||||
);
|
||||
const isStatusMode = groupMode === "status";
|
||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
||||
placements: list.workspacePlacements,
|
||||
enabled: isStatusMode,
|
||||
});
|
||||
const statusGroups = useMemo(
|
||||
() =>
|
||||
isStatusMode ? buildStatusGroups(statusWorkspacePlacements, list.projectNamesByKey) : [],
|
||||
[isStatusMode, list.projectNamesByKey, statusWorkspacePlacements],
|
||||
);
|
||||
const shortcutModel = useMemo(() => {
|
||||
if (isStatusMode) {
|
||||
return buildStatusSidebarShortcutModel({
|
||||
groups: statusGroups,
|
||||
collapsedStatusGroupKeys,
|
||||
});
|
||||
}
|
||||
return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys });
|
||||
}, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]);
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...list,
|
||||
groupMode,
|
||||
statusGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
shortcutModel,
|
||||
}),
|
||||
[collapsedProjectKeys, groupMode, list, shortcutModel, statusGroups, toggleProjectCollapsed],
|
||||
);
|
||||
|
||||
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSidebarModel(): SidebarModel {
|
||||
const model = useContext(SidebarModelContext);
|
||||
if (!model) throw new Error("SidebarModelProvider is required");
|
||||
return model;
|
||||
}
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import {
|
||||
buildStatusGroups,
|
||||
buildStatusShortcutIndex,
|
||||
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";
|
||||
@@ -21,8 +25,18 @@ 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";
|
||||
@@ -33,6 +47,7 @@ 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";
|
||||
@@ -45,9 +60,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,
|
||||
});
|
||||
@@ -62,8 +77,20 @@ 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 {
|
||||
groups: StatusGroup[];
|
||||
workspaces: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
@@ -73,19 +100,31 @@ interface StatusWorkspaceListProps {
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
groups,
|
||||
workspaces,
|
||||
projectNamesByKey,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const groups = useMemo(
|
||||
() => buildStatusGroups(workspaces, projectNamesByKey),
|
||||
[workspaces, projectNamesByKey],
|
||||
);
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||
const statusShortcutIndex = useMemo(
|
||||
() =>
|
||||
showShortcutBadges
|
||||
? buildStatusShortcutIndex(
|
||||
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
|
||||
)
|
||||
: new Map<string, number>(),
|
||||
[collapsedStatusGroupKeys, groups, showShortcutBadges],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -618,7 +657,7 @@ function StatusWorkspaceActionSlot({
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
<StatusKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -636,6 +675,105 @@ 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,
|
||||
@@ -738,4 +876,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceMenuProps {
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={triggerStyle}
|
||||
accessibilityRole={isWeb ? undefined : "button"}
|
||||
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||
>
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyBranchName")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
{t("sidebar.workspace.actions.rename")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||
leading={markAsReadLeadingIcon}
|
||||
onSelect={onMarkAsRead}
|
||||
>
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
trailing={archiveTrailing}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.trigger, hovered && styles.triggerHovered];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -1,13 +1,22 @@
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
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 { 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";
|
||||
@@ -19,9 +28,8 @@ 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 { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { isWeb as platformIsWeb, 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,
|
||||
@@ -30,6 +38,31 @@ 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 {
|
||||
@@ -425,7 +458,7 @@ function WorkspaceRowTrailingActions({
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
<WorkspaceKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -445,6 +478,103 @@ 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,
|
||||
@@ -500,4 +630,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -180,7 +180,7 @@ export function TerminalPane({
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}, [settings.monoFontFamily]);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobilePanel.target);
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
||||
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||
|
||||
@@ -14,7 +14,6 @@ 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;
|
||||
@@ -123,11 +122,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
|
||||
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
@@ -191,11 +186,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
@@ -229,11 +220,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
@@ -251,19 +238,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
|
||||
it("clears targets when disabled", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={false} />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
|
||||
});
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
||||
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 { shortcutModel } = useSidebarModel();
|
||||
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 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([]);
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Keyboard, useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getLeftSidebarAnimationTargets,
|
||||
MOBILE_PANEL_STATE_AGENT,
|
||||
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
|
||||
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
|
||||
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
|
||||
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
|
||||
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
|
||||
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
|
||||
MOBILE_PANEL_TARGET_AGENT,
|
||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
||||
MOBILE_PANEL_TARGET_FILE_EXPLORER,
|
||||
shouldSettleMobilePanelTransition,
|
||||
shouldSyncSidebarAnimation,
|
||||
} from "@/utils/sidebar-animation-state";
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||
// Keeps the overlay displayed long enough for the close animation to finish
|
||||
// before React hides it.
|
||||
const OVERLAY_CLOSE_GRACE_MS = 300;
|
||||
export const MOBILE_VISUAL_PANEL_AGENT = 0;
|
||||
export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1;
|
||||
export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2;
|
||||
|
||||
interface SidebarAnimationContextValue {
|
||||
translateX: SharedValue<number>;
|
||||
backdropOpacity: SharedValue<number>;
|
||||
windowWidth: number;
|
||||
animateToOpen: () => void;
|
||||
animateToClose: () => void;
|
||||
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||
overlayVisible: boolean;
|
||||
setOverlayPeek: (peek: boolean) => void;
|
||||
isGesturing: SharedValue<boolean>;
|
||||
mobileVisualPanel: SharedValue<number>;
|
||||
mobilePanelState: SharedValue<number>;
|
||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
||||
|
||||
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
||||
if (mobileView === "agent-list") {
|
||||
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
}
|
||||
if (mobileView === "file-explorer") {
|
||||
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||
}
|
||||
return MOBILE_VISUAL_PANEL_AGENT;
|
||||
}
|
||||
|
||||
function getSettledMobilePanelState(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
||||
if (mobileView === "agent-list") {
|
||||
return MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
|
||||
}
|
||||
if (mobileView === "file-explorer") {
|
||||
return MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
|
||||
}
|
||||
return MOBILE_PANEL_STATE_AGENT;
|
||||
}
|
||||
|
||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
|
||||
// Initialize based on current state
|
||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
const translateX = useSharedValue(initialTargets.translateX);
|
||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
|
||||
const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView));
|
||||
const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView));
|
||||
const gestureAnimatingRef = useRef(false);
|
||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// React owns whether the overlay is displayed at all; the worklet owns its
|
||||
// motion. On Fabric, Reanimated re-applies its own (possibly stale) animated
|
||||
// props over React's committed props on every commit, so a settled-closed
|
||||
// overlay can ghost back on screen after a heavy commit (reanimated#9635) —
|
||||
// no committed transform/opacity value can prevent that. display lives on
|
||||
// the plain wrapper View that Reanimated never touches, so React's value is
|
||||
// authoritative: a hidden overlay stays hidden no matter what the animated
|
||||
// props revert to. overlayPeek shows the overlay during an open gesture
|
||||
// before the store flips; the close grace keeps it displayed while the
|
||||
// close animation plays.
|
||||
const [overlayPeek, setOverlayPeek] = useState(false);
|
||||
const overlayTarget = isOpen || overlayPeek;
|
||||
const [overlayVisible, setOverlayVisible] = useState(overlayTarget);
|
||||
useEffect(() => {
|
||||
if (overlayTarget) {
|
||||
setOverlayVisible(true);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [overlayTarget]);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
const prevIsOpen = useRef(isOpen);
|
||||
const prevMobileView = useRef(mobileView);
|
||||
const prevWindowWidth = useRef(windowWidth);
|
||||
|
||||
const startMobilePanelTransition = useCallback(
|
||||
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
|
||||
"worklet";
|
||||
if (nextMobileView === "agent-list") {
|
||||
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT_LIST;
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPENING;
|
||||
return;
|
||||
}
|
||||
if (nextMobileView === "file-explorer") {
|
||||
mobilePanelTarget.value = MOBILE_PANEL_TARGET_FILE_EXPLORER;
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING;
|
||||
return;
|
||||
}
|
||||
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT;
|
||||
if (mobilePanelState.value === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN) {
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING;
|
||||
return;
|
||||
}
|
||||
if (mobilePanelState.value === MOBILE_PANEL_STATE_AGENT_LIST_OPEN) {
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_CLOSING;
|
||||
return;
|
||||
}
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
|
||||
},
|
||||
[mobilePanelState, mobilePanelTarget],
|
||||
);
|
||||
|
||||
const settleMobilePanel = useCallback(
|
||||
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
|
||||
"worklet";
|
||||
if (nextMobileView === "agent-list") {
|
||||
if (
|
||||
!shouldSettleMobilePanelTransition(
|
||||
mobilePanelTarget.value,
|
||||
MOBILE_PANEL_TARGET_AGENT_LIST,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
|
||||
return;
|
||||
}
|
||||
if (nextMobileView === "file-explorer") {
|
||||
if (
|
||||
!shouldSettleMobilePanelTransition(
|
||||
mobilePanelTarget.value,
|
||||
MOBILE_PANEL_TARGET_FILE_EXPLORER,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
|
||||
return;
|
||||
}
|
||||
if (!shouldSettleMobilePanelTransition(mobilePanelTarget.value, MOBILE_PANEL_TARGET_AGENT)) {
|
||||
return;
|
||||
}
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
|
||||
},
|
||||
[mobileVisualPanel, mobilePanelState, mobilePanelTarget],
|
||||
);
|
||||
|
||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||
useEffect(() => {
|
||||
const didStateChange = shouldSyncSidebarAnimation({
|
||||
previousIsOpen: prevIsOpen.current,
|
||||
nextIsOpen: isOpen,
|
||||
previousWindowWidth: prevWindowWidth.current,
|
||||
nextWindowWidth: windowWidth,
|
||||
});
|
||||
const didMobileViewChange = prevMobileView.current !== mobileView;
|
||||
const previousIsOpen = prevIsOpen.current;
|
||||
const previousMobileView = prevMobileView.current;
|
||||
const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list";
|
||||
prevIsOpen.current = isOpen;
|
||||
prevMobileView.current = mobileView;
|
||||
prevWindowWidth.current = windowWidth;
|
||||
const didOpen = !previousIsOpen && isOpen;
|
||||
|
||||
if (!didStateChange && !didMobileViewChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (didOpen && isCompactLayout && isNative) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
|
||||
// Gesture onEnd already started the animation on the UI thread — skip to avoid
|
||||
// a second competing withTiming that can desync translateX and backdropOpacity
|
||||
// after a provider remount (e.g. theme change).
|
||||
if (gestureAnimatingRef.current) {
|
||||
gestureAnimatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||
if (isGesturing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
if (previousIsOpen !== isOpen) {
|
||||
if (isOpen) {
|
||||
if (isCompactLayout) {
|
||||
startMobilePanelTransition("agent-list");
|
||||
}
|
||||
translateX.value = withTiming(
|
||||
targets.translateX,
|
||||
{
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
},
|
||||
(finished) => {
|
||||
if (!finished) return;
|
||||
if (isCompactLayout) {
|
||||
settleMobilePanel("agent-list");
|
||||
}
|
||||
},
|
||||
);
|
||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCompactLayout && mobileView === "agent") {
|
||||
startMobilePanelTransition("agent");
|
||||
}
|
||||
translateX.value = withTiming(
|
||||
targets.translateX,
|
||||
{
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
},
|
||||
(finished) => {
|
||||
if (!finished) return;
|
||||
if (isCompactLayout && mobileView === "agent") {
|
||||
settleMobilePanel("agent");
|
||||
}
|
||||
},
|
||||
);
|
||||
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
translateX.value = targets.translateX;
|
||||
backdropOpacity.value = targets.backdropOpacity;
|
||||
if (isCompactLayout && ownsMobileViewChange) {
|
||||
settleMobilePanel(mobileView);
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
mobileView,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
isGesturing,
|
||||
isCompactLayout,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
startMobilePanelTransition,
|
||||
settleMobilePanel,
|
||||
]);
|
||||
|
||||
const animateToOpen = useCallback(() => {
|
||||
"worklet";
|
||||
startMobilePanelTransition("agent-list");
|
||||
translateX.value = withTiming(
|
||||
0,
|
||||
{
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
},
|
||||
(finished) => {
|
||||
if (!finished) return;
|
||||
settleMobilePanel("agent-list");
|
||||
},
|
||||
);
|
||||
backdropOpacity.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
|
||||
|
||||
const animateToClose = useCallback(() => {
|
||||
"worklet";
|
||||
startMobilePanelTransition("agent");
|
||||
translateX.value = withTiming(
|
||||
-windowWidth,
|
||||
{
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
},
|
||||
(finished) => {
|
||||
if (!finished) return;
|
||||
settleMobilePanel("agent");
|
||||
},
|
||||
);
|
||||
backdropOpacity.value = withTiming(0, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
|
||||
|
||||
const value = useMemo<SidebarAnimationContextValue>(
|
||||
() => ({
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
startMobilePanelTransition,
|
||||
settleMobilePanel,
|
||||
overlayVisible,
|
||||
setOverlayPeek,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
}),
|
||||
[
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
startMobilePanelTransition,
|
||||
settleMobilePanel,
|
||||
overlayVisible,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarAnimationContext.Provider value={value}>{children}</SidebarAnimationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarAnimation() {
|
||||
const context = useContext(SidebarAnimationContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.39",
|
||||
version: "3.0.38",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.39", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.38", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.118.2",
|
||||
version: "2.117.2",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.118.2", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.117.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.17",
|
||||
version: "0.1.15",
|
||||
iconId: "deepagents",
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.17"],
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.15"],
|
||||
},
|
||||
{
|
||||
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.26",
|
||||
version: "0.2.22",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.26", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.22", "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.15",
|
||||
version: "0.4.13",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.15", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.169.0",
|
||||
version: "0.164.1",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.169.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.164.1", "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.5",
|
||||
version: "0.9.2",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.5", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.2", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
version: "0.50.0",
|
||||
version: "0.49.0",
|
||||
iconId: "gemini",
|
||||
installLink: "https://geminicli.com",
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.50.0", "--acp"],
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.49.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.41",
|
||||
version: "1.0.39",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.41", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.39", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.19.8",
|
||||
version: "0.19.7",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.8", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.7", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
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.");
|
||||
}
|
||||
@@ -231,7 +231,6 @@ describe("shared sidebar workspace model", () => {
|
||||
sessions: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
[
|
||||
"main",
|
||||
@@ -247,7 +246,6 @@ describe("shared sidebar workspace model", () => {
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
workspaceAgentActivity: new Map(),
|
||||
workspaces: new Map([
|
||||
[
|
||||
"feature",
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { PrHint } from "@/git/pr-hint";
|
||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type {
|
||||
WorkspaceStructureHostPlacement,
|
||||
WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
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>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
agents?: Map<string, Agent>;
|
||||
}
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
@@ -74,6 +74,8 @@ interface EffectiveWorkspaceStatus {
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
|
||||
|
||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
||||
return (
|
||||
workspace.projectCustomName ??
|
||||
@@ -94,7 +96,7 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
agents?: Map<string, Agent>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||
@@ -127,7 +129,7 @@ function deriveEffectiveWorkspaceStatus(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
agents?: Map<string, Agent>;
|
||||
}): EffectiveWorkspaceStatus {
|
||||
if (input.workspace.status !== "done") {
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
@@ -142,7 +144,10 @@ function deriveEffectiveWorkspaceStatus(input: {
|
||||
return { status: "running", enteredAt: pendingStartedAt };
|
||||
}
|
||||
|
||||
const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id);
|
||||
const rootAgentActivity = getRootAgentWorkspaceActivity({
|
||||
workspace: input.workspace,
|
||||
agents: input.agents,
|
||||
});
|
||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||
return rootAgentActivity;
|
||||
}
|
||||
@@ -168,6 +173,28 @@ 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 {
|
||||
@@ -271,7 +298,7 @@ export function buildSidebarStatusWorkspacePlacements(input: {
|
||||
serverId: placement.serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
agents: session.agents,
|
||||
});
|
||||
|
||||
rows.push({
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -61,14 +61,12 @@ export function useSidebarWorkspaceEntry(
|
||||
(state) => {
|
||||
const workspace = selectWorkspace(state, serverId, workspaceId);
|
||||
if (!workspace) return null;
|
||||
const workspaceAgentActivity = serverId
|
||||
? state.sessions[serverId]?.workspaceAgentActivity
|
||||
: undefined;
|
||||
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
|
||||
return createSidebarWorkspaceEntry({
|
||||
serverId: serverId ?? "",
|
||||
workspace,
|
||||
pendingCreateAttempts,
|
||||
workspaceAgentActivity,
|
||||
agents,
|
||||
});
|
||||
},
|
||||
equal,
|
||||
|
||||
@@ -4,21 +4,20 @@ import {
|
||||
selectStatusModeSessions,
|
||||
type StatusModeSession,
|
||||
} from "./use-status-mode-workspaces";
|
||||
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function activityMap(): Map<string, WorkspaceAgentActivity> {
|
||||
function agentMap(): Map<string, Agent> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
|
||||
return {
|
||||
workspaces: input?.workspaces ?? workspaceMap(),
|
||||
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
|
||||
agents: input?.agents ?? agentMap(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,44 +37,34 @@ describe("status mode session selection", () => {
|
||||
["host-b", "missing", "host-a"],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
serverId: "host-b",
|
||||
workspaces: hostB.workspaces,
|
||||
workspaceAgentActivity: hostB.workspaceAgentActivity,
|
||||
},
|
||||
{
|
||||
serverId: "host-a",
|
||||
workspaces: hostA.workspaces,
|
||||
workspaceAgentActivity: hostA.workspaceAgentActivity,
|
||||
},
|
||||
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
|
||||
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps selector output equal when only wrapper objects change", () => {
|
||||
const workspaces = workspaceMap();
|
||||
const workspaceAgentActivity = activityMap();
|
||||
const agents = agentMap();
|
||||
|
||||
const previous = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
|
||||
["host-a"],
|
||||
);
|
||||
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
||||
"host-a",
|
||||
]);
|
||||
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
||||
"host-a",
|
||||
]);
|
||||
|
||||
expect(previous).not.toBe(next);
|
||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects workspace or activity index changes for selected hosts", () => {
|
||||
const workspaceAgentActivity = activityMap();
|
||||
it("detects workspace or agent map changes for selected hosts", () => {
|
||||
const agents = agentMap();
|
||||
const previous = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@ const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
|
||||
|
||||
interface StatusModeSessionSource {
|
||||
workspaces: SessionState["workspaces"];
|
||||
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
|
||||
agents: SessionState["agents"];
|
||||
}
|
||||
|
||||
export interface StatusModeSession {
|
||||
serverId: string;
|
||||
workspaces: SessionState["workspaces"];
|
||||
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
|
||||
agents: SessionState["agents"];
|
||||
}
|
||||
|
||||
export function selectStatusModeSessions(
|
||||
@@ -38,7 +38,7 @@ export function selectStatusModeSessions(
|
||||
statusSessions.push({
|
||||
serverId,
|
||||
workspaces: session.workspaces,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
agents: session.agents,
|
||||
});
|
||||
}
|
||||
return statusSessions;
|
||||
@@ -59,7 +59,7 @@ export function areStatusModeSessionsEqual(
|
||||
!rightSession ||
|
||||
leftSession.serverId !== rightSession.serverId ||
|
||||
leftSession.workspaces !== rightSession.workspaces ||
|
||||
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
|
||||
leftSession.agents !== rightSession.agents
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -386,7 +386,7 @@ describe("translation resources", () => {
|
||||
});
|
||||
|
||||
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
|
||||
expect(en.projectPicker.placeholder).toBe("Type to search...");
|
||||
expect(en.projectPicker.placeholder).toBe("Type a directory path...");
|
||||
expect(en.projectPicker.opening).toBe("Opening project...");
|
||||
expect(en.projectPicker.searching).toBe("Searching...");
|
||||
expect(en.projectPicker.empty).toBe("Start typing a path");
|
||||
|
||||
@@ -1093,8 +1093,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "اكتب للبحث...",
|
||||
browse: "استعراض…",
|
||||
placeholder: "اكتب مسار الدليل...",
|
||||
opening: "افتتاح المشروع...",
|
||||
searching: "جارٍ البحث...",
|
||||
empty: "ابدأ بكتابة المسار",
|
||||
|
||||
@@ -1100,8 +1100,7 @@ export const en = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Type to search...",
|
||||
browse: "Browse…",
|
||||
placeholder: "Type a directory path...",
|
||||
opening: "Opening project...",
|
||||
searching: "Searching...",
|
||||
empty: "Start typing a path",
|
||||
|
||||
@@ -1129,8 +1129,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Escriba para buscar...",
|
||||
browse: "Explorar…",
|
||||
placeholder: "Escriba una ruta de directorio...",
|
||||
opening: "Proyecto de apertura...",
|
||||
searching: "Buscando...",
|
||||
empty: "Comience a escribir una ruta",
|
||||
|
||||
@@ -1131,8 +1131,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Tapez pour rechercher...",
|
||||
browse: "Parcourir…",
|
||||
placeholder: "Tapez un chemin de répertoire...",
|
||||
opening: "Projet d'ouverture...",
|
||||
searching: "Recherche en cours...",
|
||||
empty: "Commencez à taper un chemin",
|
||||
|
||||
@@ -1107,8 +1107,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "入力して検索...",
|
||||
browse: "参照…",
|
||||
placeholder: "ディレクトリパスを入力...",
|
||||
opening: "プロジェクトを開いています...",
|
||||
searching: "検索中...",
|
||||
empty: "パスを入力してください",
|
||||
|
||||
@@ -1115,8 +1115,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Digite para pesquisar...",
|
||||
browse: "Procurar…",
|
||||
placeholder: "Digite um caminho de diretório...",
|
||||
opening: "Abrindo projeto...",
|
||||
searching: "Buscando...",
|
||||
empty: "Comece digitando um caminho",
|
||||
|
||||
@@ -1119,8 +1119,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Введите текст для поиска...",
|
||||
browse: "Обзор…",
|
||||
placeholder: "Введите путь к каталогу...",
|
||||
opening: "Открытие проекта...",
|
||||
searching: "Идет поиск...",
|
||||
empty: "Начните вводить путь",
|
||||
|
||||
@@ -1078,8 +1078,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "输入以搜索...",
|
||||
browse: "浏览…",
|
||||
placeholder: "输入目录路径...",
|
||||
opening: "正在打开 project...",
|
||||
searching: "正在搜索...",
|
||||
empty: "开始输入路径",
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -26,12 +26,10 @@ import {
|
||||
migratePanelState,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
setMobilePanelTarget,
|
||||
selectPanelVisibility,
|
||||
type DesktopSidebarState,
|
||||
type ExplorerPanelIntent,
|
||||
type MobilePanelView,
|
||||
type MobilePanelSelection,
|
||||
type PanelLayoutInput,
|
||||
type PanelVisibilityState,
|
||||
type SortOption,
|
||||
@@ -43,7 +41,6 @@ export type {
|
||||
DesktopSidebarState,
|
||||
ExplorerPanelIntent,
|
||||
MobilePanelView,
|
||||
MobilePanelSelection,
|
||||
PanelLayoutInput,
|
||||
PanelVisibilityState,
|
||||
SortOption,
|
||||
@@ -64,8 +61,8 @@ export {
|
||||
};
|
||||
|
||||
export interface PanelState {
|
||||
// Mobile: React's durable target plus the generation that owns it.
|
||||
mobilePanel: MobilePanelSelection;
|
||||
// Mobile: which panel is currently shown
|
||||
mobileView: MobilePanelView;
|
||||
|
||||
// Desktop: independent sidebar toggles
|
||||
desktop: DesktopSidebarState;
|
||||
@@ -117,19 +114,11 @@ 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
|
||||
mobilePanel: { target: "agent", revision: 0 },
|
||||
mobileView: "agent",
|
||||
|
||||
// Desktop defaults based on platform
|
||||
desktop: {
|
||||
@@ -155,17 +144,26 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
|
||||
})),
|
||||
|
||||
showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")),
|
||||
showMobileAgent: () =>
|
||||
set((state) => {
|
||||
if (state.mobileView === "agent") {
|
||||
return state;
|
||||
}
|
||||
return { mobileView: "agent" as const };
|
||||
}),
|
||||
|
||||
showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")),
|
||||
showMobileAgentList: () =>
|
||||
set((state) => {
|
||||
if (state.mobileView === "agent-list") {
|
||||
return state;
|
||||
}
|
||||
return { mobileView: "agent-list" as const };
|
||||
}),
|
||||
|
||||
toggleMobileAgentList: () =>
|
||||
set((state) =>
|
||||
setMobilePanelTargetPatch(
|
||||
state,
|
||||
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
|
||||
),
|
||||
),
|
||||
set((state) => ({
|
||||
mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list",
|
||||
})),
|
||||
|
||||
openDesktopAgentList: () =>
|
||||
set((state) => {
|
||||
@@ -199,7 +197,9 @@ export const usePanelStore = create<PanelState>()(
|
||||
openAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return setMobilePanelTargetPatch(state, "agent-list");
|
||||
return state.mobileView === "agent-list"
|
||||
? state
|
||||
: { mobileView: "agent-list" as const };
|
||||
}
|
||||
return state.desktop.agentListOpen
|
||||
? state
|
||||
@@ -209,7 +209,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
closeAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return setMobilePanelTargetPatch(state, "agent");
|
||||
return state.mobileView === "agent" ? state : { mobileView: "agent" as const };
|
||||
}
|
||||
return state.desktop.agentListOpen
|
||||
? { desktop: { ...state.desktop, agentListOpen: false } }
|
||||
@@ -219,10 +219,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
toggleAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return setMobilePanelTargetPatch(
|
||||
state,
|
||||
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
|
||||
);
|
||||
return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" };
|
||||
}
|
||||
return {
|
||||
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
|
||||
@@ -298,6 +295,7 @@ 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,
|
||||
@@ -317,7 +315,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
/**
|
||||
* Hook that provides platform-aware panel state.
|
||||
*
|
||||
* On mobile, uses the revisioned mobile panel target.
|
||||
* On mobile, uses the state machine (mobileView).
|
||||
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
|
||||
*
|
||||
* @param isMobile - Whether the current breakpoint is mobile
|
||||
|
||||
@@ -10,14 +10,13 @@ import {
|
||||
migratePanelState,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
setMobilePanelTarget,
|
||||
selectPanelVisibility,
|
||||
type PanelCoreState,
|
||||
} from "./state";
|
||||
|
||||
function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState {
|
||||
return {
|
||||
mobilePanel: { target: "agent", revision: 0 },
|
||||
mobileView: "agent",
|
||||
desktop: {
|
||||
agentListOpen: false,
|
||||
fileExplorerOpen: false,
|
||||
@@ -118,33 +117,12 @@ 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("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", () => {
|
||||
it("uses mobileView for compact layout visibility", () => {
|
||||
const state = makePanelState({
|
||||
mobilePanel: { target: "file-explorer", revision: 1 },
|
||||
mobileView: "file-explorer",
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
|
||||
});
|
||||
|
||||
@@ -158,7 +136,7 @@ describe("panel-store visibility selectors", () => {
|
||||
|
||||
it("uses desktop flags for expanded layout visibility", () => {
|
||||
const state = makePanelState({
|
||||
mobilePanel: { target: "file-explorer", revision: 1 },
|
||||
mobileView: "file-explorer",
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
|
||||
});
|
||||
|
||||
@@ -182,7 +160,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout });
|
||||
|
||||
expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
|
||||
expect(patch.mobileView).toBe("file-explorer");
|
||||
expect(patch.desktop).toBeUndefined();
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
});
|
||||
@@ -197,7 +175,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
|
||||
|
||||
expect(patch.mobilePanel).toBeUndefined();
|
||||
expect(patch.mobileView).toBeUndefined();
|
||||
expect(patch.desktop?.fileExplorerOpen).toBe(true);
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
@@ -8,11 +8,6 @@ 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;
|
||||
@@ -48,7 +43,7 @@ export interface ExplorerPanelIntent extends PanelLayoutInput {
|
||||
}
|
||||
|
||||
export interface PanelCoreState {
|
||||
mobilePanel: MobilePanelSelection;
|
||||
mobileView: MobilePanelView;
|
||||
desktop: DesktopSidebarState;
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
@@ -79,8 +74,8 @@ export function selectPanelVisibility(
|
||||
): PanelVisibilityState {
|
||||
if (input.isCompact) {
|
||||
return {
|
||||
isAgentListOpen: state.mobilePanel.target === "agent-list",
|
||||
isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
|
||||
isAgentListOpen: state.mobileView === "agent-list",
|
||||
isFileExplorerOpen: state.mobileView === "file-explorer",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -97,16 +92,6 @@ 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,
|
||||
@@ -120,7 +105,7 @@ function resolveExplorerTabFromCheckout(
|
||||
}
|
||||
|
||||
export interface OpenFileExplorerPatch {
|
||||
mobilePanel?: MobilePanelSelection;
|
||||
mobileView?: MobilePanelView;
|
||||
desktop?: DesktopSidebarState;
|
||||
explorerTab: ExplorerTab;
|
||||
}
|
||||
@@ -132,7 +117,7 @@ export function buildOpenFileExplorerPatch(
|
||||
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
|
||||
if (input.isCompact) {
|
||||
return {
|
||||
mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
|
||||
mobileView: "file-explorer",
|
||||
explorerTab: resolvedTab,
|
||||
};
|
||||
}
|
||||
@@ -144,7 +129,7 @@ export function buildOpenFileExplorerPatch(
|
||||
|
||||
export type ToggleFileExplorerPatch =
|
||||
| OpenFileExplorerPatch
|
||||
| { mobilePanel: MobilePanelSelection }
|
||||
| { mobileView: MobilePanelView }
|
||||
| { desktop: DesktopSidebarState };
|
||||
|
||||
export function buildToggleFileExplorerPatch(
|
||||
@@ -156,7 +141,7 @@ export function buildToggleFileExplorerPatch(
|
||||
return buildOpenFileExplorerPatch(state, input);
|
||||
}
|
||||
if (input.isCompact) {
|
||||
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
|
||||
return { mobileView: "agent" };
|
||||
}
|
||||
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||
}
|
||||
@@ -270,12 +255,6 @@ export function migratePanelState(
|
||||
if (typeof state.explorerShowHiddenFiles !== "boolean") {
|
||||
state.explorerShowHiddenFiles = true;
|
||||
}
|
||||
if (version < 12) {
|
||||
// Compact panel position is transient UI state. Cold starts always begin
|
||||
// at content, regardless of what an older version persisted.
|
||||
delete state.mobileView;
|
||||
delete state.mobilePanel;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -37,10 +37,6 @@ 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 =
|
||||
@@ -357,7 +353,6 @@ 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
|
||||
@@ -565,7 +560,6 @@ 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(),
|
||||
@@ -1200,11 +1194,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agents: nextAgents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(nextAgents),
|
||||
},
|
||||
[serverId]: { ...session, agents: nextAgents },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -27,39 +27,6 @@ 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
|
||||
@@ -77,7 +44,11 @@ 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, agent(new Date("2026-01-02T00:00:00.000Z")));
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date(),
|
||||
} as unknown as Agent);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -124,7 +95,11 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, agent(null));
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: undefined,
|
||||
} as unknown as Agent);
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
interface SidebarAnimationSyncInput {
|
||||
previousIsOpen: boolean;
|
||||
nextIsOpen: boolean;
|
||||
previousWindowWidth: number;
|
||||
nextWindowWidth: number;
|
||||
}
|
||||
|
||||
interface SidebarAnimationTargetInput {
|
||||
isOpen: boolean;
|
||||
windowWidth: number;
|
||||
}
|
||||
|
||||
interface SidebarAnimationTargets {
|
||||
translateX: number;
|
||||
backdropOpacity: number;
|
||||
}
|
||||
|
||||
export const MOBILE_PANEL_STATE_AGENT = 0;
|
||||
export const MOBILE_PANEL_STATE_AGENT_LIST_OPENING = 1;
|
||||
export const MOBILE_PANEL_STATE_AGENT_LIST_OPEN = 2;
|
||||
export const MOBILE_PANEL_STATE_AGENT_LIST_CLOSING = 3;
|
||||
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING = 4;
|
||||
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN = 5;
|
||||
export const MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING = 6;
|
||||
|
||||
export const MOBILE_PANEL_TARGET_AGENT = 0;
|
||||
export const MOBILE_PANEL_TARGET_AGENT_LIST = 1;
|
||||
export const MOBILE_PANEL_TARGET_FILE_EXPLORER = 2;
|
||||
|
||||
const CLOSED_POSITION_TOLERANCE = 1;
|
||||
|
||||
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
|
||||
return (
|
||||
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
|
||||
);
|
||||
}
|
||||
|
||||
export function getLeftSidebarAnimationTargets(
|
||||
input: SidebarAnimationTargetInput,
|
||||
): SidebarAnimationTargets {
|
||||
return {
|
||||
translateX: input.isOpen ? 0 : -input.windowWidth,
|
||||
backdropOpacity: input.isOpen ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRightSidebarAnimationTargets(
|
||||
input: SidebarAnimationTargetInput,
|
||||
): SidebarAnimationTargets {
|
||||
return {
|
||||
translateX: input.isOpen ? 0 : input.windowWidth,
|
||||
backdropOpacity: input.isOpen ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function canOpenLeftSidebarGesture(
|
||||
mobilePanelState: number,
|
||||
translateX: number,
|
||||
windowWidth: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
return (
|
||||
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
|
||||
translateX <= -windowWidth + CLOSED_POSITION_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function canCloseLeftSidebarGesture(mobilePanelState: number): boolean {
|
||||
"worklet";
|
||||
return mobilePanelState === MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
|
||||
}
|
||||
|
||||
export function canOpenRightSidebarGesture(
|
||||
mobilePanelState: number,
|
||||
translateX: number,
|
||||
windowWidth: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
return (
|
||||
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
|
||||
translateX >= windowWidth - CLOSED_POSITION_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function canCloseRightSidebarGesture(mobilePanelState: number): boolean {
|
||||
"worklet";
|
||||
return mobilePanelState === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
|
||||
}
|
||||
|
||||
export function shouldSettleMobilePanelTransition(
|
||||
activeTarget: number,
|
||||
settledTarget: number,
|
||||
): boolean {
|
||||
"worklet";
|
||||
return activeTarget === settledTarget;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
@@ -203,13 +202,11 @@ describe("buildStatusSidebarShortcutModel", () => {
|
||||
];
|
||||
|
||||
const model = buildStatusSidebarShortcutModel({
|
||||
groups: buildStatusGroups(
|
||||
workspaces,
|
||||
new Map([
|
||||
["p1", "Project 1"],
|
||||
["p2", "Project 2"],
|
||||
]),
|
||||
),
|
||||
workspaces,
|
||||
projectNamesByKey: new Map([
|
||||
["p1", "Project 1"],
|
||||
["p2", "Project 2"],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toEqual([
|
||||
@@ -245,7 +242,8 @@ describe("buildStatusSidebarShortcutModel", () => {
|
||||
];
|
||||
|
||||
const model = buildStatusSidebarShortcutModel({
|
||||
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])),
|
||||
workspaces,
|
||||
projectNamesByKey: new Map([["p1", "Project 1"]]),
|
||||
collapsedStatusGroupKeys: new Set(["needs_input"]),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarStatusWorkspacePlacement,
|
||||
SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string;
|
||||
@@ -52,15 +53,17 @@ export function buildSidebarShortcutModel(input: {
|
||||
}
|
||||
|
||||
export function buildStatusSidebarShortcutModel(input: {
|
||||
groups: readonly StatusGroup[];
|
||||
workspaces: SidebarStatusWorkspacePlacement[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
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 input.groups) {
|
||||
for (const group of groups) {
|
||||
if (input.collapsedStatusGroupKeys?.has(group.bucket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -12,40 +12,31 @@ describe("buildWorkingDirectorySuggestions", () => {
|
||||
expect(results).toEqual(["/Users/me/projects/paseo"]);
|
||||
});
|
||||
|
||||
it("keeps fuzzy recommendation matches before de-duplicated daemon suggestions", () => {
|
||||
it("prioritizes matching recommended directories before server matches", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
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",
|
||||
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
|
||||
serverPaths: [
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/paseo",
|
||||
"/Users/me/projects/planbook",
|
||||
],
|
||||
serverPaths: [],
|
||||
query: "~/projects/pso",
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
|
||||
});
|
||||
|
||||
it("treats '~' as an active query and includes daemon suggestions", () => {
|
||||
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", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo"],
|
||||
serverPaths: ["/Users/me/documents", "/Users/me/projects"],
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { scoreMatch } from "./score-match";
|
||||
|
||||
export interface BuildWorkingDirectorySuggestionsInput {
|
||||
recommendedPaths: string[];
|
||||
serverPaths: string[];
|
||||
@@ -9,27 +7,40 @@ export interface BuildWorkingDirectorySuggestionsInput {
|
||||
export function buildWorkingDirectorySuggestions(
|
||||
input: BuildWorkingDirectorySuggestionsInput,
|
||||
): string[] {
|
||||
const query = input.query.trim();
|
||||
const rawQuery = input.query.trim();
|
||||
const recommended = uniquePaths(input.recommendedPaths);
|
||||
if (!query) {
|
||||
if (!rawQuery) {
|
||||
return recommended;
|
||||
}
|
||||
|
||||
const matchingRecommended = recommended.filter((path) =>
|
||||
recommendedPathMatchesQuery(path, query),
|
||||
);
|
||||
const normalizedQuery = normalizeQuery(rawQuery);
|
||||
const shouldFilterByQuery = normalizedQuery.length > 0;
|
||||
|
||||
// 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]);
|
||||
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;
|
||||
}
|
||||
|
||||
function uniquePaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const path of paths) {
|
||||
const trimmed = path.trim();
|
||||
for (const pathEntry of paths) {
|
||||
const trimmed = pathEntry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
@@ -39,20 +50,23 @@ function uniquePaths(paths: string[]): string[] {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function recommendedPathMatchesQuery(path: string, query: string): boolean {
|
||||
const candidate = normalizePath(path);
|
||||
const normalizedQuery = normalizePath(query);
|
||||
if (["~", "~/"].includes(normalizedQuery)) {
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
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();
|
||||
const segments = lowerPath.split("/");
|
||||
return (segments[segments.length - 1] ?? "").includes(query);
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"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.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"@getpaseo/client": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/server": "0.1.104",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/relay": "0.1.104",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.105",
|
||||
"version": "0.1.104",
|
||||
"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.105",
|
||||
"@getpaseo/highlight": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@getpaseo/client": "0.1.104",
|
||||
"@getpaseo/highlight": "0.1.104",
|
||||
"@getpaseo/protocol": "0.1.104",
|
||||
"@getpaseo/relay": "0.1.104",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -185,46 +185,6 @@ 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[] = [
|
||||
{
|
||||
|
||||
@@ -165,9 +165,6 @@ 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":
|
||||
|
||||
@@ -45,17 +45,9 @@ function assistant(
|
||||
options?: {
|
||||
provider?: AgentProvider;
|
||||
turnId?: string;
|
||||
messageId?: string;
|
||||
},
|
||||
): Extract<AgentStreamEvent, { type: "timeline" }> {
|
||||
return timeline(
|
||||
{
|
||||
type: "assistant_message",
|
||||
text,
|
||||
...(options?.messageId !== undefined ? { messageId: options.messageId } : {}),
|
||||
},
|
||||
options,
|
||||
);
|
||||
return timeline({ type: "assistant_message", text }, options);
|
||||
}
|
||||
|
||||
function reasoning(
|
||||
@@ -292,23 +284,6 @@ 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();
|
||||
|
||||
|
||||
@@ -73,16 +73,6 @@ 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;
|
||||
@@ -251,7 +241,7 @@ export class AgentStreamCoalescer {
|
||||
previous &&
|
||||
previous.kind === "text" &&
|
||||
entry.kind === "text" &&
|
||||
isSameTextStream(previous, entry) &&
|
||||
previous.item.type === entry.item.type &&
|
||||
previous.provider === entry.provider &&
|
||||
previous.turnId === entry.turnId
|
||||
) {
|
||||
|
||||
@@ -3,10 +3,8 @@ 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";
|
||||
|
||||
@@ -43,7 +41,6 @@ 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 = [];
|
||||
@@ -55,7 +52,6 @@ const mockState = vi.hoisted(() => {
|
||||
this.isCommandAvailable.mockReset();
|
||||
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
|
||||
this.runtimeModels.clear();
|
||||
this.cursorListFeaturesConfigs = [];
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -382,19 +378,6 @@ 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" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -714,41 +697,6 @@ 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: {
|
||||
|
||||
@@ -398,7 +398,6 @@ function wrapClientProvider(
|
||||
): AgentClient {
|
||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||
const importSession = inner.importSession?.bind(inner);
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -442,9 +441,6 @@ 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,
|
||||
|
||||
@@ -229,7 +229,7 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
||||
const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
||||
fs: {
|
||||
readTextFile: true,
|
||||
writeTextFile: true,
|
||||
@@ -237,18 +237,6 @@ const BASE_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.
|
||||
@@ -371,7 +359,6 @@ interface ACPAgentClientOptions {
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||
clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
@@ -400,7 +387,6 @@ interface ACPAgentSessionOptions {
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||
clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
@@ -500,7 +486,7 @@ interface ConfigOptionSelector {
|
||||
export interface ACPConfigFeatureOption {
|
||||
id: string;
|
||||
configId: string;
|
||||
category?: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
@@ -708,7 +694,6 @@ 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?: (
|
||||
@@ -742,7 +727,6 @@ 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;
|
||||
@@ -770,7 +754,6 @@ 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,
|
||||
@@ -819,7 +802,6 @@ 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,
|
||||
@@ -1057,7 +1039,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
Promise.race([
|
||||
transport.connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
|
||||
clientCapabilities: ACP_CLIENT_CAPABILITIES,
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
transport.spawnError,
|
||||
@@ -1269,7 +1251,6 @@ 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?: (
|
||||
@@ -1334,7 +1315,6 @@ 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;
|
||||
@@ -1910,19 +1890,16 @@ 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 =
|
||||
category === undefined
|
||||
? findSelectConfigOptionById({ configOptions: this.configOptions, id: configId })
|
||||
: findSelectConfigOption({
|
||||
configOptions: this.configOptions,
|
||||
category,
|
||||
id: configId,
|
||||
});
|
||||
const responseOption = findSelectConfigOption({
|
||||
configOptions: this.configOptions,
|
||||
category,
|
||||
id: configId,
|
||||
});
|
||||
if (responseOption?.currentValue != null) {
|
||||
return responseOption.currentValue;
|
||||
}
|
||||
@@ -2320,7 +2297,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const initialize = await this.runACPRequest(() =>
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
|
||||
clientCapabilities: ACP_CLIENT_CAPABILITIES,
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
);
|
||||
@@ -2766,19 +2743,6 @@ 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,
|
||||
@@ -2787,7 +2751,7 @@ function findSelectConfigFeatureOption(
|
||||
(entry): entry is SelectConfigOption =>
|
||||
entry.type === "select" &&
|
||||
entry.id === featureOption.configId &&
|
||||
(featureOption.category === undefined || entry.category === featureOption.category),
|
||||
entry.category === featureOption.category,
|
||||
);
|
||||
return option ?? null;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ 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[];
|
||||
@@ -165,17 +163,12 @@ describe("Claude tool_result image rendering", () => {
|
||||
const [imageMessage, ...extraImages] = imageMessages(timelineItems);
|
||||
expect(extraImages).toEqual([]);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
|
||||
test("replays the image as assistant markdown through history conversion", async () => {
|
||||
@@ -186,17 +179,12 @@ describe("Claude tool_result image rendering", () => {
|
||||
const [imageMessage, ...extraImages] = imageMessages(items);
|
||||
expect(extraImages).toEqual([]);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
|
||||
test("keeps base64 out of an errored tool_result that carries an image", async () => {
|
||||
@@ -210,17 +198,12 @@ describe("Claude tool_result image rendering", () => {
|
||||
const [imageMessage, ...extraImages] = imageMessages(timelineItems);
|
||||
expect(extraImages).toEqual([]);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
|
||||
test("emits one image message per image block in a multi-image tool_result", async () => {
|
||||
@@ -233,16 +216,12 @@ describe("Claude tool_result image rendering", () => {
|
||||
.map((event) => (event as { item: AgentTimelineItem }).item);
|
||||
const sources = imageMessages(timelineItems).map(markdownImageSource);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
+21
-94
@@ -1,120 +1,47 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { beforeAll, beforeEach, 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 canRunOpenRouter = false;
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRunOpenRouter = await canRunRealProvider("codex");
|
||||
canRun = await canRunRealProvider("codex");
|
||||
});
|
||||
|
||||
test("lists models and runs a simple prompt", async (context) => {
|
||||
if (!canRunOpenRouter) {
|
||||
beforeEach((context) => {
|
||||
if (!canRun) {
|
||||
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-"));
|
||||
try {
|
||||
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 result = await session.run("Say hello in one sentence.");
|
||||
expect(result.finalText.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
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);
|
||||
} 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);
|
||||
});
|
||||
|
||||
@@ -1527,599 +1527,6 @@ 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[] = [];
|
||||
@@ -2266,178 +1673,6 @@ 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 = {
|
||||
@@ -3119,7 +2354,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 });
|
||||
});
|
||||
@@ -3235,7 +2470,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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,25 +6,6 @@ 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;
|
||||
@@ -37,21 +18,7 @@ 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;
|
||||
@@ -228,34 +195,6 @@ 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,
|
||||
@@ -276,17 +215,6 @@ 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({
|
||||
@@ -295,88 +223,6 @@ 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;
|
||||
|
||||
@@ -221,49 +221,6 @@ 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",
|
||||
|
||||
@@ -193,16 +193,6 @@ 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,
|
||||
@@ -216,7 +206,6 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
||||
CodexMcpToolCallItemSchema,
|
||||
CodexWebSearchItemSchema,
|
||||
CodexCollabAgentToolCallItemSchema,
|
||||
CodexSubAgentActivityItemSchema,
|
||||
]);
|
||||
|
||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||
@@ -988,25 +977,6 @@ 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,
|
||||
@@ -1042,9 +1012,6 @@ 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;
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js";
|
||||
import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js";
|
||||
import { CursorACPAgentClient } from "./cursor-acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
describe("CursorACPAgentClient model discovery", () => {
|
||||
function fastConfigOption(currentValue: "false" | "true") {
|
||||
return {
|
||||
id: "fast",
|
||||
name: "Fast",
|
||||
type: "select" as const,
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: "false", name: "Off" },
|
||||
{ value: "true", name: "Fast" },
|
||||
],
|
||||
};
|
||||
}
|
||||
class TestCursorACPAgentClient extends CursorACPAgentClient {
|
||||
constructor(response: SessionStateResponse) {
|
||||
super({
|
||||
@@ -89,79 +77,4 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
modes: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps modern Cursor models as plain ACP ids", async () => {
|
||||
const client = new TestCursorACPAgentClient({
|
||||
sessionId: "session-1",
|
||||
models: {
|
||||
currentModelId: "composer-2.5",
|
||||
availableModels: [
|
||||
{
|
||||
modelId: "composer-2.5",
|
||||
name: "Composer 2.5",
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
configOptions: [fastConfigOption("false")],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/cursor", force: false }),
|
||||
).resolves.toEqual({
|
||||
models: [
|
||||
{
|
||||
provider: "acp",
|
||||
id: "composer-2.5",
|
||||
label: "Composer 2.5",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
thinkingOptions: undefined,
|
||||
defaultThinkingOptionId: undefined,
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes Cursor fast mode through provider features", async () => {
|
||||
const client = new TestCursorACPAgentClient({
|
||||
sessionId: "session-1",
|
||||
models: null,
|
||||
configOptions: [fastConfigOption("false")],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
provider: "acp",
|
||||
cwd: "/tmp/cursor",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "select",
|
||||
id: CURSOR_FAST_FEATURE_OPTION.id,
|
||||
label: "Fast",
|
||||
description: "Cursor fast mode",
|
||||
tooltip: "Select Cursor fast mode",
|
||||
icon: "zap",
|
||||
value: "false",
|
||||
options: [
|
||||
{
|
||||
id: "false",
|
||||
label: "Off",
|
||||
isDefault: true,
|
||||
description: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "true",
|
||||
label: "Fast",
|
||||
isDefault: false,
|
||||
description: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ACPConfigFeatureOption } from "./acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
interface CursorACPAgentClientOptions {
|
||||
@@ -13,18 +12,6 @@ interface CursorACPAgentClientOptions {
|
||||
}
|
||||
|
||||
const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
|
||||
const CURSOR_CLIENT_CAPABILITY_META = {
|
||||
parameterizedModelPicker: true,
|
||||
};
|
||||
|
||||
export const CURSOR_FAST_FEATURE_OPTION: ACPConfigFeatureOption = {
|
||||
id: "fast",
|
||||
configId: "fast",
|
||||
label: "Fast",
|
||||
description: "Cursor fast mode",
|
||||
tooltip: "Select Cursor fast mode",
|
||||
icon: "zap",
|
||||
};
|
||||
|
||||
export class CursorACPAgentClient extends GenericACPAgentClient {
|
||||
constructor(options: CursorACPAgentClientOptions) {
|
||||
@@ -38,8 +25,6 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
|
||||
// cursor-agent publishes slash commands asynchronously via available_commands_update.
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
|
||||
clientCapabilityMeta: CURSOR_CLIENT_CAPABILITY_META,
|
||||
configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
|
||||
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
type ACPClientCapabilityMeta,
|
||||
type ACPConfigFeatureOption,
|
||||
DEFAULT_ACP_CAPABILITIES,
|
||||
type ACPExtensionCommandsParser,
|
||||
} from "./acp-agent.js";
|
||||
@@ -35,8 +33,6 @@ interface GenericACPAgentClientOptions {
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
diagnosticPhaseTimeoutMs?: number;
|
||||
clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
}
|
||||
|
||||
@@ -57,8 +53,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
capabilities: buildGenericACPCapabilities(options),
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
clientCapabilityMeta: options.clientCapabilityMeta,
|
||||
configFeatureOptions: options.configFeatureOptions,
|
||||
extensionCommandsParser: options.extensionCommandsParser,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -18,9 +17,6 @@ import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../a
|
||||
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
|
||||
import { FakePi } from "./test-utils/fake-pi.js";
|
||||
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
|
||||
function createClient(pi = new FakePi()): PiRpcAgentClient {
|
||||
return new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
@@ -662,91 +658,6 @@ describe("PiRpcAgentSession", () => {
|
||||
expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]);
|
||||
});
|
||||
|
||||
test("materializes image prompts as text hints for text-only Pi models", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.setModelResult = {
|
||||
provider: "openrouter",
|
||||
id: "openai/gpt-oss-20b:free",
|
||||
name: "OpenAI: gpt-oss-20b (free)",
|
||||
input: ["text"],
|
||||
};
|
||||
|
||||
await session.setModel("openrouter/openai/gpt-oss-20b:free");
|
||||
await session.startTurn([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
|
||||
]);
|
||||
|
||||
let imagePath: string | undefined;
|
||||
try {
|
||||
expect(fakeSession.prompts).toHaveLength(1);
|
||||
const prompt = fakeSession.prompts[0]!;
|
||||
expect(prompt.imageCount).toBe(0);
|
||||
expect(prompt.message).toContain("Describe this image.");
|
||||
expect(prompt.message).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
||||
imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
|
||||
expect(imagePath).toBeTypeOf("string");
|
||||
expect(imagePath).toMatch(
|
||||
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/,
|
||||
);
|
||||
expect(existsSync(imagePath!)).toBe(true);
|
||||
} finally {
|
||||
if (imagePath) {
|
||||
rmSync(imagePath, { force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("materializes image prompts when Pi model capabilities are unknown", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
|
||||
]);
|
||||
|
||||
let imagePath: string | undefined;
|
||||
try {
|
||||
expect(fakeSession.prompts).toHaveLength(1);
|
||||
const prompt = fakeSession.prompts[0]!;
|
||||
expect(prompt.imageCount).toBe(0);
|
||||
expect(prompt.message).toContain("Describe this image.");
|
||||
imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
|
||||
expect(imagePath).toBeTypeOf("string");
|
||||
expect(existsSync(imagePath!)).toBe(true);
|
||||
} finally {
|
||||
if (imagePath) {
|
||||
rmSync(imagePath, { force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("forwards raw image prompts for vision-capable Pi models", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.setModelResult = {
|
||||
provider: "openai",
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
input: ["text", "image"],
|
||||
};
|
||||
|
||||
await session.setModel("openai/gpt-4o");
|
||||
await session.startTurn([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
|
||||
]);
|
||||
|
||||
expect(fakeSession.prompts).toEqual([
|
||||
{
|
||||
message: "Describe this image.",
|
||||
imageCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails the active turn when the Pi process exits mid-turn", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user