Compare commits

..
115 changed files with 4151 additions and 5929 deletions
+2 -13
View File
@@ -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()
-25
View File
@@ -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
-1
View File
@@ -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 |
-9
View File
@@ -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:
+1 -1
View File
@@ -171,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-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`
-88
View File
@@ -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.
-2
View File
@@ -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`.
-2
View File
@@ -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.
+21 -21
View File
@@ -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
View File
@@ -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 -39
View File
@@ -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);
});
+1 -1
View File
@@ -136,7 +136,7 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<
async function addProjectFromSidebar(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.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 -2
View File
@@ -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",
+194 -24
View File
@@ -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,
});
+4 -2
View File
@@ -8,7 +8,7 @@ import {
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { 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(() => {
+194 -22
View File
@@ -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,
},
+298 -111
View File
@@ -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;
}
+20 -20
View File
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.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;
}
+1 -1
View File
@@ -386,7 +386,7 @@ describe("translation resources", () => {
});
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
expect(en.projectPicker.placeholder).toBe("Type 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");
+1 -2
View File
@@ -1093,8 +1093,7 @@ export const ar: TranslationResources = {
},
},
projectPicker: {
placeholder: "اكتب للبحث...",
browse: "استعراض…",
placeholder: "اكتب مسار الدليل...",
opening: "افتتاح المشروع...",
searching: "جارٍ البحث...",
empty: "ابدأ بكتابة المسار",
+1 -2
View File
@@ -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",
+1 -2
View File
@@ -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",
+1 -2
View File
@@ -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",
+1 -2
View File
@@ -1107,8 +1107,7 @@ export const ja: TranslationResources = {
},
},
projectPicker: {
placeholder: "入力して検索...",
browse: "参照…",
placeholder: "ディレクトリパスを入力...",
opening: "プロジェクトを開いています...",
searching: "検索中...",
empty: "パスを入力してください",
+1 -2
View File
@@ -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",
+1 -2
View File
@@ -1119,8 +1119,7 @@ export const ru: TranslationResources = {
},
},
projectPicker: {
placeholder: "Введите текст для поиска...",
browse: "Обзор…",
placeholder: "Введите путь к каталогу...",
opening: "Открытие проекта...",
searching: "Идет поиск...",
empty: "Начните вводить путь",
+1 -2
View File
@@ -1078,8 +1078,7 @@ export const zhCN: TranslationResources = {
},
},
projectPicker: {
placeholder: "输入以搜索...",
browse: "浏览…",
placeholder: "输入目录路径...",
opening: "正在打开 project...",
searching: "正在搜索...",
empty: "开始输入路径",
-422
View File
@@ -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,
});
});
});
-155
View File
@@ -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,
},
});
-260
View File
@@ -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;
}
+27 -29
View File
@@ -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");
});
+7 -28
View File
@@ -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;
}
+1 -11
View File
@@ -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"]),
});
+6 -3
View File
@@ -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;
}
+4 -4
View File
@@ -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",
+3 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.105",
"version": "0.1.104",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -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 -1
View File
@@ -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",
+5 -5
View File
@@ -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 });
});
});
@@ -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