mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1ba7e3f3 |
+47
-11
@@ -66,12 +66,7 @@ jobs:
|
||||
run: npm run typecheck
|
||||
|
||||
server-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: server-tests (${{ matrix.os }})
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -88,9 +83,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
@@ -103,6 +95,50 @@ jobs:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
server-tests-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run Windows-critical server tests
|
||||
working-directory: packages/server
|
||||
run: >
|
||||
npx vitest run
|
||||
src/utils/executable.probe.test.ts
|
||||
src/utils/executable.test.ts
|
||||
src/utils/spawn.launch-regression.test.ts
|
||||
src/utils/spawn.percent-escape.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/tree-kill.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/terminal/worker-terminal-manager.test.ts
|
||||
src/server/agent/provider-registry.test.ts
|
||||
src/server/agent/provider-launch-config.test.ts
|
||||
src/server/agent/provider-snapshot-manager.test.ts
|
||||
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||
src/server/agent/providers/provider-windows-launch.test.ts
|
||||
src/server/agent/providers/provider-availability.test.ts
|
||||
src/server/workspace-registry-model.test.ts
|
||||
src/server/persisted-config.test.ts
|
||||
src/server/bootstrap-provider-availability.test.ts
|
||||
|
||||
desktop-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -180,7 +216,7 @@ jobs:
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
@@ -235,7 +271,7 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
@@ -36,9 +36,6 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ CLAUDE.local.md
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
**/.tanstack/
|
||||
|
||||
# Local agent/tooling artifacts (do not commit)
|
||||
PLAN.md
|
||||
|
||||
+1
-1
@@ -11,5 +11,5 @@
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
||||
"ignorePatterns": ["*.lock"]
|
||||
}
|
||||
|
||||
@@ -1,56 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.70-beta.1 - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
|
||||
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
|
||||
- Detect GitHub issue and PR URLs pasted into the composer search.
|
||||
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
|
||||
- `paseo schedule update` to edit a schedule in place without recreating it.
|
||||
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
|
||||
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
|
||||
|
||||
### Improved
|
||||
|
||||
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
|
||||
- Self-hosted relays can opt into `wss://` for TLS connections.
|
||||
- Workspace open targets only show options reachable from the current daemon.
|
||||
- Combobox search matches model descriptions, not just names.
|
||||
- Codex image attachments render inline as path markdown.
|
||||
- Subagent task notifications no longer clutter the parent agent's timeline.
|
||||
- Voice mode: quieter thinking tone and small UI polish.
|
||||
- Settings sidebar order: Projects now appears after General.
|
||||
- Electron upgraded to 41.2.0 for the desktop app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
|
||||
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
|
||||
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
|
||||
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
|
||||
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
|
||||
- Daemon shutdown terminates all child processes cleanly using tree-kill.
|
||||
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
|
||||
- OpenCode now forwards provider retry errors instead of silently swallowing them.
|
||||
- Codex import no longer reverts to the wrong default mode.
|
||||
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
|
||||
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
|
||||
- Workspace navigation regression on web fixed.
|
||||
- Duplicate workspace shell navigation eliminated.
|
||||
- The 'Update installed' callout no longer flashes incorrectly.
|
||||
- Browser pane reload focus and devtools handling.
|
||||
- MCP terminal capture now includes scrollback.
|
||||
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
|
||||
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
|
||||
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
|
||||
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
|
||||
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
|
||||
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
|
||||
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
|
||||
|
||||
## 0.1.69 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-Fo95v2pBAW1i0K7WPoEwtKbwjeDZ5ed4vJ5p7I7LIYw=";
|
||||
npmDepsHash = "sha256-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
Generated
+784
-951
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface ArchiveTabAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClient {
|
||||
@@ -34,9 +33,9 @@ interface ArchiveTabDaemonClient {
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string; createdAt: string }) => boolean,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; createdAt: string }>;
|
||||
): Promise<{ status: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
@@ -133,7 +132,6 @@ export async function createIdleAgent(
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
createdAt: snapshot.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +257,7 @@ export async function reloadWorkspace(page: Page, workspaceId: string): Promise<
|
||||
}
|
||||
|
||||
export async function openSessions(page: Page): Promise<void> {
|
||||
const sessionsButton = page.getByTestId("sidebar-history");
|
||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await sessionsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
|
||||
@@ -31,9 +31,9 @@ export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-history")).toBeInViewport({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-history")).not.toBeInViewport({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabVisible,
|
||||
type ArchiveTabAgent,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
type OpenedProject,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { expectSidebarWorkspaceSelected, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
interface SeededSidebarSessions {
|
||||
firstWorkspace: OpenedProject;
|
||||
secondWorkspace: OpenedProject;
|
||||
firstProjectName: string;
|
||||
secondProjectName: string;
|
||||
unmappedProjectName: string;
|
||||
agentsByNewest: [ArchiveTabAgent, ArchiveTabAgent, ArchiveTabAgent];
|
||||
unmappedAgent: ArchiveTabAgent;
|
||||
cleanupRepos: () => Promise<void>;
|
||||
}
|
||||
|
||||
function requireServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function uniqueTitle(label: string): string {
|
||||
return `sidebar-history-${label}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function checkoutBranch(repoPath: string, branch: string): void {
|
||||
execSync(`git checkout ${JSON.stringify(branch)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function seedSidebarSessionsJourney(clients: {
|
||||
archiveClient: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
}): Promise<SeededSidebarSessions> {
|
||||
const firstRepo = await createTempGitRepo("sidebar-history-a-", { branches: ["feature-a"] });
|
||||
const secondRepo = await createTempGitRepo("sidebar-history-b-", { branches: ["feature-b"] });
|
||||
const unmappedRepo = await createTempGitRepo("sidebar-history-unmapped-");
|
||||
const workspaceIds: string[] = [];
|
||||
const agentIds: string[] = [];
|
||||
|
||||
try {
|
||||
checkoutBranch(firstRepo.path, "feature-a");
|
||||
checkoutBranch(secondRepo.path, "feature-b");
|
||||
|
||||
const firstWorkspace = await openProjectViaDaemon(clients.workspaceClient, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(clients.workspaceClient, secondRepo.path);
|
||||
workspaceIds.push(firstWorkspace.workspaceId, secondWorkspace.workspaceId);
|
||||
|
||||
const oldest = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: firstRepo.path,
|
||||
title: uniqueTitle("oldest"),
|
||||
});
|
||||
await sleep(20);
|
||||
const middle = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: firstRepo.path,
|
||||
title: uniqueTitle("middle"),
|
||||
});
|
||||
await sleep(20);
|
||||
const newest = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: secondRepo.path,
|
||||
title: uniqueTitle("newest"),
|
||||
});
|
||||
const unmappedAgent = await createIdleAgent(clients.archiveClient, {
|
||||
cwd: unmappedRepo.path,
|
||||
title: uniqueTitle("unmapped"),
|
||||
});
|
||||
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, unmappedRepo.path);
|
||||
agentIds.push(oldest.id, middle.id, newest.id, unmappedAgent.id);
|
||||
|
||||
return {
|
||||
firstWorkspace,
|
||||
secondWorkspace,
|
||||
firstProjectName: path.basename(firstRepo.path),
|
||||
secondProjectName: path.basename(secondRepo.path),
|
||||
unmappedProjectName: path.basename(unmappedRepo.path),
|
||||
agentsByNewest: [newest, middle, oldest],
|
||||
unmappedAgent,
|
||||
cleanupRepos: async () => {
|
||||
await unmappedRepo.cleanup();
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(clients.archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, workspaceId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
await unmappedRepo.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupSeededSidebarSessions(
|
||||
seeded: SeededSidebarSessions | null,
|
||||
clients: {
|
||||
archiveClient: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
workspaceClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!seeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const agent of seeded.agentsByNewest) {
|
||||
await archiveAgentFromDaemon(clients.archiveClient, agent.id).catch(() => undefined);
|
||||
}
|
||||
await archiveAgentFromDaemon(clients.archiveClient, seeded.unmappedAgent.id).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await archiveLocalWorkspaceFromDaemon(clients.workspaceClient, seeded.unmappedAgent.cwd).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await archiveLocalWorkspaceFromDaemon(
|
||||
clients.workspaceClient,
|
||||
seeded.secondWorkspace.workspaceId,
|
||||
).catch(() => undefined);
|
||||
await archiveLocalWorkspaceFromDaemon(
|
||||
clients.workspaceClient,
|
||||
seeded.firstWorkspace.workspaceId,
|
||||
).catch(() => undefined);
|
||||
await seeded.cleanupRepos();
|
||||
}
|
||||
|
||||
async function openAppWithSeededWorkspaces(page: Page, seeded: SeededSidebarSessions) {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expectWorkspaceTreeVisible(page, seeded);
|
||||
}
|
||||
|
||||
function expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded: SeededSidebarSessions) {
|
||||
const [newest, middle, oldest] = seeded.agentsByNewest;
|
||||
expect(new Date(newest.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(middle.createdAt).getTime(),
|
||||
);
|
||||
expect(new Date(middle.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(oldest.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectWorkspaceTreeVisible(page: Page, seeded: SeededSidebarSessions) {
|
||||
await expect(page.getByRole("button", { name: "Workspaces" })).toBeVisible();
|
||||
await expect(page.getByText(seeded.firstProjectName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText(seeded.firstWorkspace.workspaceName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText(seeded.secondProjectName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText(seeded.secondWorkspace.workspaceName, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Filter sessions" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function switchSidebarToSessions(page: Page) {
|
||||
await page.getByRole("button", { name: "Sessions" }).click();
|
||||
await expect(page.getByRole("button", { name: "Filter sessions" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function switchSidebarToWorkspaces(page: Page, seeded: SeededSidebarSessions) {
|
||||
await page.getByRole("button", { name: "Workspaces" }).click();
|
||||
await expectWorkspaceTreeVisible(page, seeded);
|
||||
for (const agent of seeded.agentsByNewest) {
|
||||
await expect(sessionRow(page, agent)).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow(page: Page, agent: ArchiveTabAgent) {
|
||||
return page.getByRole("button", { name: new RegExp(escapeRegex(agent.title)) });
|
||||
}
|
||||
|
||||
async function expectSidebarSessionRows(page: Page, agents: readonly ArchiveTabAgent[]) {
|
||||
for (const agent of agents) {
|
||||
await expect(sessionRow(page, agent)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function expectOnlySidebarSessionRows(
|
||||
page: Page,
|
||||
input: {
|
||||
visible: readonly ArchiveTabAgent[];
|
||||
hidden: readonly ArchiveTabAgent[];
|
||||
},
|
||||
) {
|
||||
await expectSidebarSessionRows(page, input.visible);
|
||||
for (const agent of input.hidden) {
|
||||
await expect(sessionRow(page, agent)).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectUnmappedSessionHidden(page: Page, seeded: SeededSidebarSessions) {
|
||||
await expect(sessionRow(page, seeded.unmappedAgent)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Filter sessions" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: new RegExp(`^${escapeRegex(seeded.unmappedProjectName)}$`),
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function expectNewestSessionRowFirst(page: Page, newest: ArchiveTabAgent) {
|
||||
await expect(sessionRow(page, newest)).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const titles = await page.getByRole("button").allTextContents();
|
||||
return titles.find((title) => title.includes("sidebar-history-")) ?? "";
|
||||
})
|
||||
.toContain(newest.title);
|
||||
}
|
||||
|
||||
async function chooseSessionsFilter(page: Page, name: string | RegExp) {
|
||||
await page.getByRole("button", { name: "Filter sessions" }).click();
|
||||
await page.getByRole("button", { name }).click();
|
||||
}
|
||||
|
||||
async function chooseProjectSessionsFilter(page: Page, projectName: string) {
|
||||
await chooseSessionsFilter(page, new RegExp(`^${escapeRegex(projectName)}$`));
|
||||
}
|
||||
|
||||
async function openFirstWorkspace(page: Page, seeded: SeededSidebarSessions) {
|
||||
const serverId = requireServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.firstWorkspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: seeded.firstWorkspace.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateFromSessionRow(page: Page, agent: ArchiveTabAgent) {
|
||||
await sessionRow(page, agent).click();
|
||||
}
|
||||
|
||||
async function expectWorkspaceFocusedOnSession(
|
||||
page: Page,
|
||||
input: { workspaceId: string; agent: ArchiveTabAgent },
|
||||
) {
|
||||
const serverId = requireServerId();
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, input.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, input.agent.id);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${input.agent.id}`).filter({ visible: true }).first(),
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openHistoryFromSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "History", exact: true }).click();
|
||||
await expect(page).toHaveURL(buildHostSessionsRoute(requireServerId()), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function runSidebarHistorySessionsJourney(page: Page) {
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
let seeded: SeededSidebarSessions | null = null;
|
||||
|
||||
try {
|
||||
seeded = await seedSidebarSessionsJourney({ archiveClient, workspaceClient });
|
||||
expectSeededAgentsHaveDeterministicCreatedAtOrder(seeded);
|
||||
const [newest, middle, oldest] = seeded.agentsByNewest;
|
||||
|
||||
await openAppWithSeededWorkspaces(page, seeded);
|
||||
await switchSidebarToSessions(page);
|
||||
await expectSidebarSessionRows(page, seeded.agentsByNewest);
|
||||
await expectUnmappedSessionHidden(page, seeded);
|
||||
await expectNewestSessionRowFirst(page, newest);
|
||||
await switchSidebarToWorkspaces(page, seeded);
|
||||
|
||||
await switchSidebarToSessions(page);
|
||||
await chooseProjectSessionsFilter(page, seeded.secondProjectName);
|
||||
await expectOnlySidebarSessionRows(page, {
|
||||
visible: [newest],
|
||||
hidden: [middle, oldest],
|
||||
});
|
||||
await chooseSessionsFilter(page, "All");
|
||||
await expectSidebarSessionRows(page, seeded.agentsByNewest);
|
||||
|
||||
await openFirstWorkspace(page, seeded);
|
||||
await switchSidebarToSessions(page);
|
||||
await navigateFromSessionRow(page, newest);
|
||||
await expectWorkspaceFocusedOnSession(page, {
|
||||
workspaceId: seeded.secondWorkspace.workspaceId,
|
||||
agent: newest,
|
||||
});
|
||||
|
||||
await openHistoryFromSidebar(page);
|
||||
} finally {
|
||||
await cleanupSeededSidebarSessions(seeded, { archiveClient, workspaceClient });
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Sidebar History Sessions journey", () => {
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("toggles sessions, filters by project, and opens a session tab", async ({ page }) => {
|
||||
await runSidebarHistorySessionsJourney(page);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
appId: sh.paseo
|
||||
---
|
||||
# Ensure sidebar is closed: if sidebar-history is visible, close it via swipe left
|
||||
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-history"
|
||||
id: "sidebar-sessions"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
@@ -21,7 +21,7 @@ appId: sh.paseo
|
||||
|
||||
# Verify sidebar opened
|
||||
- assertVisible:
|
||||
id: "sidebar-history"
|
||||
id: "sidebar-sessions"
|
||||
- takeScreenshot: 01-sidebar-opened
|
||||
|
||||
# Close sidebar via swipe left
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||
|
||||
const { state, startHostRuntimeBootstrapMock } = vi.hoisted(() => {
|
||||
return {
|
||||
state: {
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
daemonStartError: null as string | null,
|
||||
daemonStartIsRunning: false,
|
||||
latestBootstrapState: null as HostRuntimeBootstrapState | null,
|
||||
},
|
||||
startHostRuntimeBootstrapMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/styles/unistyles", () => ({}));
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
PortalProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
QueryClientProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("expo-linking", () => ({ addEventListener: vi.fn(), getInitialURL: vi.fn() }));
|
||||
vi.mock("expo-notifications", () => ({
|
||||
addNotificationResponseReceivedListener: vi.fn(),
|
||||
getLastNotificationResponseAsync: vi.fn(),
|
||||
setNotificationHandler: vi.fn(),
|
||||
}));
|
||||
vi.mock("expo-router", () => ({
|
||||
Stack: Object.assign(() => null, { Protected: () => null, Screen: () => null }),
|
||||
useGlobalSearchParams: () => ({}),
|
||||
usePathname: () => "/",
|
||||
useRouter: () => ({ navigate: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({ children }: { children?: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
Gesture: { Pan: () => ({}) },
|
||||
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||
GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-keyboard-controller", () => ({
|
||||
KeyboardProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
Extrapolation: { CLAMP: "clamp" },
|
||||
interpolate: vi.fn(),
|
||||
runOnJS: (fn: () => void) => fn,
|
||||
useSharedValue: (value: unknown) => ({ value }),
|
||||
}));
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function"
|
||||
? factory({ colors: { surface0: "#000", foreground: "#fff" } })
|
||||
: factory,
|
||||
},
|
||||
UnistylesRuntime: { setAdaptiveThemes: vi.fn(), setTheme: vi.fn() },
|
||||
useUnistyles: () => ({ theme: { colors: { surface0: "#000", foreground: "#fff" } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/command-center", () => ({ CommandCenter: () => null }));
|
||||
vi.mock("@/components/worktree-setup-callout-source", () => ({
|
||||
WorktreeSetupCalloutSource: () => null,
|
||||
}));
|
||||
vi.mock("@/components/download-toast", () => ({ DownloadToast: () => null }));
|
||||
vi.mock("@/components/quitting-overlay", () => ({ QuittingOverlay: () => null }));
|
||||
vi.mock("@/components/keyboard-shortcuts-dialog", () => ({ KeyboardShortcutsDialog: () => null }));
|
||||
vi.mock("@/components/left-sidebar", () => ({ LeftSidebar: () => null }));
|
||||
vi.mock("@/components/project-picker-modal", () => ({ ProjectPickerModal: () => null }));
|
||||
vi.mock("@/components/workspace-setup-dialog", () => ({ WorkspaceSetupDialog: () => null }));
|
||||
vi.mock("@/components/workspace-shortcut-targets-subscriber", () => ({
|
||||
WorkspaceShortcutTargetsSubscriber: () => null,
|
||||
}));
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
getIsElectronRuntime: () => false,
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
vi.mock("@/constants/platform", () => ({ isNative: false, isWeb: true }));
|
||||
vi.mock("@/contexts/horizontal-scroll-context", () => ({
|
||||
HorizontalScrollProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useHorizontalScrollOptional: () => null,
|
||||
}));
|
||||
vi.mock("@/contexts/session-context", () => ({
|
||||
SessionProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||
SidebarAnimationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useSidebarAnimation: () => ({
|
||||
animateToClose: vi.fn(),
|
||||
animateToOpen: vi.fn(),
|
||||
backdropOpacity: { value: 0 },
|
||||
gestureAnimatingRef: { current: false },
|
||||
isGesturing: { value: false },
|
||||
openGestureRef: {},
|
||||
translateX: { value: 0 },
|
||||
windowWidth: 320,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/contexts/sidebar-callout-context", () => ({
|
||||
SidebarCalloutProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/voice-context", () => ({
|
||||
VoiceProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/app/host-runtime-bootstrap", () => ({
|
||||
startDaemonIfGateAllows: vi.fn(),
|
||||
startHostRuntimeBootstrap: startHostRuntimeBootstrapMock,
|
||||
}));
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({ shouldUseDesktopDaemon: () => false }));
|
||||
vi.mock("@/desktop/electron/events", () => ({ listenToDesktopEvent: vi.fn() }));
|
||||
vi.mock("@/desktop/electron/window", () => ({ updateDesktopWindowControls: vi.fn() }));
|
||||
vi.mock("@/desktop/host", () => ({ getDesktopHost: () => null }));
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => ({ loadDesktopSettings: vi.fn() }));
|
||||
vi.mock("@/desktop/updates/rosetta-callout-source", () => ({ RosettaCalloutSource: () => null }));
|
||||
vi.mock("@/desktop/updates/update-callout-source", () => ({ UpdateCalloutSource: () => null }));
|
||||
vi.mock("@/hooks/use-active-worktree-new-action", () => ({ useActiveWorktreeNewAction: vi.fn() }));
|
||||
vi.mock("@/hooks/use-favicon-status", () => ({ useFaviconStatus: vi.fn() }));
|
||||
vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: vi.fn() }));
|
||||
vi.mock("@/hooks/use-latched-boolean", () => ({ useLatchedBoolean: (value: boolean) => value }));
|
||||
vi.mock("@/hooks/use-open-project", () => ({ useOpenProject: () => vi.fn() }));
|
||||
vi.mock("@/hooks/use-settings", () => ({
|
||||
useAppSettings: () => ({
|
||||
isLoading: false,
|
||||
settings: { theme: "dark" },
|
||||
updateSettings: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-stable-event", () => ({ useStableEvent: (fn: unknown) => fn }));
|
||||
vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace: vi.fn() }));
|
||||
vi.mock("@/keyboard/keyboard-action-dispatcher", () => ({
|
||||
keyboardActionDispatcher: { dispatch: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/polyfills/crypto", () => ({ polyfillCrypto: vi.fn() }));
|
||||
vi.mock("@/query/query-client", () => ({ queryClient: {} }));
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
boot: vi.fn(),
|
||||
getEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
|
||||
subscribeAll: () => vi.fn(),
|
||||
subscribeHostList: () => vi.fn(),
|
||||
}),
|
||||
useHostMutations: () => ({ upsertConnectionFromOfferUrl: vi.fn() }),
|
||||
useHostRuntimeClient: () => null,
|
||||
useHosts: () => [],
|
||||
}));
|
||||
vi.mock("@/runtime/daemon-start-service", () => ({
|
||||
getDaemonStartService: () => ({
|
||||
getLastError: () => state.daemonStartError,
|
||||
isRunning: () => state.daemonStartIsRunning,
|
||||
recordError: vi.fn(),
|
||||
start: vi.fn(),
|
||||
subscribe: () => vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/panel-store", () => ({ usePanelStore: vi.fn(() => vi.fn()) }));
|
||||
vi.mock("@/stores/session-store", () => ({
|
||||
useSessionStore: { getState: () => ({ sessions: {} }) },
|
||||
}));
|
||||
vi.mock("@/styles/theme", () => ({ THEME_TO_UNISTYLES: { dark: "dark" } }));
|
||||
vi.mock("@/utils/active-host", () => ({ resolveActiveHost: () => null }));
|
||||
vi.mock("@/utils/desktop-sidebar-toggle", () => ({
|
||||
toggleDesktopSidebarsWithCheckoutIntent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/utils/host-routes", () => ({
|
||||
buildHostRootRoute: (serverId: string) => `/h/${serverId}`,
|
||||
mapPathnameToServer: () => null,
|
||||
parseHostAgentRouteFromPathname: () => null,
|
||||
parseServerIdFromPathname: () => null,
|
||||
parseWorkspaceOpenIntent: () => null,
|
||||
}));
|
||||
vi.mock("@/utils/notification-routing", () => ({
|
||||
buildNotificationRoute: () => "/",
|
||||
resolveNotificationTarget: () => ({}),
|
||||
}));
|
||||
vi.mock("@/utils/os-notifications", () => ({
|
||||
WEB_NOTIFICATION_CLICK_EVENT: "notification-click",
|
||||
ensureOsNotificationPermission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/utils/workspace-execution", () => ({
|
||||
resolveWorkspaceIdByExecutionDirectory: () => null,
|
||||
}));
|
||||
vi.mock("@/utils/workspace-navigation", () => ({ prepareWorkspaceTab: vi.fn() }));
|
||||
|
||||
let useHostRuntimeBootstrapState: typeof import("./_layout").useHostRuntimeBootstrapState;
|
||||
|
||||
function BootstrapStateProbe() {
|
||||
state.latestBootstrapState = useHostRuntimeBootstrapState();
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("HostRuntimeBootstrapProvider startup give-up timer", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.daemonStartError = null;
|
||||
state.daemonStartIsRunning = false;
|
||||
state.latestBootstrapState = null;
|
||||
startHostRuntimeBootstrapMock.mockClear();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function renderProvider() {
|
||||
const layout = await import("./_layout");
|
||||
useHostRuntimeBootstrapState = layout.useHostRuntimeBootstrapState;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<layout.HostRuntimeBootstrapProvider>
|
||||
<BootstrapStateProbe />
|
||||
</layout.HostRuntimeBootstrapProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("gives up after 5s when the env override host is not online", async () => {
|
||||
await renderProvider();
|
||||
|
||||
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
});
|
||||
|
||||
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(true);
|
||||
});
|
||||
|
||||
it("does not give up after 5s when an env override host is online", async () => {
|
||||
state.anyOnlineHostServerId = "server-local";
|
||||
|
||||
await renderProvider();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
});
|
||||
|
||||
expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
default as React,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -66,6 +65,7 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHosts,
|
||||
@@ -312,7 +312,7 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
|
||||
export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
const daemonStartService = getDaemonStartService({ store });
|
||||
@@ -327,12 +327,16 @@ export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const daemonStartError = useDaemonStartLastError();
|
||||
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||
const waitForConfiguredLocalDaemon =
|
||||
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
|
||||
|
||||
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
anyOnlineHostServerId ||
|
||||
daemonStartError ||
|
||||
daemonStartIsRunning ||
|
||||
waitForConfiguredLocalDaemon ||
|
||||
hasGivenUpWaitingForHost
|
||||
) {
|
||||
return;
|
||||
@@ -343,7 +347,13 @@ export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||
}, [
|
||||
anyOnlineHostServerId,
|
||||
daemonStartError,
|
||||
daemonStartIsRunning,
|
||||
waitForConfiguredLocalDaemon,
|
||||
hasGivenUpWaitingForHost,
|
||||
]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
|
||||
@@ -2,16 +2,10 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
}>();
|
||||
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const headerTitle = typeof params.title === "string" ? params.title : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
@@ -22,7 +16,6 @@ export default function HostNewWorkspaceRoute() {
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
headerTitle={headerTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,22 +67,6 @@ describe("combined model selector helpers", () => {
|
||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
||||
const row = {
|
||||
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
||||
provider: "opencode",
|
||||
providerLabel: "OpenCode",
|
||||
modelId: "opencode-zen/kimi-k2.5",
|
||||
modelLabel: "Kimi K2.5",
|
||||
description: "OpenCode Zen - kimi",
|
||||
};
|
||||
|
||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
|
||||
@@ -48,10 +48,7 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, Settings } from "lucide-react-native";
|
||||
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -32,9 +32,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SidebarHistoryNavigationRow } from "@/components/sidebar/sidebar-history-navigation-row";
|
||||
import { SidebarShellHeader } from "@/components/sidebar/sidebar-shell-header";
|
||||
import { SidebarWorkspacesView } from "@/components/sidebar/sidebar-workspaces-view";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -63,14 +62,9 @@ import {
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
SidebarSessionsToggle,
|
||||
SidebarSessionsView,
|
||||
type SidebarSessionFilter,
|
||||
type SidebarSessionViewMode,
|
||||
useSidebarSessionsController,
|
||||
} from "@/sidebar-sessions";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
@@ -86,14 +80,6 @@ interface SidebarSharedProps {
|
||||
activeServerId: string | null;
|
||||
activeHostLabel: string;
|
||||
activeHostStatusColor: string;
|
||||
sidebarViewMode: SidebarSessionViewMode;
|
||||
setSidebarViewMode: (mode: SidebarSessionViewMode) => void;
|
||||
sidebarSessionFilter: SidebarSessionFilter;
|
||||
setSidebarSessionFilter: (filter: SidebarSessionFilter) => void;
|
||||
groupByProject: boolean;
|
||||
previewExpandedProjects: ReadonlySet<string>;
|
||||
setGroupByProject: (next: boolean) => void;
|
||||
toggleProjectPreviewExpanded: (projectKey: string) => void;
|
||||
hostOptions: ComboboxOption[];
|
||||
hostTriggerRef: RefObject<View | null>;
|
||||
isHostPickerOpen: boolean;
|
||||
@@ -196,16 +182,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
);
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
const {
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
} = useSidebarSessionsController({ serverId: activeServerId });
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
@@ -271,14 +247,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -517,14 +485,6 @@ function MobileSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -547,6 +507,8 @@ function MobileSidebar({
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -702,30 +664,6 @@ function MobileSidebar({
|
||||
],
|
||||
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
||||
);
|
||||
const toggleSlot = useMemo(
|
||||
() => (
|
||||
<SidebarSessionsToggle
|
||||
serverId={activeServerId}
|
||||
mode={sidebarViewMode}
|
||||
filter={sidebarSessionFilter}
|
||||
projects={projects}
|
||||
groupByProject={groupByProject}
|
||||
onModeChange={setSidebarViewMode}
|
||||
onFilterChange={setSidebarSessionFilter}
|
||||
onGroupByProjectChange={setGroupByProject}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeServerId,
|
||||
groupByProject,
|
||||
projects,
|
||||
sidebarSessionFilter,
|
||||
sidebarViewMode,
|
||||
setGroupByProject,
|
||||
setSidebarSessionFilter,
|
||||
setSidebarViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -734,35 +672,29 @@ function MobileSidebar({
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<SidebarShellHeader toggleSlot={toggleSlot}>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
groupByProject={groupByProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
@@ -791,14 +723,6 @@ function DesktopSidebar({
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
@@ -819,6 +743,8 @@ function DesktopSidebar({
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -873,30 +799,6 @@ function DesktopSidebar({
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
);
|
||||
const toggleSlot = useMemo(
|
||||
() => (
|
||||
<SidebarSessionsToggle
|
||||
serverId={activeServerId}
|
||||
mode={sidebarViewMode}
|
||||
filter={sidebarSessionFilter}
|
||||
projects={projects}
|
||||
groupByProject={groupByProject}
|
||||
onModeChange={setSidebarViewMode}
|
||||
onFilterChange={setSidebarSessionFilter}
|
||||
onGroupByProjectChange={setGroupByProject}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeServerId,
|
||||
groupByProject,
|
||||
projects,
|
||||
sidebarSessionFilter,
|
||||
sidebarViewMode,
|
||||
setGroupByProject,
|
||||
setSidebarSessionFilter,
|
||||
setSidebarViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -905,37 +807,31 @@ function DesktopSidebar({
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<SidebarShellHeader
|
||||
topSpacerStyle={padding.top > 0 ? paddingTopSpacerStyle : undefined}
|
||||
toggleSlot={toggleSlot}
|
||||
withTitlebarDragRegion
|
||||
>
|
||||
<SidebarHistoryNavigationRow onPress={handleViewMore} />
|
||||
</SidebarShellHeader>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{sidebarViewMode === "workspaces" ? (
|
||||
<SidebarWorkspacesView
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isInitialLoad={isInitialLoad}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
/>
|
||||
) : (
|
||||
<SidebarSessionsView
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
filter={sidebarSessionFilter}
|
||||
groupByProject={groupByProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded}
|
||||
onProjectCollapsedToggle={toggleProjectCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
@@ -1002,6 +898,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -20,7 +19,6 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface PathRowProps {
|
||||
path: string;
|
||||
@@ -65,7 +63,6 @@ export function ProjectPickerModal() {
|
||||
const serverId = useActiveServerId();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
const intent = useKeyboardShortcutsStore((s) => s.projectPickerIntent);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
@@ -118,16 +115,6 @@ export function ProjectPickerModal() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (intent.kind === "new-workspace") {
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, trimmed, {
|
||||
headerTitle: intent.headerTitle,
|
||||
}) as never,
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const didOpenProject = await openProject(trimmed);
|
||||
if (didOpenProject) {
|
||||
setOpen(false);
|
||||
@@ -136,7 +123,7 @@ export function ProjectPickerModal() {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, intent, openProject, serverId, setOpen],
|
||||
[client, openProject, serverId, setOpen],
|
||||
);
|
||||
|
||||
const handleSubmitCustom = useCallback(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarProjectSection } from "./sidebar-collapsible-project-section";
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) =>
|
||||
typeof styles === "function"
|
||||
? styles({
|
||||
borderRadius: { lg: 8 },
|
||||
colors: {
|
||||
border: "#dddddd",
|
||||
surface2: "#eeeeee",
|
||||
surfaceSidebarHover: "#f5f5f5",
|
||||
},
|
||||
iconSize: { md: 16 },
|
||||
shadow: { md: {} },
|
||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||
})
|
||||
: styles,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/sidebar-project-row-visual", () => ({
|
||||
SidebarProjectIcon: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||
SidebarProjectRowVisual: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const collapsedFooter = <Text>Footer row</Text>;
|
||||
|
||||
describe("SidebarProjectSection", () => {
|
||||
it("renders the header and expanded children", () => {
|
||||
const { getByTestId, getByText } = render(
|
||||
<SidebarProjectSection
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
iconDataUri={null}
|
||||
onPress={vi.fn()}
|
||||
isHovered={false}
|
||||
chevron="collapse"
|
||||
isCollapsed={false}
|
||||
testID="project-a-header"
|
||||
>
|
||||
<Text>Child row</Text>
|
||||
</SidebarProjectSection>,
|
||||
);
|
||||
|
||||
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||
expect(getByText("Project A")).toBeTruthy();
|
||||
expect(getByText("Child row")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the header visible and hides children plus footer when collapsed", () => {
|
||||
const { getByTestId, queryByText } = render(
|
||||
<SidebarProjectSection
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
iconDataUri={null}
|
||||
onPress={vi.fn()}
|
||||
isHovered={false}
|
||||
chevron="expand"
|
||||
isCollapsed
|
||||
footer={collapsedFooter}
|
||||
testID="project-a-header"
|
||||
>
|
||||
<Text>Child row</Text>
|
||||
</SidebarProjectSection>,
|
||||
);
|
||||
|
||||
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||
expect(queryByText("Child row")).toBeNull();
|
||||
expect(queryByText("Footer row")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ComponentType,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Pressable,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import {
|
||||
SidebarProjectIcon,
|
||||
SidebarProjectRowVisual,
|
||||
} from "@/components/sidebar/sidebar-project-row-visual";
|
||||
|
||||
export type SidebarProjectChevronDirection = "expand" | "collapse";
|
||||
|
||||
export interface SidebarProjectHeaderRowProps {
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
onPress: () => void;
|
||||
isHovered: boolean;
|
||||
|
||||
/** Visual collapsed state. When set, drives the chevron direction (down=collapse / right=expand). */
|
||||
chevron?: SidebarProjectChevronDirection | null;
|
||||
|
||||
selected?: boolean;
|
||||
isDragging?: boolean;
|
||||
|
||||
/** Replaces the chevron+icon leading visual entirely (e.g. archiving spinner, workspace status icon). */
|
||||
leadingVisualOverride?: ReactElement | null;
|
||||
|
||||
/** Appended after the project name. Kebab, new-worktree button, shortcut badge. */
|
||||
trailingSlot?: ReactNode;
|
||||
|
||||
/** Long-press / drag handlers. */
|
||||
onPressIn?: (event: GestureResponderEvent) => void;
|
||||
onTouchMove?: (event: GestureResponderEvent) => void;
|
||||
onPressOut?: (event: GestureResponderEvent) => void;
|
||||
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
|
||||
/** Substitute for the inner Pressable (e.g. ContextMenuTrigger). Receives the same props. */
|
||||
PressableComponent?: ComponentType<PressableProps & { ref?: Ref<View> }>;
|
||||
}
|
||||
|
||||
export interface SidebarProjectSectionProps {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
onPress: () => void;
|
||||
isHovered: boolean;
|
||||
chevron: "expand" | "collapse" | null;
|
||||
selected?: boolean;
|
||||
isDragging?: boolean;
|
||||
trailingSlot?: ReactNode;
|
||||
testID?: string;
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
isCollapsed: boolean;
|
||||
headerProps?: Pick<
|
||||
SidebarProjectHeaderRowProps,
|
||||
| "accessibilityLabel"
|
||||
| "leadingVisualOverride"
|
||||
| "onPressIn"
|
||||
| "onTouchMove"
|
||||
| "onPressOut"
|
||||
| "PressableComponent"
|
||||
>;
|
||||
}
|
||||
|
||||
export const SidebarProjectSection = memo(function SidebarProjectSection({
|
||||
projectName,
|
||||
iconDataUri,
|
||||
onPress,
|
||||
isHovered,
|
||||
chevron,
|
||||
selected = false,
|
||||
isDragging = false,
|
||||
trailingSlot = null,
|
||||
testID,
|
||||
children = null,
|
||||
footer = null,
|
||||
isCollapsed,
|
||||
headerProps,
|
||||
}: SidebarProjectSectionProps): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<SidebarProjectHeaderRow
|
||||
projectName={projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
chevron={chevron}
|
||||
isHovered={isHovered}
|
||||
isDragging={isDragging}
|
||||
selected={selected}
|
||||
trailingSlot={trailingSlot}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
{...headerProps}
|
||||
/>
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
{children}
|
||||
{footer}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared chrome for a project row that names a section: pressable, hover/press/selected
|
||||
* styles, on-hover chevron leading visual, and a trailing actions slot.
|
||||
*
|
||||
* Hover is controlled — callers wrap with their own outer View carrying onPointerEnter/Leave
|
||||
* (workspace tree adds drag-handle attrs to that wrapper; sessions wraps a plain View).
|
||||
*/
|
||||
export const SidebarProjectHeaderRow = memo(
|
||||
forwardRef<View, SidebarProjectHeaderRowProps>(function SidebarProjectHeaderRow(
|
||||
{
|
||||
projectName,
|
||||
iconDataUri,
|
||||
onPress,
|
||||
isHovered,
|
||||
chevron = null,
|
||||
selected = false,
|
||||
isDragging = false,
|
||||
leadingVisualOverride = null,
|
||||
trailingSlot = null,
|
||||
onPressIn,
|
||||
onTouchMove,
|
||||
onPressOut,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
PressableComponent = Pressable,
|
||||
},
|
||||
ref,
|
||||
): ReactElement {
|
||||
const showChevron = isHovered && chevron !== null;
|
||||
const leadingVisual = useMemo<ReactElement | null>(() => {
|
||||
if (showChevron && chevron) {
|
||||
return (
|
||||
<View style={sidebarProjectHeaderRowStyles.leadingSlot}>
|
||||
<SidebarProjectChevron chevron={chevron} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (leadingVisualOverride) {
|
||||
return leadingVisualOverride;
|
||||
}
|
||||
return <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />;
|
||||
}, [chevron, iconDataUri, leadingVisualOverride, projectName, showChevron]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
sidebarProjectHeaderRowStyles.row,
|
||||
isDragging && sidebarProjectHeaderRowStyles.dragging,
|
||||
selected && sidebarProjectHeaderRowStyles.selected,
|
||||
isHovered && sidebarProjectHeaderRowStyles.hovered,
|
||||
pressed && sidebarProjectHeaderRowStyles.pressed,
|
||||
],
|
||||
[isDragging, isHovered, selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<PressableComponent
|
||||
ref={ref}
|
||||
style={pressableStyle}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onTouchMove={onTouchMove}
|
||||
onPressOut={onPressOut}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
<SidebarProjectRowVisual
|
||||
projectName={projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
leadingVisual={leadingVisual}
|
||||
/>
|
||||
{trailingSlot}
|
||||
</PressableComponent>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
function SidebarProjectChevron({
|
||||
chevron,
|
||||
}: {
|
||||
chevron: SidebarProjectChevronDirection;
|
||||
}): ReactElement {
|
||||
if (chevron === "collapse") {
|
||||
return <ChevronDown size={14} color="#9ca3af" />;
|
||||
}
|
||||
return <ChevronRight size={14} color="#9ca3af" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer wrapper that carries hover state for a `<SidebarProjectHeaderRow>` plus extra props
|
||||
* (drag handle attrs, ref). Sessions uses the lighter `<SidebarProjectHeaderHoverWrap>`.
|
||||
*/
|
||||
export const SidebarProjectHeaderHoverWrap = forwardRef<
|
||||
View,
|
||||
ViewProps & {
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
}
|
||||
>(function SidebarProjectHeaderHoverWrap({ onHoverChange, children, ...rest }, ref): ReactElement {
|
||||
const handleEnter = useCallback(() => onHoverChange(true), [onHoverChange]);
|
||||
const handleLeave = useCallback(() => onHoverChange(false), [onHoverChange]);
|
||||
return (
|
||||
<View ref={ref} {...rest} onPointerEnter={handleEnter} onPointerLeave={handleLeave}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export const sidebarProjectHeaderRowStyles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
hovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
selected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
dragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
leadingSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indent style for rows nested under a `<SidebarProjectHeaderRow>`. Workspace rows and
|
||||
* grouped-session rows both apply this so children visually align across surfaces.
|
||||
*/
|
||||
export const sidebarProjectChildIndentStyles = StyleSheet.create((theme) => ({
|
||||
childRow: {
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
},
|
||||
}));
|
||||
@@ -108,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
buttonHovered: {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { usePathname } from "expo-router";
|
||||
import { History } from "lucide-react-native";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
|
||||
interface SidebarHistoryNavigationRowProps {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function SidebarHistoryNavigationRow({ onPress }: SidebarHistoryNavigationRowProps) {
|
||||
const pathname = usePathname();
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label="History"
|
||||
onPress={onPress}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-history"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { memo, useMemo, type ReactElement } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
|
||||
export interface SidebarProjectRowVisualProps {
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
leadingVisual?: ReactElement | null;
|
||||
}
|
||||
|
||||
export const SidebarProjectRowVisual = memo(function SidebarProjectRowVisual({
|
||||
projectName,
|
||||
iconDataUri,
|
||||
leadingVisual = null,
|
||||
}: SidebarProjectRowVisualProps): ReactElement {
|
||||
return (
|
||||
<View style={styles.projectRowLeft}>
|
||||
{leadingVisual ?? <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />}
|
||||
<SidebarProjectName projectName={projectName} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export const SidebarProjectIcon = memo(function SidebarProjectIcon({
|
||||
projectName,
|
||||
iconDataUri,
|
||||
}: {
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
}): ReactElement {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
|
||||
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
{iconDataUri ? (
|
||||
<Image source={imageSource} style={styles.projectIcon} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const SidebarProjectName = memo(function SidebarProjectName({
|
||||
projectName,
|
||||
}: {
|
||||
projectName: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<View style={styles.projectTitleGroup}>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{projectName}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
projectRowLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIcon: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectIconFallback: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIconFallbackText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: 9,
|
||||
},
|
||||
projectTitleGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Pressable, View } from "react-native";
|
||||
import {
|
||||
SidebarProjectTrailingActions,
|
||||
type SidebarProjectCreateButtonConfig,
|
||||
} from "./sidebar-project-trailing-actions";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: navigateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
FolderPlus: createIcon("FolderPlus"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
Plus: createIcon("Plus"),
|
||||
Settings: createIcon("Settings"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) =>
|
||||
typeof styles === "function"
|
||||
? styles({
|
||||
borderRadius: { md: 6 },
|
||||
colors: {
|
||||
foreground: "#111111",
|
||||
foregroundMuted: "#666666",
|
||||
surface2: "#eeeeee",
|
||||
surfaceSidebarHover: "#f5f5f5",
|
||||
},
|
||||
fontSize: { sm: 14 },
|
||||
spacing: { 2: 8 },
|
||||
})
|
||||
: styles,
|
||||
},
|
||||
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
function ThemedComponent(props: Record<string, unknown>) {
|
||||
return <Component {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||
<View testID={testID}>{children}</View>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID}>
|
||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||
</Pressable>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/shortcut", () => ({
|
||||
Shortcut: () => <View />,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => ({ error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||
useShortcutKeys: () => null,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
navigateMock.mockClear();
|
||||
});
|
||||
|
||||
const newWorkspaceCreateButton: SidebarProjectCreateButtonConfig = {
|
||||
onPress: () => {},
|
||||
accessibilityLabel: "New workspace",
|
||||
testID: "sidebar-project-new-worktree-project-a",
|
||||
tooltipLabel: "New workspace",
|
||||
icon: "folder-plus",
|
||||
};
|
||||
|
||||
describe("SidebarProjectTrailingActions", () => {
|
||||
it("renders the kebab and create button when configured", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={newWorkspaceCreateButton}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(getByTestId("sidebar-project-new-worktree-project-a")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders only the kebab when createButton is null", () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={null}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the create button override instead of the default route", () => {
|
||||
const createOnPress = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const createButton = React.useMemo(
|
||||
() => ({
|
||||
onPress: createOnPress,
|
||||
accessibilityLabel: "New session",
|
||||
testID: "sidebar-project-project-a-new-session-button",
|
||||
tooltipLabel: "New session",
|
||||
icon: "plus" as const,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={createButton}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByTestId } = render(<Harness />);
|
||||
|
||||
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
|
||||
|
||||
expect(createOnPress).toHaveBeenCalledOnce();
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,441 +0,0 @@
|
||||
import React, { useCallback, useState, type ReactElement } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, MoreVertical, Plus, Settings, Trash2 } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
|
||||
export interface SidebarProjectCreateButtonConfig {
|
||||
onPress: () => void;
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
tooltipLabel: string;
|
||||
icon: "folder-plus" | "plus";
|
||||
showShortcutHint?: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarProjectTrailingActionsProps {
|
||||
projectKey: string;
|
||||
serverId: string | null;
|
||||
isHovered: boolean;
|
||||
projectName?: string;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending" | "success";
|
||||
workspaces?: readonly SidebarWorkspaceEntry[];
|
||||
createButton: SidebarProjectCreateButtonConfig | null;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
export function SidebarProjectTrailingActions({
|
||||
projectKey,
|
||||
serverId,
|
||||
isHovered,
|
||||
projectName,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
workspaces = [],
|
||||
createButton,
|
||||
}: SidebarProjectTrailingActionsProps): ReactElement {
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||
const fallbackRemoval = useSidebarProjectRemoval({
|
||||
projectKey,
|
||||
projectName,
|
||||
serverId,
|
||||
workspaces,
|
||||
});
|
||||
const resolvedRemoveProject = onRemoveProject ?? fallbackRemoval.onRemoveProject;
|
||||
const resolvedRemoveProjectStatus = removeProjectStatus ?? fallbackRemoval.removeProjectStatus;
|
||||
|
||||
return (
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{createButton ? (
|
||||
<ProjectCreateButton actionsVisible={actionsVisible} createButton={createButton} />
|
||||
) : null}
|
||||
<View
|
||||
style={!actionsVisible && styles.projectKebabButtonHidden}
|
||||
pointerEvents={actionsVisible ? "auto" : "none"}
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectKey={projectKey}
|
||||
onRemoveProject={resolvedRemoveProject}
|
||||
removeProjectStatus={resolvedRemoveProjectStatus}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCreateButton({
|
||||
actionsVisible,
|
||||
createButton,
|
||||
}: {
|
||||
actionsVisible: boolean;
|
||||
createButton: SidebarProjectCreateButtonConfig;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<NewWorktreeButton
|
||||
accessibilityLabel={createButton.accessibilityLabel}
|
||||
icon={createButton.icon}
|
||||
onPress={createButton.onPress}
|
||||
visible={actionsVisible}
|
||||
showShortcutHint={createButton.showShortcutHint ?? false}
|
||||
testID={createButton.testID}
|
||||
tooltipLabel={createButton.tooltipLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useSidebarProjectRemoval({
|
||||
projectKey,
|
||||
projectName,
|
||||
serverId,
|
||||
workspaces,
|
||||
}: {
|
||||
projectKey: string;
|
||||
projectName?: string;
|
||||
serverId: string | null;
|
||||
workspaces: readonly SidebarWorkspaceEntry[];
|
||||
}): {
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending";
|
||||
} {
|
||||
const toast = useToast();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
const displayName = projectName ?? projectKey;
|
||||
|
||||
const handleRemoveProject = useCallback(() => {
|
||||
if (isRemovingProject || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Remove project?",
|
||||
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Remove",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
workspaces.map(async (ws) => {
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId,
|
||||
workspaceId: ws.workspaceId,
|
||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
const failed = results.filter(isRejected);
|
||||
if (failed.length > 0) {
|
||||
toast.error("Failed to remove some workspaces");
|
||||
}
|
||||
setIsRemovingProject(false);
|
||||
return;
|
||||
});
|
||||
})();
|
||||
}, [displayName, isRemovingProject, serverId, toast, workspaces]);
|
||||
|
||||
return {
|
||||
onRemoveProject: handleRemoveProject,
|
||||
removeProjectStatus: isRemovingProject ? "pending" : "idle",
|
||||
};
|
||||
}
|
||||
|
||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceDescriptor | null;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||
}
|
||||
}
|
||||
|
||||
function projectKebabStyle({
|
||||
hovered = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectKebabMenu({
|
||||
projectKey,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
projectKey: string;
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Project actions"
|
||||
testID={`sidebar-project-kebab-${projectKey}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{canOpenProjectSettings ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
Open project settings
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel="Removing..."
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NewWorktreeButton({
|
||||
accessibilityLabel,
|
||||
icon,
|
||||
onPress,
|
||||
visible,
|
||||
loading = false,
|
||||
testID,
|
||||
showShortcutHint = false,
|
||||
tooltipLabel,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
icon: "folder-plus" | "plus";
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
loading?: boolean;
|
||||
testID: string;
|
||||
showShortcutHint?: boolean;
|
||||
tooltipLabel: string;
|
||||
}) {
|
||||
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.projectIconActionButton,
|
||||
!visible && styles.projectIconActionButtonHidden,
|
||||
(Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
|
||||
],
|
||||
[visible, loading],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
},
|
||||
[onPress],
|
||||
);
|
||||
|
||||
const renderIcon = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (loading) {
|
||||
return <ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
const uniProps = hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
if (icon === "plus") {
|
||||
return <ThemedPlus size={15} uniProps={uniProps} />;
|
||||
}
|
||||
return <ThemedFolderPlus size={15} uniProps={uniProps} />;
|
||||
},
|
||||
[icon, loading],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
{renderIcon}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>{tooltipLabel}</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
projectIconActionButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectIconActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
projectIconActionButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectTrailingActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectKebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
projectTrailingControlSlot: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectActionTooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
projectActionTooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
projectActionTooltipShortcut: {},
|
||||
}));
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import {
|
||||
SidebarProjectsDraggableList,
|
||||
type SidebarProjectDragInfo,
|
||||
} from "./sidebar-projects-draggable-list";
|
||||
|
||||
interface CapturedDraggableProps {
|
||||
data: SidebarProjectEntry[];
|
||||
onDragEnd: (next: SidebarProjectEntry[]) => void;
|
||||
}
|
||||
|
||||
const captured: { props: CapturedDraggableProps | null } = { props: null };
|
||||
|
||||
vi.mock("@/components/draggable-list", () => ({
|
||||
DraggableList: (props: CapturedDraggableProps) => {
|
||||
captured.props = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function makeProject(key: string): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey: key,
|
||||
projectName: key,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/repo/${key}`,
|
||||
workspaces: [],
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTS_ABC: readonly SidebarProjectEntry[] = [
|
||||
makeProject("a"),
|
||||
makeProject("b"),
|
||||
makeProject("c"),
|
||||
];
|
||||
|
||||
const PROJECTS_AB: readonly SidebarProjectEntry[] = [makeProject("a"), makeProject("b")];
|
||||
|
||||
function renderEmpty(): React.ReactElement {
|
||||
return React.createElement(React.Fragment);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
captured.props = null;
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
|
||||
const noopRender = (_info: SidebarProjectDragInfo): React.ReactElement => renderEmpty();
|
||||
|
||||
describe("SidebarProjectsDraggableList", () => {
|
||||
it("persists the reordered project keys to the order store on drag end", () => {
|
||||
render(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_ABC}
|
||||
serverId="server-1"
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured.props).not.toBeNull();
|
||||
const reordered = [PROJECTS_ABC[1], PROJECTS_ABC[0], PROJECTS_ABC[2]];
|
||||
captured.props?.onDragEnd(reordered);
|
||||
|
||||
expect(useSidebarOrderStore.getState().getProjectOrder("server-1")).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("does not persist when the reordered visible keys match the current order", () => {
|
||||
useSidebarOrderStore.getState().setProjectOrder("server-1", ["a", "b"]);
|
||||
const setSpy = vi.spyOn(useSidebarOrderStore.getState(), "setProjectOrder");
|
||||
|
||||
render(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_AB}
|
||||
serverId="server-1"
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
captured.props?.onDragEnd([...PROJECTS_AB]);
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when serverId is null", () => {
|
||||
render(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_AB}
|
||||
serverId={null}
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
captured.props?.onDragEnd([PROJECTS_AB[1], PROJECTS_AB[0]]);
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { useCallback, type MutableRefObject, type ReactElement } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
|
||||
export interface SidebarProjectDragInfo {
|
||||
project: SidebarProjectEntry;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
interface SidebarProjectsDraggableListProps {
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
serverId: string | null;
|
||||
renderProject: (info: SidebarProjectDragInfo) => ReactElement;
|
||||
/** Native: use the nestable variant when wrapped in a NestableScrollContainer. */
|
||||
nestable?: boolean;
|
||||
/** When false, the outer scroll container owns scrolling. */
|
||||
scrollEnabled?: boolean;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
/** Native: gesture ref to coordinate with parent (e.g. sidebar close swipe). */
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
export function SidebarProjectsDraggableList({
|
||||
projects,
|
||||
serverId,
|
||||
renderProject,
|
||||
nestable = false,
|
||||
scrollEnabled = false,
|
||||
containerStyle,
|
||||
testID,
|
||||
simultaneousGestureRef,
|
||||
}: SidebarProjectsDraggableListProps): ReactElement {
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey);
|
||||
const currentProjectOrder = getProjectOrder(serverId);
|
||||
if (
|
||||
!hasVisibleOrderChanged({
|
||||
currentOrder: currentProjectOrder,
|
||||
reorderedVisibleKeys: reorderedProjectKeys,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setProjectOrder(
|
||||
serverId,
|
||||
mergeWithRemainder({
|
||||
currentOrder: currentProjectOrder,
|
||||
reorderedVisibleKeys: reorderedProjectKeys,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[getProjectOrder, serverId, setProjectOrder],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||
renderProject({ project: item, drag, isDragging: isActive, dragHandleProps }),
|
||||
[renderProject],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableList
|
||||
testID={testID}
|
||||
data={projects as SidebarProjectEntry[]}
|
||||
keyExtractor={projectKeyExtractor}
|
||||
renderItem={renderItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
scrollEnabled={scrollEnabled}
|
||||
useDragHandle
|
||||
nestable={nestable}
|
||||
simultaneousGestureRef={simultaneousGestureRef}
|
||||
containerStyle={containerStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
interface SidebarShellHeaderProps {
|
||||
children: ReactNode;
|
||||
topSpacerStyle?: StyleProp<ViewStyle>;
|
||||
toggleSlot?: ReactNode;
|
||||
withTitlebarDragRegion?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarShellHeader({
|
||||
children,
|
||||
topSpacerStyle,
|
||||
toggleSlot,
|
||||
withTitlebarDragRegion = false,
|
||||
}: SidebarShellHeaderProps) {
|
||||
const content = (
|
||||
<Fragment>
|
||||
{withTitlebarDragRegion ? <TitlebarDragRegion /> : null}
|
||||
{topSpacerStyle ? <View style={topSpacerStyle} /> : null}
|
||||
{children}
|
||||
{toggleSlot}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (withTitlebarDragRegion || topSpacerStyle) {
|
||||
return <View style={styles.headerDragArea}>{content}</View>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
headerDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
}));
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { ReactElement, MutableRefObject } from "react";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { SidebarAgentListSkeleton } from "../sidebar-agent-list-skeleton";
|
||||
import { SidebarWorkspaceList } from "../sidebar-workspace-list";
|
||||
|
||||
interface SidebarWorkspacesViewProps {
|
||||
serverId: string | null;
|
||||
projects: SidebarProjectEntry[];
|
||||
isInitialLoad: boolean;
|
||||
isRefreshing: boolean;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleProjectCollapsed: (projectKey: string) => void;
|
||||
onRefresh: () => void;
|
||||
onAddProject: () => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
export function SidebarWorkspacesView({
|
||||
serverId,
|
||||
projects,
|
||||
isInitialLoad,
|
||||
isRefreshing,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleProjectCollapsed,
|
||||
onRefresh,
|
||||
onAddProject,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspacesViewProps): ReactElement {
|
||||
if (isInitialLoad) {
|
||||
return <SidebarAgentListSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceList
|
||||
serverId={serverId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onAddProject={onAddProject}
|
||||
parentGestureRef={parentGestureRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useContextMenu } from "@/components/ui/context-menu";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
|
||||
/**
|
||||
* Shared mobile drag affordance: long-press arms a draggable-flatlist drag with haptics,
|
||||
* arbitrating against vertical scroll, horizontal swipe, and an optional context-menu open.
|
||||
*
|
||||
* Web does not exercise this — `dragHandleProps` from `DraggableList` covers web DnD.
|
||||
*/
|
||||
export function useLongPressDragInteraction(input: {
|
||||
drag: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
}) {
|
||||
const didLongPressRef = useRef(false);
|
||||
const dragArmedRef = useRef(false);
|
||||
const dragActivatedRef = useRef(false);
|
||||
const didStartDragRef = useRef(false);
|
||||
const scrollIntentRef = useRef(false);
|
||||
const menuOpenedRef = useRef(false);
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (dragArmTimerRef.current) {
|
||||
clearTimeout(dragArmTimerRef.current);
|
||||
dragArmTimerRef.current = null;
|
||||
}
|
||||
if (contextMenuTimerRef.current) {
|
||||
clearTimeout(contextMenuTimerRef.current);
|
||||
contextMenuTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openContextMenuAtStartPoint = useCallback(() => {
|
||||
if (!input.menuController || !touchStartRef.current) {
|
||||
return;
|
||||
}
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
input.menuController.setAnchorRect({
|
||||
x: touchStartRef.current.x,
|
||||
y: touchStartRef.current.y + statusBarHeight,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
input.menuController.setOpen(true);
|
||||
menuOpenedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
}, [input.menuController]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
// Manual timers own long-press behavior on mobile.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
|
||||
const DRAG_ARM_DELAY_MS = 180;
|
||||
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||
const CONTEXT_MENU_DELAY_MS = 450;
|
||||
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
||||
|
||||
dragArmTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
dragArmedRef.current = true;
|
||||
dragActivatedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!input.menuController || platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextMenuTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
openContextMenuAtStartPoint();
|
||||
}, CONTEXT_MENU_DELAY_MS);
|
||||
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
if (!dragActivatedRef.current) {
|
||||
return;
|
||||
}
|
||||
didStartDragRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleScrollIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
scrollIntentRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleSwipeIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
didLongPressRef.current = false;
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
didStartDragRef.current = false;
|
||||
scrollIntentRef.current = false;
|
||||
menuOpenedRef.current = false;
|
||||
touchStartRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
touchCurrentRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
armTimers();
|
||||
},
|
||||
[armTimers],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||
const x = touch?.pageX;
|
||||
const y = touch?.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = { x, y };
|
||||
touchCurrentRef.current = current;
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const decision = decideLongPressMove({
|
||||
dragArmed: dragArmedRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
startPoint: start,
|
||||
currentPoint: current,
|
||||
});
|
||||
|
||||
if (decision === "vertical_scroll") {
|
||||
handleScrollIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
||||
handleSwipeIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "start_drag") {
|
||||
handleDragIntent({ dx, dy, distance });
|
||||
}
|
||||
},
|
||||
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
||||
);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
clearTimers();
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
touchStartRef.current = null;
|
||||
touchCurrentRef.current = null;
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
didLongPressRef,
|
||||
handleLongPress,
|
||||
handlePressIn,
|
||||
handleTouchMove,
|
||||
handlePressOut,
|
||||
};
|
||||
}
|
||||
@@ -7,34 +7,12 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
type SkillOp,
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const UNINSTALL_MESSAGE =
|
||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
||||
|
||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
||||
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
|
||||
add: "Add skill",
|
||||
update: "Update skill",
|
||||
delete: "Delete skill",
|
||||
};
|
||||
|
||||
function formatUpdateMessage(ops: readonly SkillOp[]): string {
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -47,18 +25,16 @@ export function IntegrationsSection() {
|
||||
} = useCliInstall();
|
||||
const {
|
||||
status: skillsStatus,
|
||||
isWorking: isSkillsWorking,
|
||||
isInstalling: isInstallingSkills,
|
||||
install: installSkills,
|
||||
update: updateSkills,
|
||||
uninstall: uninstallSkills,
|
||||
refresh: refreshSkillsStatus,
|
||||
} = useSkillsStatus();
|
||||
} = useSkillsInstall();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) return undefined;
|
||||
refreshCliStatus();
|
||||
void refreshSkillsStatus();
|
||||
refreshSkillsStatus();
|
||||
return undefined;
|
||||
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
||||
);
|
||||
@@ -69,33 +45,9 @@ export function IntegrationsSection() {
|
||||
}, [installCli, isInstallingCli]);
|
||||
|
||||
const handleInstallSkills = useCallback(() => {
|
||||
if (isSkillsWorking) return;
|
||||
void installSkills();
|
||||
}, [installSkills, isSkillsWorking]);
|
||||
|
||||
const handleUpdateSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const ops = skillsStatus?.ops ?? [];
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Update Paseo skills?",
|
||||
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
|
||||
confirmLabel: "Update",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await updateSkills();
|
||||
}, [isSkillsWorking, skillsStatus, updateSkills]);
|
||||
|
||||
const handleUninstallSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Uninstall Paseo skills?",
|
||||
message: UNINSTALL_MESSAGE,
|
||||
confirmLabel: "Uninstall",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await uninstallSkills();
|
||||
}, [isSkillsWorking, uninstallSkills]);
|
||||
if (isInstallingSkills) return;
|
||||
installSkills();
|
||||
}, [installSkills, isInstallingSkills]);
|
||||
|
||||
const handleOpenCliDocs = useCallback(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
@@ -144,8 +96,6 @@ export function IntegrationsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skillsState = skillsStatus?.state ?? null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="Integrations" trailing={trailing}>
|
||||
<View style={settingsStyles.card}>
|
||||
@@ -180,69 +130,30 @@ export function IntegrationsSection() {
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{skillsState === "drift"
|
||||
? "Update available"
|
||||
: "Teach your agents to orchestrate through the CLI"}
|
||||
Teach your agents to orchestrate through the CLI
|
||||
</Text>
|
||||
</View>
|
||||
<SkillsActions
|
||||
state={skillsState}
|
||||
isWorking={isSkillsWorking}
|
||||
onInstall={handleInstallSkills}
|
||||
onUpdate={handleUpdateSkills}
|
||||
onUninstall={handleUninstallSkills}
|
||||
/>
|
||||
{skillsStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallSkills}
|
||||
disabled={isInstallingSkills}
|
||||
>
|
||||
{isInstallingSkills ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillsActionsProps {
|
||||
state: SkillsStatus["state"] | null;
|
||||
isWorking: boolean;
|
||||
onInstall: () => void;
|
||||
onUpdate: () => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (state === "up-to-date") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "drift") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
||||
{isWorking ? "Working..." : "Update"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||
{isWorking ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerLinks: {
|
||||
flexDirection: "row",
|
||||
@@ -263,9 +174,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -220,67 +220,10 @@ export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||
|
||||
export type SkillOp =
|
||||
| { kind: "add"; name: string }
|
||||
| { kind: "update"; name: string }
|
||||
| { kind: "delete"; name: string };
|
||||
|
||||
export interface SkillsStatus {
|
||||
state: SkillsState;
|
||||
ops: SkillOp[];
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||
}
|
||||
|
||||
function parseSkillsState(value: unknown): SkillsState {
|
||||
switch (value) {
|
||||
case "not-installed":
|
||||
case "up-to-date":
|
||||
case "drift":
|
||||
return value;
|
||||
default:
|
||||
throw new Error(`Unexpected skills status state: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillOp(raw: unknown): SkillOp {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skill op response.");
|
||||
}
|
||||
const name = toStringOrNull(raw.name);
|
||||
if (!name) throw new Error("Skill op missing name.");
|
||||
switch (raw.kind) {
|
||||
case "add":
|
||||
return { kind: "add", name };
|
||||
case "update":
|
||||
return { kind: "update", name };
|
||||
case "delete":
|
||||
return { kind: "delete", name };
|
||||
default:
|
||||
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillsStatus(raw: unknown): SkillsStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skills status response.");
|
||||
}
|
||||
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
|
||||
return { state: parseSkillsState(raw.state), ops };
|
||||
}
|
||||
|
||||
export async function getSkillsStatus(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
export async function updateSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
|
||||
}
|
||||
|
||||
export async function uninstallSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
||||
import { useCliInstall, useSkillsInstall } from "./use-install-status";
|
||||
|
||||
const toast = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
@@ -15,11 +15,9 @@ const toast = vi.hoisted(() => ({
|
||||
|
||||
const desktopDaemon = vi.hoisted(() => ({
|
||||
getCliInstallStatus: vi.fn(),
|
||||
getSkillsInstallStatus: vi.fn(),
|
||||
installCli: vi.fn(),
|
||||
getSkillsStatus: vi.fn(),
|
||||
installSkills: vi.fn(),
|
||||
updateSkills: vi.fn(),
|
||||
uninstallSkills: vi.fn(),
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
@@ -91,9 +89,11 @@ describe("useCliInstall", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSkillsStatus", () => {
|
||||
describe("useSkillsInstall", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
|
||||
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,155 +101,34 @@ describe("useSkillsStatus", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads the current skills status", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
});
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
it("loads skills install status", async () => {
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
expect(result.current.status).toEqual({ installed: true });
|
||||
});
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.install();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.installSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it("update transitions drift to up-to-date", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "drift",
|
||||
ops: [{ kind: "update", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("drift");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it("uninstall transitions up-to-date back to not-installed", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
desktopDaemon.uninstallSkills.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("up-to-date");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uninstall();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("isWorking flips while a mutation is in flight", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
|
||||
let resolveInstall: ((value: unknown) => void) | null = null;
|
||||
desktopDaemon.installSkills.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveInstall = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
|
||||
let installPromise: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
installPromise = result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isWorking).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveInstall?.({ state: "up-to-date", ops: [] });
|
||||
await installPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
});
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
|
||||
it("toasts and exposes errors when install fails", async () => {
|
||||
it("toasts and exposes skills install errors", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
expect(result.current.status).toEqual({ installed: false });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.install();
|
||||
act(() => {
|
||||
result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
||||
});
|
||||
|
||||
@@ -2,14 +2,11 @@ import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getCliInstallStatus,
|
||||
getSkillsStatus,
|
||||
getSkillsInstallStatus,
|
||||
installCli,
|
||||
installSkills,
|
||||
shouldUseDesktopDaemon,
|
||||
type InstallStatus,
|
||||
type SkillsStatus,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
useDesktopIpcErrorReporter,
|
||||
@@ -17,7 +14,11 @@ import {
|
||||
} from "@/desktop/hooks/desktop-ipc-error";
|
||||
|
||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
||||
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
|
||||
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
|
||||
"desktop",
|
||||
"integrations",
|
||||
"skills-install-status",
|
||||
] as const;
|
||||
|
||||
interface DesktopInstallHookResult {
|
||||
status: InstallStatus | null;
|
||||
@@ -76,43 +77,25 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillsStatusHookResult {
|
||||
status: SkillsStatus | null;
|
||||
isLoading: boolean;
|
||||
isWorking: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
install: () => Promise<void>;
|
||||
update: () => Promise<void>;
|
||||
uninstall: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const statusQuery = useQuery<SkillsStatus, Error>({
|
||||
queryKey: SKILLS_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsStatus,
|
||||
const statusQuery = useQuery<InstallStatus, Error>({
|
||||
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsInstallStatus,
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check orchestration skills status.",
|
||||
message: "Unable to check orchestration skills install status.",
|
||||
logLabel: "[Integrations] Failed to load skills status",
|
||||
});
|
||||
|
||||
const setStatus = useCallback(
|
||||
(next: SkillsStatus) => {
|
||||
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const installMutation = useMutation<SkillsStatus, Error>({
|
||||
const installMutation = useMutation<InstallStatus, Error>({
|
||||
mutationFn: installSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
@@ -121,65 +104,23 @@ export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
logLabel: "[Integrations] Failed to install skills",
|
||||
});
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: updateSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to update orchestration skills.",
|
||||
logLabel: "[Integrations] Failed to update skills",
|
||||
});
|
||||
onSuccess: (nextStatus) => {
|
||||
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||
|
||||
const uninstallMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: uninstallSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to uninstall orchestration skills.",
|
||||
logLabel: "[Integrations] Failed to uninstall skills",
|
||||
});
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
|
||||
const isWorking =
|
||||
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await refetch();
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const install = useCallback(async () => {
|
||||
await installMutation.mutateAsync().catch(() => undefined);
|
||||
}, [installMutation]);
|
||||
|
||||
const update = useCallback(async () => {
|
||||
await updateMutation.mutateAsync().catch(() => undefined);
|
||||
}, [updateMutation]);
|
||||
|
||||
const uninstall = useCallback(async () => {
|
||||
await uninstallMutation.mutateAsync().catch(() => undefined);
|
||||
}, [uninstallMutation]);
|
||||
|
||||
return {
|
||||
status: status ?? null,
|
||||
status: installStatus ?? null,
|
||||
isLoading,
|
||||
isWorking,
|
||||
error:
|
||||
statusError ??
|
||||
installMutation.error ??
|
||||
updateMutation.error ??
|
||||
uninstallMutation.error ??
|
||||
null,
|
||||
refresh,
|
||||
isInstalling,
|
||||
error: statusError ?? installError ?? null,
|
||||
install,
|
||||
update,
|
||||
uninstall,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +184,21 @@ describe("UpdateCalloutSource", () => {
|
||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the changelog action once the update is installed", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
status: "installed",
|
||||
availableUpdate: null,
|
||||
};
|
||||
await renderHarness(root!);
|
||||
|
||||
expect(container?.textContent).toContain("Update installed");
|
||||
expect(
|
||||
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
|
||||
).toContain("What's new");
|
||||
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the install action and shows Installing... while installing", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
|
||||
@@ -13,18 +13,25 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
|
||||
function resolveUpdateCalloutTitle(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
}): string {
|
||||
if (args.isInstalled) return "Update installed";
|
||||
if (args.isInstalling) return "Installing update";
|
||||
if (args.isError) return "Update failed";
|
||||
return "Update available";
|
||||
}
|
||||
|
||||
function resolveUpdateCalloutDescription(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
errorMessage: string | null;
|
||||
latestVersion: string | undefined;
|
||||
}): ReactNode {
|
||||
if (args.isInstalled) return "Restart to use the new version.";
|
||||
if (args.isInstalling) return "Installing and restarting...";
|
||||
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
||||
if (args.latestVersion) {
|
||||
@@ -36,6 +43,7 @@ function resolveUpdateCalloutDescription(args: {
|
||||
}
|
||||
|
||||
function buildUpdateCalloutActions(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
openChangelog: () => void;
|
||||
@@ -45,7 +53,7 @@ function buildUpdateCalloutActions(args: {
|
||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
||||
if (args.isError) {
|
||||
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
||||
} else {
|
||||
} else if (!args.isInstalled) {
|
||||
actions.push({
|
||||
label: args.isInstalling ? "Installing..." : "Install & restart",
|
||||
onPress: args.install,
|
||||
@@ -99,21 +107,29 @@ export function UpdateCalloutSource() {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
if (status !== "available" && status !== "installing" && status !== "error") {
|
||||
if (
|
||||
status !== "available" &&
|
||||
status !== "installed" &&
|
||||
status !== "installing" &&
|
||||
status !== "error"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInstalled = status === "installed";
|
||||
const isError = status === "error";
|
||||
const isAvailable = !isInstalling && !isError;
|
||||
const isAvailable = !isInstalled && !isInstalling && !isError;
|
||||
|
||||
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
|
||||
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
|
||||
const description = resolveUpdateCalloutDescription({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
errorMessage,
|
||||
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
||||
});
|
||||
const actions = buildUpdateCalloutActions({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
openChangelog,
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useAggregatedAgentIds, useAggregatedAgents } from "./use-aggregated-agents";
|
||||
|
||||
const { mockRuntimeStore } = vi.hoisted(() => ({
|
||||
mockRuntimeStore: {
|
||||
subscribeAll: vi.fn(() => () => undefined),
|
||||
getVersion: vi.fn(() => 1),
|
||||
getSnapshot: vi.fn(() => ({ agentDirectoryStatus: "idle" })),
|
||||
refreshAllAgentDirectories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => mockRuntimeStore,
|
||||
useHosts: () => [{ serverId: "server-1", label: "Local" }],
|
||||
}));
|
||||
|
||||
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
function makeAgent(input: Partial<Agent>): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function seedAgents(agents: Agent[]) {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents("server-1", new Map(agents.map((agent) => [agent.id, agent])));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mockRuntimeStore.subscribeAll.mockClear();
|
||||
mockRuntimeStore.getVersion.mockClear();
|
||||
mockRuntimeStore.getSnapshot.mockClear();
|
||||
mockRuntimeStore.refreshAllAgentDirectories.mockClear();
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
});
|
||||
|
||||
describe("useAggregatedAgents", () => {
|
||||
it("sorts by createdAt descending by default and falls back to id for ties", () => {
|
||||
seedAgents([
|
||||
makeAgent({
|
||||
id: "beta",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "newest",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "alpha",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgents());
|
||||
|
||||
expect(result.current.agents.map((agent) => agent.id)).toEqual(["newest", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("can preserve the legacy running-then-activity sort", () => {
|
||||
seedAgents([
|
||||
makeAgent({
|
||||
id: "idle-newer",
|
||||
status: "idle",
|
||||
lastActivityAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "running-older",
|
||||
status: "running",
|
||||
lastActivityAt: new Date("2026-05-08T09:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "running-newer",
|
||||
status: "running",
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgents({ sort: "running-then-activity" }));
|
||||
|
||||
expect(result.current.agents.map((agent) => agent.id)).toEqual([
|
||||
"running-newer",
|
||||
"running-older",
|
||||
"idle-newer",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAggregatedAgentIds", () => {
|
||||
it("keeps the same id array reference when membership and order are unchanged", () => {
|
||||
seedAgents([
|
||||
makeAgent({
|
||||
id: "alpha",
|
||||
title: "Alpha",
|
||||
status: "idle",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "beta",
|
||||
title: "Beta",
|
||||
status: "running",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgentIds());
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
[
|
||||
"alpha",
|
||||
makeAgent({
|
||||
id: "alpha",
|
||||
title: "Renamed Alpha",
|
||||
status: "running",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"beta",
|
||||
makeAgent({
|
||||
id: "beta",
|
||||
title: "Renamed Beta",
|
||||
status: "idle",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-08T12:30:00.000Z"),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
expect(result.current).toEqual(["beta", "alpha"]);
|
||||
});
|
||||
|
||||
it("changes the id array reference when membership changes", () => {
|
||||
seedAgents([makeAgent({ id: "alpha" })]);
|
||||
|
||||
const { result } = renderHook(() => useAggregatedAgentIds());
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
["alpha", makeAgent({ id: "alpha" })],
|
||||
["beta", makeAgent({ id: "beta", createdAt: new Date("2026-05-08T11:00:00.000Z") })],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).not.toBe(before);
|
||||
expect(result.current).toEqual(["beta", "alpha"]);
|
||||
});
|
||||
|
||||
it("keeps the same id array reference when a local predicate still yields the same ids", () => {
|
||||
seedAgents([
|
||||
makeAgent({ id: "mapped", cwd: "/tmp/project" }),
|
||||
makeAgent({
|
||||
id: "unmapped",
|
||||
cwd: "/tmp/missing",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAggregatedAgentIds({
|
||||
filter: (agent) => agent.cwd === "/tmp/project" && !agent.archivedAt,
|
||||
}),
|
||||
);
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
["mapped", makeAgent({ id: "mapped", cwd: "/tmp/project", title: "Renamed" })],
|
||||
[
|
||||
"unmapped",
|
||||
makeAgent({
|
||||
id: "unmapped",
|
||||
cwd: "/tmp/missing",
|
||||
title: "Still hidden",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
expect(result.current).toEqual(["mapped"]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useCallback, useSyncExternalStore } from "react";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
@@ -19,151 +18,12 @@ export interface AggregatedAgentsResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
export type AggregatedAgentSortOption = "createdAt-desc-stable" | "running-then-activity";
|
||||
|
||||
export interface AggregatedAgentIdEntry {
|
||||
id: string;
|
||||
serverId: string;
|
||||
status: Agent["status"];
|
||||
lastActivityAt: Date;
|
||||
cwd: string;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface AggregatedAgentSortFields {
|
||||
id: string;
|
||||
status: Agent["status"];
|
||||
lastActivityAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function compareAggregatedAgentsByCreatedAtDescStable(
|
||||
left: AggregatedAgentSortFields,
|
||||
right: AggregatedAgentSortFields,
|
||||
): number {
|
||||
const createdAtDiff = right.createdAt.getTime() - left.createdAt.getTime();
|
||||
if (createdAtDiff !== 0) {
|
||||
return createdAtDiff;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function compareAggregatedAgentsByRunningThenActivity(
|
||||
left: AggregatedAgentSortFields,
|
||||
right: AggregatedAgentSortFields,
|
||||
): number {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = left.lastActivityAt.getTime();
|
||||
const rightTime = right.lastActivityAt.getTime();
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
function sortAggregatedAgents(
|
||||
agents: AggregatedAgent[],
|
||||
sort: AggregatedAgentSortOption,
|
||||
): AggregatedAgent[] {
|
||||
agents.sort(
|
||||
sort === "running-then-activity"
|
||||
? compareAggregatedAgentsByRunningThenActivity
|
||||
: compareAggregatedAgentsByCreatedAtDescStable,
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
function sortAggregatedAgentIdEntries(
|
||||
agents: AggregatedAgentIdEntry[],
|
||||
sort: AggregatedAgentSortOption,
|
||||
): AggregatedAgentIdEntry[] {
|
||||
agents.sort(
|
||||
sort === "running-then-activity"
|
||||
? compareAggregatedAgentsByRunningThenActivity
|
||||
: compareAggregatedAgentsByCreatedAtDescStable,
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
export function useAggregatedAgentIds(options?: {
|
||||
includeArchived?: boolean;
|
||||
sort?: AggregatedAgentSortOption;
|
||||
filter?: (agent: AggregatedAgentIdEntry) => boolean;
|
||||
}): string[] {
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const sort = options?.sort ?? "createdAt-desc-stable";
|
||||
const filter = options?.filter;
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
const agents: AggregatedAgentIdEntry[] = [];
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
for (const agent of session.agents.values()) {
|
||||
if (!includeArchived && agent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
const entry: AggregatedAgentIdEntry = {
|
||||
id: agent.id,
|
||||
serverId,
|
||||
status: agent.status,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
createdAt: agent.createdAt,
|
||||
};
|
||||
if (!filter || filter(entry)) {
|
||||
agents.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortAggregatedAgentIdEntries(agents, sort).map((agent) => agent.id);
|
||||
},
|
||||
shallow,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAggregatedAgentsInitialLoad(): boolean {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const agentCount = useSessionStore((state) => {
|
||||
let count = 0;
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
for (const agent of session.agents.values()) {
|
||||
if (!agent.archivedAt) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
void runtimeVersion;
|
||||
const isLoading = daemons.some((daemon) => {
|
||||
const status = runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
|
||||
return status === "initial_loading" || status === "revalidating";
|
||||
});
|
||||
return isLoading && agentCount === 0;
|
||||
}
|
||||
|
||||
export function useAggregatedAgents(options?: {
|
||||
includeArchived?: boolean;
|
||||
sort?: AggregatedAgentSortOption;
|
||||
}): AggregatedAgentsResult {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const sort = options?.sort ?? "createdAt-desc-stable";
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
@@ -223,7 +83,20 @@ export function useAggregatedAgents(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
sortAggregatedAgents(allAgents, sort);
|
||||
// Sort by: running agents first, then by most recent activity
|
||||
allAgents.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = left.lastActivityAt.getTime();
|
||||
const rightTime = right.lastActivityAt.getTime();
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
|
||||
// Check if we have any cached data
|
||||
const hasAnyData = allAgents.length > 0;
|
||||
@@ -243,7 +116,7 @@ export function useAggregatedAgents(options?: {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
};
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents, sort]);
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-all-agents-list";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
refreshAgentDirectory: vi.fn(),
|
||||
}),
|
||||
useHostRuntimeConnectionStatus: () => "online",
|
||||
useHostRuntimeIsDirectoryLoading: () => false,
|
||||
useHosts: () => [{ serverId: "server-1", label: "Local" }],
|
||||
}));
|
||||
|
||||
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import {
|
||||
useKeyboardShortcutsStore,
|
||||
type ProjectPickerIntent,
|
||||
} from "@/stores/keyboard-shortcuts-store";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useIsLocalDaemon } from "./use-is-local-daemon";
|
||||
import { useOpenProject } from "./use-open-project";
|
||||
|
||||
const OPEN_PROJECT_INTENT: ProjectPickerIntent = { kind: "open-project" };
|
||||
|
||||
export function useOpenProjectPicker(
|
||||
serverId: string | null,
|
||||
intent: ProjectPickerIntent = OPEN_PROJECT_INTENT,
|
||||
): () => Promise<void> {
|
||||
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
|
||||
@@ -26,7 +16,7 @@ export function useOpenProjectPicker(
|
||||
}
|
||||
|
||||
if (!isLocalDaemon) {
|
||||
setProjectPickerOpen(true, intent);
|
||||
setProjectPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,15 +25,6 @@ export function useOpenProjectPicker(
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === "new-workspace") {
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(normalizedServerId, path, {
|
||||
headerTitle: intent.headerTitle,
|
||||
}) as never,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await openProject(path);
|
||||
}, [intent, isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
sourceDirectory: string;
|
||||
displayName?: string;
|
||||
headerTitle?: string;
|
||||
}
|
||||
|
||||
interface PickerOptionData {
|
||||
@@ -330,11 +329,6 @@ function computeWorkspaceTitle(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNewWorkspaceHeaderTitle(headerTitle: string | undefined): string {
|
||||
const trimmed = headerTitle?.trim();
|
||||
return trimmed ? trimmed : "New workspace";
|
||||
}
|
||||
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
const {
|
||||
serverId,
|
||||
@@ -391,7 +385,6 @@ export function NewWorkspaceScreen({
|
||||
serverId,
|
||||
sourceDirectory,
|
||||
displayName: displayNameProp,
|
||||
headerTitle: headerTitleProp,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -419,7 +412,6 @@ export function NewWorkspaceScreen({
|
||||
}, [pickerSearchQuery]);
|
||||
|
||||
const displayName = displayNameProp?.trim() ?? "";
|
||||
const headerTitle = resolveNewWorkspaceHeaderTitle(headerTitleProp);
|
||||
const workspace = createdWorkspace;
|
||||
const selectedItem = pickerSelection?.item ?? null;
|
||||
const isPending = pendingAction !== null;
|
||||
@@ -719,7 +711,7 @@ export function NewWorkspaceScreen({
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{headerTitle}
|
||||
New workspace
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{workspaceTitle}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -745,19 +745,16 @@ function SettingsSidebar({
|
||||
) : null}
|
||||
<View style={sidebarStyles.list}>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<SidebarSectionButton
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isSelected={selectedSectionId === item.id}
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
{item.id === "general" ? (
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
<SidebarSectionButton
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isSelected={selectedSectionId === item.id}
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
))}
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<View style={sidebarStyles.list}>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
||||
@@ -28,7 +27,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
@@ -40,7 +38,6 @@ interface OpenTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
requiresLocalDaemon: boolean;
|
||||
onOpen: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -85,16 +82,14 @@ export function WorkspaceOpenInEditorButton({
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldQueryWorkspace =
|
||||
const shouldLoadTargets =
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadEditorTargets,
|
||||
enabled: shouldLoadTargets,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -117,7 +112,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
|
||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: shouldQueryWorkspace ? cwd : "",
|
||||
cwd: shouldLoadTargets ? cwd : "",
|
||||
});
|
||||
|
||||
const editorTargets = useMemo<OpenTarget[]>(
|
||||
@@ -126,7 +121,6 @@ export function WorkspaceOpenInEditorButton({
|
||||
id: editor.id,
|
||||
label: editor.label,
|
||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: true,
|
||||
onOpen: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
@@ -155,20 +149,13 @@ export function WorkspaceOpenInEditorButton({
|
||||
id: "github",
|
||||
label: "GitHub",
|
||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: false,
|
||||
onOpen: () => openExternalUrl(url),
|
||||
};
|
||||
}, [checkoutStatus]);
|
||||
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
filterTargetsForDaemonLocation(
|
||||
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
|
||||
{
|
||||
isLocalDaemon,
|
||||
},
|
||||
),
|
||||
[editorTargets, githubTarget, isLocalDaemon],
|
||||
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
|
||||
[editorTargets, githubTarget],
|
||||
);
|
||||
|
||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||
@@ -223,7 +210,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
}
|
||||
}, [primaryOption, handleOpenTarget]);
|
||||
|
||||
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
|
||||
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
||||
|
||||
describe("filterTargetsForDaemonLocation", () => {
|
||||
const targets = [
|
||||
{ id: "cursor", requiresLocalDaemon: true },
|
||||
{ id: "vscode", requiresLocalDaemon: true },
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
];
|
||||
|
||||
it("keeps local app targets and URL targets for the local daemon", () => {
|
||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
|
||||
});
|
||||
|
||||
it("hides local app targets for a remote daemon", () => {
|
||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves target order after filtering", () => {
|
||||
expect(
|
||||
filterTargetsForDaemonLocation(
|
||||
[
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
{ id: "finder", requiresLocalDaemon: true },
|
||||
{ id: "docs", requiresLocalDaemon: false },
|
||||
],
|
||||
{ isLocalDaemon: false },
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
{ id: "docs", requiresLocalDaemon: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export interface WorkspaceOpenTargetAvailability {
|
||||
requiresLocalDaemon: boolean;
|
||||
}
|
||||
|
||||
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
|
||||
targets: readonly Target[],
|
||||
input: { isLocalDaemon: boolean },
|
||||
): Target[] {
|
||||
if (input.isLocalDaemon) {
|
||||
return [...targets];
|
||||
}
|
||||
return targets.filter((target) => !target.requiresLocalDaemon);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useSidebarSessionsController } from "./controller";
|
||||
|
||||
describe("useSidebarSessionsController", () => {
|
||||
it("starts with project grouping off and no expanded projects", () => {
|
||||
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||
|
||||
expect(result.current.groupByProject).toBe(false);
|
||||
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("enables project grouping without expanding projects", () => {
|
||||
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.setGroupByProject(true);
|
||||
});
|
||||
|
||||
expect(result.current.groupByProject).toBe(true);
|
||||
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("toggles one expanded project", () => {
|
||||
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProjectPreviewExpanded("p1");
|
||||
});
|
||||
|
||||
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProjectPreviewExpanded("p1");
|
||||
});
|
||||
|
||||
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("toggles expanded projects independently", () => {
|
||||
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProjectPreviewExpanded("p1");
|
||||
result.current.toggleProjectPreviewExpanded("p2");
|
||||
});
|
||||
|
||||
expect([...result.current.previewExpandedProjects].sort()).toEqual(["p1", "p2"]);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProjectPreviewExpanded("p1");
|
||||
});
|
||||
|
||||
expect([...result.current.previewExpandedProjects]).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("clears expanded projects when project grouping is disabled", () => {
|
||||
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.setGroupByProject(true);
|
||||
result.current.toggleProjectPreviewExpanded("p1");
|
||||
});
|
||||
|
||||
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setGroupByProject(false);
|
||||
});
|
||||
|
||||
expect(result.current.groupByProject).toBe(false);
|
||||
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("resets the session filter when the active host changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ serverId }) => useSidebarSessionsController({ serverId }),
|
||||
{ initialProps: { serverId: "server-1" as string | null } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSidebarSessionFilter({
|
||||
type: "project",
|
||||
projectKey: "project-a",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.sidebarSessionFilter).toEqual({
|
||||
type: "project",
|
||||
projectKey: "project-a",
|
||||
});
|
||||
|
||||
rerender({ serverId: "server-2" });
|
||||
|
||||
expect(result.current.sidebarSessionFilter).toEqual({ type: "all" });
|
||||
expect(result.current.sidebarViewMode).toBe("workspaces");
|
||||
});
|
||||
|
||||
it("clears expanded projects when the active host changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ serverId }) => useSidebarSessionsController({ serverId }),
|
||||
{ initialProps: { serverId: "server-1" as string | null } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setGroupByProject(true);
|
||||
result.current.toggleProjectPreviewExpanded("p1");
|
||||
});
|
||||
|
||||
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||
|
||||
rerender({ serverId: "server-2" });
|
||||
|
||||
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("clears expanded projects when the session filter changes", () => {
|
||||
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.setGroupByProject(true);
|
||||
result.current.toggleProjectPreviewExpanded("p1");
|
||||
});
|
||||
|
||||
expect(result.current.previewExpandedProjects.has("p1")).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setSidebarSessionFilter({
|
||||
type: "project",
|
||||
projectKey: "project-a",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.sidebarSessionFilter).toEqual({
|
||||
type: "project",
|
||||
projectKey: "project-a",
|
||||
});
|
||||
expect(result.current.previewExpandedProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not clear the session filter when project grouping is enabled", () => {
|
||||
const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.setSidebarSessionFilter({
|
||||
type: "project",
|
||||
projectKey: "project-a",
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setGroupByProject(true);
|
||||
});
|
||||
|
||||
expect(result.current.groupByProject).toBe(true);
|
||||
expect(result.current.sidebarSessionFilter).toEqual({
|
||||
type: "project",
|
||||
projectKey: "project-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves project grouping when the active host changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ serverId }) => useSidebarSessionsController({ serverId }),
|
||||
{ initialProps: { serverId: "server-1" as string | null } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setGroupByProject(true);
|
||||
});
|
||||
|
||||
rerender({ serverId: "server-2" });
|
||||
|
||||
expect(result.current.groupByProject).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
|
||||
|
||||
export function useSidebarSessionsController({ serverId }: { serverId: string | null }) {
|
||||
const previousServerIdRef = useRef(serverId);
|
||||
const [sidebarViewMode, setSidebarViewMode] = useState<SidebarSessionViewMode>("workspaces");
|
||||
const [sidebarSessionFilter, setSidebarSessionFilter] = useState<SidebarSessionFilter>({
|
||||
type: "all",
|
||||
});
|
||||
const [groupByProject, setGroupByProjectState] = useState(false);
|
||||
const [previewExpandedProjects, setPreviewExpandedProjects] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const setGroupByProject = useCallback((next: boolean) => {
|
||||
setGroupByProjectState(next);
|
||||
if (!next) {
|
||||
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSidebarSessionFilterWithReset = useCallback((next: SidebarSessionFilter) => {
|
||||
setSidebarSessionFilter(next);
|
||||
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
|
||||
}, []);
|
||||
|
||||
const toggleProjectPreviewExpanded = useCallback((projectKey: string) => {
|
||||
setPreviewExpandedProjects((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(projectKey)) {
|
||||
next.delete(projectKey);
|
||||
} else {
|
||||
next.add(projectKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousServerIdRef.current === serverId) {
|
||||
return;
|
||||
}
|
||||
previousServerIdRef.current = serverId;
|
||||
setSidebarSessionFilter({ type: "all" });
|
||||
setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set()));
|
||||
}, [serverId]);
|
||||
|
||||
return {
|
||||
sidebarViewMode,
|
||||
setSidebarViewMode,
|
||||
sidebarSessionFilter,
|
||||
setSidebarSessionFilter: setSidebarSessionFilterWithReset,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
setGroupByProject,
|
||||
toggleProjectPreviewExpanded,
|
||||
};
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import React, { memo, useCallback, useMemo, useState, type ReactElement, type Ref } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { SidebarProjectSection } from "@/components/sidebar/sidebar-collapsible-project-section";
|
||||
import {
|
||||
SidebarProjectTrailingActions,
|
||||
type SidebarProjectCreateButtonConfig,
|
||||
} from "@/components/sidebar/sidebar-project-trailing-actions";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
deriveGroupedSidebarSessions,
|
||||
type SidebarSessionAgentProject,
|
||||
type SidebarSessionGroup,
|
||||
} from "./session-filtering";
|
||||
import type { ResolvedSidebarSessionProject } from "./types";
|
||||
|
||||
export const GROUPED_SESSION_LIMIT = 6;
|
||||
|
||||
type SessionStoreState = ReturnType<typeof useSessionStore.getState>;
|
||||
|
||||
type ResolveCwdToProject = (cwd: string) => ResolvedSidebarSessionProject | null;
|
||||
|
||||
export function useOrderedAgentProjectShape(input: {
|
||||
orderedIds: readonly string[];
|
||||
serverId: string;
|
||||
resolveCwdToProject: ResolveCwdToProject;
|
||||
}): readonly SidebarSessionAgentProject[] {
|
||||
const { orderedIds, resolveCwdToProject, serverId } = input;
|
||||
const selector = useMemo(() => {
|
||||
let previousById = new Map<string, SidebarSessionAgentProject>();
|
||||
|
||||
return (state: SessionStoreState) => {
|
||||
const agents = state.sessions[serverId]?.agents;
|
||||
if (!agents) {
|
||||
previousById = new Map();
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextById = new Map<string, SidebarSessionAgentProject>();
|
||||
const agentsWithProjects: SidebarSessionAgentProject[] = [];
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const agent = agents.get(id);
|
||||
if (!agent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const project = resolveCwdToProject(agent.cwd);
|
||||
if (!project) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = previousById.get(id);
|
||||
const next =
|
||||
previous && previous.projectKey === project.projectKey
|
||||
? previous
|
||||
: { id, projectKey: project.projectKey };
|
||||
|
||||
nextById.set(id, next);
|
||||
agentsWithProjects.push(next);
|
||||
}
|
||||
|
||||
previousById = nextById;
|
||||
return agentsWithProjects;
|
||||
};
|
||||
}, [orderedIds, resolveCwdToProject, serverId]);
|
||||
|
||||
return useStoreWithEqualityFn(useSessionStore, selector, shallow);
|
||||
}
|
||||
|
||||
export function useGroupedSidebarSessionGroups(input: {
|
||||
agentsWithProjects: readonly SidebarSessionAgentProject[];
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
previewExpandedProjects: ReadonlySet<string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
}): readonly SidebarSessionGroup[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: input.agentsWithProjects,
|
||||
projects: input.projects,
|
||||
previewExpandedProjects: input.previewExpandedProjects,
|
||||
collapsedProjectKeys: input.collapsedProjectKeys,
|
||||
limit: GROUPED_SESSION_LIMIT,
|
||||
}),
|
||||
[
|
||||
input.agentsWithProjects,
|
||||
input.collapsedProjectKeys,
|
||||
input.previewExpandedProjects,
|
||||
input.projects,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader({
|
||||
serverId,
|
||||
projectKey,
|
||||
projectName,
|
||||
projectIconKey,
|
||||
workspaces,
|
||||
isCollapsed,
|
||||
isDragging = false,
|
||||
drag,
|
||||
dragHandleProps,
|
||||
onToggleCollapsed,
|
||||
children,
|
||||
footer = null,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectIconKey: string | null;
|
||||
workspaces: readonly SidebarWorkspaceEntry[];
|
||||
isCollapsed: boolean;
|
||||
isDragging?: boolean;
|
||||
drag?: () => void;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
onToggleCollapsed: (projectKey: string) => void;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}): ReactElement {
|
||||
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" });
|
||||
const dataUri = useMemo(() => {
|
||||
if (!icon || !icon.mimeType || !icon.data) {
|
||||
return null;
|
||||
}
|
||||
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||
}, [icon]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const sourceDirectory = projectIconKey ?? undefined;
|
||||
const handleCreateSessionPress = useCallback(() => {
|
||||
if (!serverId || !sourceDirectory) {
|
||||
return;
|
||||
}
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, sourceDirectory, {
|
||||
headerTitle: "New session",
|
||||
}) as Href,
|
||||
);
|
||||
}, [serverId, sourceDirectory]);
|
||||
const createButton = useMemo<SidebarProjectCreateButtonConfig | null>(
|
||||
() =>
|
||||
serverId && sourceDirectory
|
||||
? {
|
||||
onPress: handleCreateSessionPress,
|
||||
accessibilityLabel: "New session",
|
||||
testID: `sidebar-project-${projectKey}-new-session-button`,
|
||||
tooltipLabel: "New session",
|
||||
icon: "plus" as const,
|
||||
}
|
||||
: null,
|
||||
[handleCreateSessionPress, projectKey, serverId, sourceDirectory],
|
||||
);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag: drag ?? noopDrag,
|
||||
menuController: null,
|
||||
});
|
||||
const trailingSlot = useMemo(
|
||||
() => (
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey={projectKey}
|
||||
projectName={projectName}
|
||||
serverId={serverId}
|
||||
isHovered={isHovered}
|
||||
workspaces={workspaces}
|
||||
createButton={createButton}
|
||||
/>
|
||||
),
|
||||
[createButton, isHovered, projectKey, projectName, serverId, workspaces],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onToggleCollapsed(projectKey);
|
||||
}, [interaction.didLongPressRef, onToggleCollapsed, projectKey]);
|
||||
const headerProps = useMemo(
|
||||
() => ({
|
||||
accessibilityLabel: isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`,
|
||||
onPressIn: drag ? interaction.handlePressIn : undefined,
|
||||
onTouchMove: drag ? interaction.handleTouchMove : undefined,
|
||||
onPressOut: drag ? interaction.handlePressOut : undefined,
|
||||
}),
|
||||
[
|
||||
drag,
|
||||
interaction.handlePressIn,
|
||||
interaction.handlePressOut,
|
||||
interaction.handleTouchMove,
|
||||
isCollapsed,
|
||||
projectName,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
{...dragHandleProps?.attributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<SidebarProjectSection
|
||||
projectKey={projectKey}
|
||||
projectName={projectName}
|
||||
iconDataUri={dataUri}
|
||||
chevron={isCollapsed ? "expand" : "collapse"}
|
||||
isHovered={isHovered}
|
||||
isDragging={isDragging}
|
||||
trailingSlot={trailingSlot}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-session-group-header-${projectKey}`}
|
||||
isCollapsed={isCollapsed}
|
||||
footer={footer}
|
||||
headerProps={headerProps}
|
||||
>
|
||||
{children}
|
||||
</SidebarProjectSection>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function noopDrag() {}
|
||||
|
||||
export const SidebarSessionGroupFooter = memo(function SidebarSessionGroupFooter({
|
||||
projectKey,
|
||||
hiddenCount,
|
||||
isExpanded,
|
||||
onPress,
|
||||
}: {
|
||||
projectKey: string;
|
||||
hiddenCount: number;
|
||||
isExpanded: boolean;
|
||||
onPress: (projectKey: string) => void;
|
||||
}): ReactElement {
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(projectKey);
|
||||
}, [onPress, projectKey]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExpanded ? "Show less sessions" : `Show ${hiddenCount} more sessions`}
|
||||
onPress={handlePress}
|
||||
style={footerRowStyle}
|
||||
testID={`sidebar-session-group-footer-${projectKey}`}
|
||||
>
|
||||
<Text style={styles.footerText}>{isExpanded ? "Show less" : `Show ${hiddenCount} more`}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
footerRow: {
|
||||
minHeight: 32,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
footerText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
const footerRowStyle = styles.footerRow;
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useSidebarSessionsController } from "./controller";
|
||||
export { SidebarSessionsToggle } from "./toggle";
|
||||
export { SidebarSessionsView } from "./view";
|
||||
export type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
|
||||
@@ -1,432 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
SidebarSessionGroupFooter,
|
||||
SidebarSessionGroupHeader,
|
||||
useGroupedSidebarSessionGroups,
|
||||
useOrderedAgentProjectShape,
|
||||
} from "./grouped-view";
|
||||
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
|
||||
|
||||
function gitProject(key: string): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey: key,
|
||||
projectName: `Project ${key}`,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/repo/${key}`,
|
||||
workspaces: [
|
||||
{
|
||||
workspaceKey: `server-1:${key}-ws`,
|
||||
serverId: "server-1",
|
||||
workspaceId: `${key}-ws`,
|
||||
projectKey: key,
|
||||
projectRootPath: `/repo/${key}`,
|
||||
workspaceDirectory: `/repo/${key}/ws`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: `${key}-ws`,
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const HARNESS_PROJECTS: readonly SidebarProjectEntry[] = [
|
||||
gitProject("project-a"),
|
||||
gitProject("project-b"),
|
||||
];
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
});
|
||||
|
||||
const { routerNavigateMock } = vi.hoisted(() => ({
|
||||
routerNavigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-project-icon-query", () => ({
|
||||
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
FolderPlus: createIcon("FolderPlus"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
Plus: createIcon("Plus"),
|
||||
Settings: createIcon("Settings"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: routerNavigateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||
<View testID={testID}>{children}</View>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID}>
|
||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||
</Pressable>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/shortcut", () => ({
|
||||
Shortcut: () => <View />,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => ({ error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||
useShortcutKeys: () => null,
|
||||
}));
|
||||
|
||||
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||
const SERVER_ID = "server-1";
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) =>
|
||||
typeof styles === "function"
|
||||
? styles({
|
||||
borderRadius: { lg: 8, md: 6, sm: 4 },
|
||||
colors: {
|
||||
border: "#dddddd",
|
||||
foreground: "#111111",
|
||||
foregroundMuted: "#666666",
|
||||
surface2: "#eeeeee",
|
||||
surfaceSidebarHover: "#f5f5f5",
|
||||
},
|
||||
fontSize: { sm: 14, xs: 12 },
|
||||
iconSize: { md: 20 },
|
||||
shadow: { md: {} },
|
||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||
})
|
||||
: styles,
|
||||
},
|
||||
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
function ThemedComponent(props: Record<string, unknown>) {
|
||||
return <Component {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "running",
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: "/repo/main",
|
||||
model: null,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
function makeAgent(input: Partial<Agent> = {}): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function seedAgent(agent: Agent) {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
|
||||
}
|
||||
|
||||
function seedAgents(agents: Agent[]) {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents(SERVER_ID, new Map(agents.map((agent) => [agent.id, agent])));
|
||||
}
|
||||
|
||||
function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet<string> }) {
|
||||
const orderedIds = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => Array.from(state.sessions[SERVER_ID]?.agents?.keys() ?? []),
|
||||
shallow,
|
||||
);
|
||||
const resolveCwdToProject = useCallback((cwd: string) => {
|
||||
if (cwd === "/repo/b") {
|
||||
return { projectKey: "project-b", projectName: "Project B", projectIconKey: "/repo/b" };
|
||||
}
|
||||
return { projectKey: "project-a", projectName: "Project A", projectIconKey: "/repo/a" };
|
||||
}, []);
|
||||
const agentsWithProjects = useOrderedAgentProjectShape({
|
||||
orderedIds,
|
||||
serverId: SERVER_ID,
|
||||
resolveCwdToProject,
|
||||
});
|
||||
const previewExpandedProjects = useMemo(() => new Set<string>(), []);
|
||||
const fallbackCollapsed = useMemo(() => new Set<string>(), []);
|
||||
const collapsedProjectKeys = input?.collapsedProjectKeys ?? fallbackCollapsed;
|
||||
|
||||
return useGroupedSidebarSessionGroups({
|
||||
agentsWithProjects,
|
||||
projects: HARNESS_PROJECTS,
|
||||
previewExpandedProjects,
|
||||
collapsedProjectKeys,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
routerNavigateMock.mockClear();
|
||||
});
|
||||
|
||||
describe("sidebar session render boundaries", () => {
|
||||
it("changes the row slice when lastActivityAt changes", () => {
|
||||
const beforeAgent = makeAgent();
|
||||
seedAgent(beforeAgent);
|
||||
|
||||
const beforeSlice = selectSidebarSessionSlice(
|
||||
useSessionStore.getState(),
|
||||
"server-1",
|
||||
"agent-1",
|
||||
);
|
||||
|
||||
const afterAgent = makeAgent({
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
});
|
||||
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", afterAgent]]));
|
||||
|
||||
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
|
||||
expect(shallow(beforeSlice, afterSlice)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not change the row slice for fields the row does not consume", () => {
|
||||
seedAgent(makeAgent());
|
||||
|
||||
const beforeSlice = selectSidebarSessionSlice(
|
||||
useSessionStore.getState(),
|
||||
"server-1",
|
||||
"agent-1",
|
||||
);
|
||||
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
[
|
||||
"agent-1",
|
||||
makeAgent({
|
||||
status: "idle",
|
||||
createdAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
requiresAttention: true,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const afterSlice = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
|
||||
expect(shallow(beforeSlice, afterSlice)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps grouped section data stable when activity changes without reordering ids", () => {
|
||||
seedAgents([makeAgent({ id: "agent-1" }), makeAgent({ id: "agent-2", cwd: "/repo/main-2" })]);
|
||||
|
||||
const { result } = renderHook(() => useGroupedBoundaryHarness());
|
||||
const beforeGroups = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setAgents(
|
||||
SERVER_ID,
|
||||
new Map([
|
||||
[
|
||||
"agent-1",
|
||||
makeAgent({
|
||||
id: "agent-1",
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
["agent-2", makeAgent({ id: "agent-2", cwd: "/repo/main-2" })],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(beforeGroups);
|
||||
});
|
||||
|
||||
it("changes grouped section data when cwd resolves to a different project", () => {
|
||||
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
|
||||
|
||||
const { result } = renderHook(() => useGroupedBoundaryHarness());
|
||||
const beforeGroups = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents(SERVER_ID, new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/b" })]]));
|
||||
});
|
||||
|
||||
expect(result.current).not.toBe(beforeGroups);
|
||||
expect(result.current[0]).toMatchObject({
|
||||
projectKey: "project-b",
|
||||
visibleIds: ["agent-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps grouped section data stable when cwd changes but resolves to the same project", () => {
|
||||
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
|
||||
|
||||
const { result } = renderHook(() => useGroupedBoundaryHarness());
|
||||
const beforeGroups = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents(
|
||||
SERVER_ID,
|
||||
new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/a-worktree" })]]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(beforeGroups);
|
||||
});
|
||||
|
||||
it("emits a collapsed group with no visible ids and zero hidden count", () => {
|
||||
const overLimit = Array.from({ length: 10 }, (_, index) =>
|
||||
makeAgent({ id: `agent-${index + 1}`, cwd: "/repo/a" }),
|
||||
);
|
||||
seedAgents(overLimit);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGroupedBoundaryHarness({ collapsedProjectKeys: new Set(["project-a"]) }),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual([
|
||||
expect.objectContaining({
|
||||
projectKey: "project-a",
|
||||
isCollapsed: true,
|
||||
visibleIds: [],
|
||||
hiddenCount: 0,
|
||||
totalCount: 10,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("calls the grouped footer press handler with its project key", () => {
|
||||
const onPress = vi.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionGroupFooter
|
||||
projectKey="project-a"
|
||||
hiddenCount={2}
|
||||
isExpanded={false}
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId("sidebar-session-group-footer-project-a"));
|
||||
|
||||
expect(onPress).toHaveBeenCalledWith("project-a");
|
||||
});
|
||||
|
||||
it("renders the project kebab and new-session button in the grouped header", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionGroupHeader
|
||||
serverId="server-1"
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
projectIconKey="/repo/a"
|
||||
workspaces={HARNESS_PROJECTS[0].workspaces}
|
||||
isCollapsed={false}
|
||||
onToggleCollapsed={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(getByTestId("sidebar-project-project-a-new-session-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a new session scoped to the grouped project root", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionGroupHeader
|
||||
serverId="server-1"
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
projectIconKey="/repo/a"
|
||||
workspaces={HARNESS_PROJECTS[0].workspaces}
|
||||
isCollapsed={false}
|
||||
onToggleCollapsed={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
|
||||
|
||||
expect(routerNavigateMock).toHaveBeenCalledWith(
|
||||
"/h/server-1/new?dir=%2Frepo%2Fa&title=New+session",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
|
||||
|
||||
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "running",
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
function makeAgent(input: Partial<Agent> = {}): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function seedAgent(agent: Agent) {
|
||||
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents("server-1", new Map([[agent.id, agent]]));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
});
|
||||
|
||||
describe("selectSidebarSessionSlice", () => {
|
||||
it("returns a shallow-equal slice when the agent state is unchanged", () => {
|
||||
seedAgent(makeAgent());
|
||||
|
||||
const first = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
const second = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
|
||||
expect(shallow(first, second)).toBe(true);
|
||||
expect(first).toEqual({
|
||||
title: "Agent",
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
lastActivityAt: TIMESTAMP,
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("changes the row slice when lastActivityAt changes", () => {
|
||||
seedAgent(makeAgent());
|
||||
const before = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map([
|
||||
[
|
||||
"agent-1",
|
||||
makeAgent({
|
||||
lastActivityAt: new Date("2026-05-08T11:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const after = selectSidebarSessionSlice(useSessionStore.getState(), "server-1", "agent-1");
|
||||
|
||||
expect(shallow(before, after)).toBe(false);
|
||||
expect(after?.lastActivityAt).toEqual(new Date("2026-05-08T11:00:00.000Z"));
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
export type SidebarSessionSlice = {
|
||||
title: string | null;
|
||||
cwd: string;
|
||||
provider: Agent["provider"];
|
||||
lastActivityAt: Date;
|
||||
archivedAt: Date | null;
|
||||
} | null;
|
||||
|
||||
export function selectSidebarSessionSlice(
|
||||
state: ReturnType<typeof useSessionStore.getState>,
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): SidebarSessionSlice {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: agent.title ?? null,
|
||||
cwd: agent.cwd,
|
||||
provider: agent.provider,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSidebarSessionWorkspaceLookup,
|
||||
deriveGroupedSidebarSessions,
|
||||
deriveSidebarSessionFilterAvailability,
|
||||
deriveSidebarSessionFilterProjects,
|
||||
shouldIncludeSidebarSessionAgent,
|
||||
} from "./session-filtering";
|
||||
import type { SidebarSessionAgent, SidebarSessionWorkspace } from "./types";
|
||||
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
type ProjectKind = WorkspaceDescriptor["projectKind"];
|
||||
|
||||
const WORKSPACES: SidebarSessionWorkspace[] = [
|
||||
{
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
workspaceKey: "server-1:workspace-1",
|
||||
workspaceName: "Main",
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectIconKey: "/repo/main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
},
|
||||
{
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-2",
|
||||
workspaceKey: "server-1:workspace-2",
|
||||
workspaceName: "Docs",
|
||||
projectKey: "project-b",
|
||||
projectName: "Project B",
|
||||
projectIconKey: "/repo/docs",
|
||||
workspaceDirectory: "/repo/docs",
|
||||
},
|
||||
{
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-3",
|
||||
workspaceKey: "server-1:workspace-3",
|
||||
workspaceName: "API",
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectIconKey: "/repo/main",
|
||||
workspaceDirectory: "/repo/api",
|
||||
},
|
||||
];
|
||||
|
||||
const AGENTS: SidebarSessionAgent[] = [
|
||||
{ id: "main", serverId: "server-1", cwd: "/repo/main/", archivedAt: null },
|
||||
{ id: "docs", serverId: "server-1", cwd: "/repo/docs", archivedAt: null },
|
||||
{ id: "api", serverId: "server-1", cwd: "/repo/api", archivedAt: null },
|
||||
{ id: "unmapped", serverId: "server-1", cwd: "/repo/missing", archivedAt: null },
|
||||
{ id: "other-server", serverId: "server-2", cwd: "/repo/main", archivedAt: null },
|
||||
{
|
||||
id: "archived",
|
||||
serverId: "server-1",
|
||||
cwd: "/repo/main",
|
||||
archivedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
function projectWorkspaceKeys(project: {
|
||||
projectKey: string;
|
||||
workspaces: { workspaceKey: string }[];
|
||||
}) {
|
||||
return {
|
||||
projectKey: project.projectKey,
|
||||
workspaceKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
};
|
||||
}
|
||||
|
||||
function testProject(
|
||||
key: string,
|
||||
options: { name?: string; projectKind?: ProjectKind; workspaceCount?: number } = {},
|
||||
): SidebarProjectEntry {
|
||||
const projectKind: ProjectKind = options.projectKind ?? "git";
|
||||
const workspaceCount = options.workspaceCount ?? (projectKind === "git" ? 2 : 1);
|
||||
return {
|
||||
projectKey: key,
|
||||
projectName: options.name ?? key,
|
||||
projectKind,
|
||||
iconWorkingDir: `/repo/${key}`,
|
||||
workspaces: Array.from({ length: workspaceCount }, (_, index) => ({
|
||||
workspaceKey: `server-1:${key}-${index}`,
|
||||
serverId: "server-1",
|
||||
workspaceId: `${key}-${index}`,
|
||||
projectKey: key,
|
||||
projectRootPath: `/repo/${key}`,
|
||||
workspaceDirectory: `/repo/${key}/${index}`,
|
||||
projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: `${key}-${index}`,
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function agentProject(id: string, project: SidebarProjectEntry) {
|
||||
return { id, projectKey: project.projectKey };
|
||||
}
|
||||
|
||||
function agentProjects(ids: readonly string[], project: SidebarProjectEntry) {
|
||||
return ids.map((id) => agentProject(id, project));
|
||||
}
|
||||
|
||||
describe("sidebar session filtering", () => {
|
||||
it("hides unmapped agents and includes every mapped active agent for All", () => {
|
||||
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
|
||||
|
||||
const visibleIds = AGENTS.filter((agent) =>
|
||||
shouldIncludeSidebarSessionAgent({
|
||||
agent,
|
||||
filter: { type: "all" },
|
||||
lookup,
|
||||
}),
|
||||
).map((agent) => agent.id);
|
||||
|
||||
expect(visibleIds).toEqual(["main", "docs", "api"]);
|
||||
});
|
||||
|
||||
it("filters by project", () => {
|
||||
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
|
||||
|
||||
const visibleIds = AGENTS.filter((agent) =>
|
||||
shouldIncludeSidebarSessionAgent({
|
||||
agent,
|
||||
filter: { type: "project", projectKey: "project-a" },
|
||||
lookup,
|
||||
}),
|
||||
).map((agent) => agent.id);
|
||||
|
||||
expect(visibleIds).toEqual(["main", "api"]);
|
||||
});
|
||||
|
||||
it("derives filter options only from mapped active agents", () => {
|
||||
const lookup = createSidebarSessionWorkspaceLookup(WORKSPACES);
|
||||
const availability = deriveSidebarSessionFilterAvailability({
|
||||
agents: [
|
||||
AGENTS[0],
|
||||
AGENTS[3],
|
||||
{
|
||||
id: "archived-docs",
|
||||
serverId: "server-1",
|
||||
cwd: "/repo/docs",
|
||||
archivedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
lookup,
|
||||
});
|
||||
|
||||
expect(availability).toEqual({
|
||||
projectKeys: ["project-a"],
|
||||
});
|
||||
const filterProjects = deriveSidebarSessionFilterProjects({
|
||||
projects: [
|
||||
{
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo",
|
||||
workspaces: [
|
||||
{
|
||||
workspaceKey: "server-1:workspace-1",
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectKey: "project-a",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "Main",
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
},
|
||||
{
|
||||
workspaceKey: "server-1:workspace-3",
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-3",
|
||||
projectKey: "project-a",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/api",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "API",
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
projectKey: "project-b",
|
||||
projectName: "Project B",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/docs",
|
||||
workspaces: [
|
||||
{
|
||||
workspaceKey: "server-1:workspace-2",
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-2",
|
||||
projectKey: "project-b",
|
||||
projectRootPath: "/repo/docs",
|
||||
workspaceDirectory: "/repo/docs",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "Docs",
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
availability,
|
||||
});
|
||||
|
||||
expect(filterProjects.map(projectWorkspaceKeys)).toEqual([
|
||||
{
|
||||
projectKey: "project-a",
|
||||
workspaceKeys: ["server-1:workspace-1", "server-1:workspace-3"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveGroupedSidebarSessions", () => {
|
||||
it("returns no groups for empty input", () => {
|
||||
expect(
|
||||
deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: [],
|
||||
projects: [],
|
||||
previewExpandedProjects: new Set(),
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a single project under the limit fully visible and collapsed", () => {
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
expect(groups).toEqual([
|
||||
{
|
||||
projectKey: "project-a",
|
||||
projectName: "project-a",
|
||||
projectIconKey: "/repo/project-a",
|
||||
visibleIds: ["a1", "a2", "a3"],
|
||||
hiddenCount: 0,
|
||||
isExpanded: false,
|
||||
isCollapsed: false,
|
||||
totalCount: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps a collapsed project over the limit", () => {
|
||||
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
visibleIds: ["a1", "a2", "a3", "a4", "a5", "a6"],
|
||||
hiddenCount: 4,
|
||||
isExpanded: false,
|
||||
isCollapsed: false,
|
||||
totalCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows every id for an expanded project over the limit", () => {
|
||||
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(["project-a"]),
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
visibleIds: orderedIds,
|
||||
hiddenCount: 0,
|
||||
isExpanded: true,
|
||||
isCollapsed: false,
|
||||
totalCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides all rows when a project is collapsed", () => {
|
||||
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
collapsedProjectKeys: new Set(["project-a"]),
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
visibleIds: [],
|
||||
hiddenCount: 0,
|
||||
isExpanded: false,
|
||||
isCollapsed: true,
|
||||
totalCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("collapsed wins over preview-expanded", () => {
|
||||
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(["project-a"]),
|
||||
collapsedProjectKeys: new Set(["project-a"]),
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
visibleIds: [],
|
||||
isExpanded: false,
|
||||
isCollapsed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("orders projects by the canonical project list, ignoring agent first-occurrence", () => {
|
||||
const projectA = testProject("project-a");
|
||||
const projectB = testProject("project-b");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: [
|
||||
agentProject("b1", projectB),
|
||||
agentProject("a1", projectA),
|
||||
agentProject("b2", projectB),
|
||||
agentProject("a2", projectA),
|
||||
],
|
||||
projects: [projectA, projectB],
|
||||
previewExpandedProjects: new Set(),
|
||||
});
|
||||
|
||||
expect(groups.map((group) => ({ key: group.projectKey, ids: group.visibleIds }))).toEqual([
|
||||
{ key: "project-a", ids: ["a1", "a2"] },
|
||||
{ key: "project-b", ids: ["b1", "b2"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves order within each project", () => {
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
});
|
||||
|
||||
expect(groups[0]?.visibleIds).toEqual(["a1", "a2", "a3"]);
|
||||
});
|
||||
|
||||
it("skips projects that have no agents", () => {
|
||||
const projectA = testProject("project-a");
|
||||
const projectB = testProject("project-b");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
projects: [projectA, projectB],
|
||||
previewExpandedProjects: new Set(),
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.projectKey)).toEqual(["project-a"]);
|
||||
});
|
||||
|
||||
it("keeps a single-workspace non-git project grouped and collapsible", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
collapsedProjectKeys: new Set(["project-a"]),
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
projectKey: "project-a",
|
||||
isCollapsed: true,
|
||||
visibleIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a single-workspace non-git project group when expanded", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
projectKey: "project-a",
|
||||
projectName: "project-a",
|
||||
isCollapsed: false,
|
||||
visibleIds: ["a1", "a2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders multi-workspace non-git project groups too", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 2 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
collapsedProjectKeys: new Set(["project-a"]),
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
isCollapsed: true,
|
||||
visibleIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps single-workspace git projects collapsible", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "git", workspaceCount: 1 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
collapsedProjectKeys: new Set(["project-a"]),
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
isCollapsed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the collapsed limit to 6", () => {
|
||||
const orderedIds = Array.from({ length: 7 }, (_, index) => `a${index + 1}`);
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(orderedIds, projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
});
|
||||
|
||||
expect(groups[0]?.visibleIds).toHaveLength(6);
|
||||
expect(groups[0]?.hiddenCount).toBe(1);
|
||||
});
|
||||
|
||||
it("honors a custom collapsed limit", () => {
|
||||
const projectA = testProject("project-a");
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2", "a3", "a4"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
visibleIds: ["a1", "a2"],
|
||||
hiddenCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { SidebarSessionAgent, SidebarSessionFilter, SidebarSessionWorkspace } from "./types";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export interface SidebarSessionWorkspaceLookup {
|
||||
workspaceByExecutionKey: Map<string, SidebarSessionWorkspace>;
|
||||
}
|
||||
|
||||
export interface SidebarSessionFilterAvailability {
|
||||
projectKeys: readonly string[];
|
||||
}
|
||||
|
||||
export interface SidebarSessionGroup {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectIconKey: string | null;
|
||||
visibleIds: readonly string[];
|
||||
hiddenCount: number;
|
||||
isExpanded: boolean;
|
||||
isCollapsed: boolean;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface SidebarSessionAgentProject {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
}
|
||||
|
||||
const DEFAULT_GROUPED_SESSION_LIMIT = 6;
|
||||
|
||||
function executionKey(serverId: string, cwd: string): string {
|
||||
return `${serverId}:${cwd}`;
|
||||
}
|
||||
|
||||
export function createSidebarSessionWorkspaceLookup(
|
||||
workspaces: readonly SidebarSessionWorkspace[],
|
||||
): SidebarSessionWorkspaceLookup {
|
||||
const workspaceByExecutionKey = new Map<string, SidebarSessionWorkspace>();
|
||||
for (const workspace of workspaces) {
|
||||
const normalizedDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
|
||||
if (!normalizedDirectory) {
|
||||
continue;
|
||||
}
|
||||
workspaceByExecutionKey.set(executionKey(workspace.serverId, normalizedDirectory), workspace);
|
||||
}
|
||||
return { workspaceByExecutionKey };
|
||||
}
|
||||
|
||||
export function resolveSidebarSessionWorkspace(
|
||||
lookup: SidebarSessionWorkspaceLookup,
|
||||
agent: SidebarSessionAgent,
|
||||
): SidebarSessionWorkspace | null {
|
||||
const normalizedCwd = normalizeWorkspacePath(agent.cwd);
|
||||
if (!normalizedCwd) {
|
||||
return null;
|
||||
}
|
||||
return lookup.workspaceByExecutionKey.get(executionKey(agent.serverId, normalizedCwd)) ?? null;
|
||||
}
|
||||
|
||||
export function resolveSidebarSessionWorkspaceId(input: {
|
||||
agent: SidebarSessionAgent;
|
||||
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedCwd = normalizeWorkspacePath(input.agent.cwd);
|
||||
if (!normalizedCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const workspace of input.workspaces ?? []) {
|
||||
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedCwd) {
|
||||
return workspace.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldIncludeSidebarSessionAgent(input: {
|
||||
agent: SidebarSessionAgent;
|
||||
filter: SidebarSessionFilter;
|
||||
lookup: SidebarSessionWorkspaceLookup;
|
||||
}): boolean {
|
||||
if (input.agent.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspace = resolveSidebarSessionWorkspace(input.lookup, input.agent);
|
||||
if (!workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.filter.type === "all") {
|
||||
return true;
|
||||
}
|
||||
return workspace.projectKey === input.filter.projectKey;
|
||||
}
|
||||
|
||||
export function deriveSidebarSessionFilterAvailability(input: {
|
||||
agents: readonly SidebarSessionAgent[];
|
||||
lookup: SidebarSessionWorkspaceLookup;
|
||||
}): SidebarSessionFilterAvailability {
|
||||
const projectKeys = new Set<string>();
|
||||
|
||||
for (const agent of input.agents) {
|
||||
if (agent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
const workspace = resolveSidebarSessionWorkspace(input.lookup, agent);
|
||||
if (!workspace) {
|
||||
continue;
|
||||
}
|
||||
projectKeys.add(workspace.projectKey);
|
||||
}
|
||||
|
||||
return {
|
||||
projectKeys: Array.from(projectKeys).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveSidebarSessionFilterProjects(input: {
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
availability: SidebarSessionFilterAvailability;
|
||||
}): SidebarProjectEntry[] {
|
||||
if (input.projects.length === 0 || input.availability.projectKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visibleProjectKeys = new Set(input.availability.projectKeys);
|
||||
const projects: SidebarProjectEntry[] = [];
|
||||
|
||||
for (const project of input.projects) {
|
||||
if (!visibleProjectKeys.has(project.projectKey)) {
|
||||
continue;
|
||||
}
|
||||
projects.push(project);
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
export function deriveGroupedSidebarSessions(input: {
|
||||
agentsWithProjects: readonly SidebarSessionAgentProject[];
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
previewExpandedProjects?: ReadonlySet<string>;
|
||||
collapsedProjectKeys?: ReadonlySet<string>;
|
||||
limit?: number;
|
||||
}): readonly SidebarSessionGroup[] {
|
||||
const limit = input.limit ?? DEFAULT_GROUPED_SESSION_LIMIT;
|
||||
const previewExpandedProjects = input.previewExpandedProjects ?? EMPTY_KEY_SET;
|
||||
const collapsedProjectKeys = input.collapsedProjectKeys ?? EMPTY_KEY_SET;
|
||||
|
||||
const idsByProject = new Map<string, string[]>();
|
||||
for (const agent of input.agentsWithProjects) {
|
||||
let ids = idsByProject.get(agent.projectKey);
|
||||
if (!ids) {
|
||||
ids = [];
|
||||
idsByProject.set(agent.projectKey, ids);
|
||||
}
|
||||
ids.push(agent.id);
|
||||
}
|
||||
|
||||
const groups: SidebarSessionGroup[] = [];
|
||||
for (const project of input.projects) {
|
||||
const ids = idsByProject.get(project.projectKey);
|
||||
if (!ids || ids.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const isCollapsed = collapsedProjectKeys.has(project.projectKey);
|
||||
const isExpanded = !isCollapsed && previewExpandedProjects.has(project.projectKey);
|
||||
groups.push({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectIconKey: project.iconWorkingDir || null,
|
||||
visibleIds: visibleIdsFor({ ids, isCollapsed, isExpanded, limit }),
|
||||
hiddenCount: hiddenCountFor({ totalCount: ids.length, isCollapsed, isExpanded, limit }),
|
||||
isExpanded,
|
||||
isCollapsed,
|
||||
totalCount: ids.length,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function visibleIdsFor(input: {
|
||||
ids: readonly string[];
|
||||
isCollapsed: boolean;
|
||||
isExpanded: boolean;
|
||||
limit: number;
|
||||
}): readonly string[] {
|
||||
if (input.isCollapsed) {
|
||||
return [];
|
||||
}
|
||||
if (input.isExpanded) {
|
||||
return input.ids;
|
||||
}
|
||||
return input.ids.slice(0, input.limit);
|
||||
}
|
||||
|
||||
function hiddenCountFor(input: {
|
||||
totalCount: number;
|
||||
isCollapsed: boolean;
|
||||
isExpanded: boolean;
|
||||
limit: number;
|
||||
}): number {
|
||||
if (input.isCollapsed || input.isExpanded) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, input.totalCount - input.limit);
|
||||
}
|
||||
|
||||
const EMPTY_KEY_SET: ReadonlySet<string> = new Set();
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
});
|
||||
|
||||
const { archiveAgentMock, platformState } = vi.hoisted(() => ({
|
||||
archiveAgentMock: vi.fn(),
|
||||
platformState: { isNative: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-archive-agent", () => ({
|
||||
useArchiveAgent: () => ({
|
||||
archiveAgent: archiveAgentMock,
|
||||
isArchivingAgent: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
get isNative() {
|
||||
return platformState.isNative;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID} onPress={onSelect}>
|
||||
<Text>{children}</Text>
|
||||
</Pressable>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID}>
|
||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||
</Pressable>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) =>
|
||||
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
|
||||
return React.createElement("span", { ...props, "data-icon": name });
|
||||
};
|
||||
return {
|
||||
Archive: createIcon("Archive"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) =>
|
||||
typeof styles === "function"
|
||||
? styles({
|
||||
colors: {
|
||||
foreground: "#111111",
|
||||
foregroundMuted: "#666666",
|
||||
surface2: "#eeeeee",
|
||||
},
|
||||
fontSize: { xs: 12 },
|
||||
})
|
||||
: styles,
|
||||
},
|
||||
withUnistyles: (Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
function ThemedComponent(props: Record<string, unknown>) {
|
||||
return <Component {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
beforeEach(() => {
|
||||
archiveAgentMock.mockReset();
|
||||
archiveAgentMock.mockResolvedValue(undefined);
|
||||
platformState.isNative = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SidebarSessionRowKebabMenu", () => {
|
||||
it("renders when hovered and hides on non-touch idle rows", () => {
|
||||
const hidden = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
|
||||
);
|
||||
expect(hidden.queryByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeNull();
|
||||
hidden.unmount();
|
||||
|
||||
const visible = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||
);
|
||||
expect(visible.getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders on touch platforms without hover", () => {
|
||||
platformState.isNative = true;
|
||||
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("archives the agent from the archive item", () => {
|
||||
const { getAllByTestId, getByTestId, queryByTestId } = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||
);
|
||||
|
||||
expect(getAllByTestId(/^sidebar-session-menu-server-1-agent-1-/)).toHaveLength(1);
|
||||
expect(queryByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`)).toBeNull();
|
||||
|
||||
fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-archive`));
|
||||
|
||||
expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { memo, useCallback, type ReactElement } from "react";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { Archive, MoreVertical } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { isNative as isTouchPlatform } from "@/constants/platform";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export interface SidebarSessionRowKebabMenuProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function kebabButtonStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMenu({
|
||||
serverId,
|
||||
agentId,
|
||||
isHovered,
|
||||
}: SidebarSessionRowKebabMenuProps): ReactElement | null {
|
||||
const visible = isHovered || isTouchPlatform;
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
const handleArchiveAgent = useCallback(() => {
|
||||
void archiveAgent({ serverId, agentId }).catch(() => {});
|
||||
}, [agentId, archiveAgent, serverId]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Session actions"
|
||||
testID={`sidebar-session-kebab-${serverId}-${agentId}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-session-menu-${serverId}-${agentId}-archive`}
|
||||
leading={archiveLeadingIcon}
|
||||
onSelect={handleArchiveAgent}
|
||||
>
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -1,343 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { SidebarSessionsView } from "./view";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
});
|
||||
|
||||
const {
|
||||
aggregatedAgentsState,
|
||||
navigateToPreparedWorkspaceTabMock,
|
||||
prepareWorkspaceTabMock,
|
||||
testTheme,
|
||||
} = vi.hoisted(() => ({
|
||||
aggregatedAgentsState: {
|
||||
value: {
|
||||
agents: [] as unknown[],
|
||||
isInitialLoad: false,
|
||||
},
|
||||
},
|
||||
navigateToPreparedWorkspaceTabMock: vi.fn(),
|
||||
prepareWorkspaceTabMock: vi.fn(),
|
||||
testTheme: {
|
||||
borderRadius: { full: 999, lg: 8 },
|
||||
colors: {
|
||||
foreground: "#111111",
|
||||
foregroundMuted: "#666666",
|
||||
palette: {
|
||||
amber: { 500: "#f59e0b", 700: "#b45309" },
|
||||
blue: { 500: "#3b82f6" },
|
||||
green: { 500: "#22c55e" },
|
||||
red: { 500: "#ef4444" },
|
||||
},
|
||||
surface0: "#ffffff",
|
||||
surface2: "#eeeeee",
|
||||
surfaceSidebarHover: "#f5f5f5",
|
||||
},
|
||||
colorScheme: "light",
|
||||
fontSize: { sm: 14, xs: 12 },
|
||||
fontWeight: { medium: "500" },
|
||||
iconSize: { md: 20, sm: 16 },
|
||||
shadow: { md: {} },
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-aggregated-agents", () => ({
|
||||
useAggregatedAgentIds: (options?: { filter?: (agent: AggregatedAgent) => boolean }) =>
|
||||
aggregatedAgentsState.value.agents
|
||||
.filter((agent) => !options?.filter || options.filter(agent as AggregatedAgent))
|
||||
.map((agent) => (agent as AggregatedAgent).id),
|
||||
useAggregatedAgentsInitialLoad: () => aggregatedAgentsState.value.isInitialLoad,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", async () => {
|
||||
const ReactModule = await import("react");
|
||||
return {
|
||||
getProviderIcon: () =>
|
||||
function ProviderIcon() {
|
||||
return ReactModule.createElement("span", { "data-testid": "provider-icon" });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./session-row-actions", () => ({
|
||||
SidebarSessionRowKebabMenu: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/sidebar-project-trailing-actions", () => ({
|
||||
SidebarProjectTrailingActions: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) =>
|
||||
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
|
||||
return React.createElement("span", { ...props, "data-icon": name });
|
||||
};
|
||||
return {
|
||||
Archive: createIcon("Archive"),
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
FolderPlus: createIcon("FolderPlus"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
Settings: createIcon("Settings"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
X: createIcon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/screens/workspace/workspace-tab-presentation", async () => {
|
||||
const ReactModule = await import("react");
|
||||
return {
|
||||
WorkspaceTabPresentationResolver: ({ children }: { children: (presentation: {}) => unknown }) =>
|
||||
children({}),
|
||||
WorkspaceTabIcon: () =>
|
||||
ReactModule.createElement("span", { "data-testid": "workspace-tab-icon" }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-draggable-flatlist", async () => {
|
||||
const ReactModule = await import("react");
|
||||
return {
|
||||
NestableScrollContainer: ({ children }: { children?: React.ReactNode }) =>
|
||||
ReactModule.createElement("div", null, children),
|
||||
default: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
default: {
|
||||
View: "div",
|
||||
},
|
||||
Easing: {
|
||||
linear: vi.fn(),
|
||||
},
|
||||
makeMutable: (value: unknown) => ({ value }),
|
||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||
withRepeat: (value: unknown) => value,
|
||||
withTiming: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: (theme: unknown) => unknown) => styles(testTheme),
|
||||
},
|
||||
withUnistyles: (Component: unknown) => Component,
|
||||
useUnistyles: () => ({
|
||||
theme: testTheme,
|
||||
rt: {},
|
||||
breakpoint: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/workspace-navigation", () => ({
|
||||
navigateToPreparedWorkspaceTab: navigateToPreparedWorkspaceTabMock,
|
||||
prepareWorkspaceTab: prepareWorkspaceTabMock,
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn().mockResolvedValue(null),
|
||||
setItem: vi.fn().mockResolvedValue(undefined),
|
||||
removeItem: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
const CWD = "/repo/project/workspace";
|
||||
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||
const ALL_SESSIONS_FILTER = { type: "all" } as const;
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
provider: "codex",
|
||||
status: "running",
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Navigation agent",
|
||||
cwd: CWD,
|
||||
model: null,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
function makeAgent(input: Partial<Agent> = {}): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function toAggregatedAgent(agent: Agent): AggregatedAgent {
|
||||
return {
|
||||
...agent,
|
||||
serverLabel: "Server 1",
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo/project",
|
||||
workspaceDirectory: CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "workspace",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function projects(): SidebarProjectEntry[] {
|
||||
return [
|
||||
{
|
||||
projectKey: "project-1",
|
||||
projectName: "Project",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo/project",
|
||||
workspaces: [
|
||||
{
|
||||
workspaceKey: `${SERVER_ID}:${WORKSPACE_ID}`,
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
projectKey: "project-1",
|
||||
projectRootPath: "/repo/project",
|
||||
workspaceDirectory: CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "workspace",
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function seedState(input: { agent: Agent; workspaces?: WorkspaceDescriptor[] }) {
|
||||
const agent = input.agent;
|
||||
aggregatedAgentsState.value = {
|
||||
agents: [toAggregatedAgent(agent)],
|
||||
isInitialLoad: false,
|
||||
};
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(
|
||||
SERVER_ID,
|
||||
new Map((input.workspaces ?? [workspace()]).map((entry) => [entry.id, entry])),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prepareWorkspaceTabMock.mockReset();
|
||||
navigateToPreparedWorkspaceTabMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
});
|
||||
|
||||
describe("SidebarSessionsView row navigation", () => {
|
||||
it("prepares and navigates to the agent tab in the resolved workspace", () => {
|
||||
seedState({ agent: makeAgent() });
|
||||
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionsView
|
||||
serverId={SERVER_ID}
|
||||
projects={projects()}
|
||||
filter={ALL_SESSIONS_FILTER}
|
||||
groupByProject={false}
|
||||
previewExpandedProjects={new Set()}
|
||||
collapsedProjectKeys={new Set()}
|
||||
onProjectPreviewExpandedToggle={vi.fn()}
|
||||
onProjectCollapsedToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId(`sidebar-session-row-${SERVER_ID}-${AGENT_ID}`));
|
||||
|
||||
const navigationInput = {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
};
|
||||
expect(prepareWorkspaceTabMock).toHaveBeenCalledTimes(1);
|
||||
expect(prepareWorkspaceTabMock).toHaveBeenCalledWith(navigationInput);
|
||||
expect(navigateToPreparedWorkspaceTabMock).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToPreparedWorkspaceTabMock).toHaveBeenCalledWith(navigationInput);
|
||||
expect(prepareWorkspaceTabMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
navigateToPreparedWorkspaceTabMock.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when the row agent no longer resolves to a workspace", () => {
|
||||
seedState({ agent: makeAgent() });
|
||||
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionsView
|
||||
serverId={SERVER_ID}
|
||||
projects={projects()}
|
||||
filter={ALL_SESSIONS_FILTER}
|
||||
groupByProject={false}
|
||||
previewExpandedProjects={new Set()}
|
||||
collapsedProjectKeys={new Set()}
|
||||
onProjectPreviewExpandedToggle={vi.fn()}
|
||||
onProjectCollapsedToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
||||
|
||||
fireEvent.click(getByTestId(`sidebar-session-row-${SERVER_ID}-${AGENT_ID}`));
|
||||
|
||||
expect(prepareWorkspaceTabMock).not.toHaveBeenCalled();
|
||||
expect(navigateToPreparedWorkspaceTabMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,427 +0,0 @@
|
||||
import {
|
||||
Folder,
|
||||
Folders,
|
||||
LayoutList,
|
||||
ListFilter,
|
||||
MessagesSquare,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { memo, useCallback, useMemo, type ReactElement } from "react";
|
||||
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
|
||||
import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces";
|
||||
|
||||
interface SidebarSessionsToggleProps {
|
||||
serverId: string | null;
|
||||
mode: SidebarSessionViewMode;
|
||||
filter: SidebarSessionFilter;
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
groupByProject: boolean;
|
||||
onModeChange: (mode: SidebarSessionViewMode) => void;
|
||||
onFilterChange: (filter: SidebarSessionFilter) => void;
|
||||
onGroupByProjectChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const PILL_TRANSITION = LinearTransition.duration(160);
|
||||
const LABEL_FADE = { in: FadeIn.duration(120), out: FadeOut.duration(80) };
|
||||
|
||||
export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({
|
||||
serverId,
|
||||
mode,
|
||||
filter,
|
||||
projects,
|
||||
groupByProject,
|
||||
onModeChange,
|
||||
onFilterChange,
|
||||
onGroupByProjectChange,
|
||||
}: SidebarSessionsToggleProps): ReactElement {
|
||||
const sessionsActive = mode === "sessions";
|
||||
|
||||
const handleWorkspacesPress = useCallback(() => {
|
||||
onModeChange("workspaces");
|
||||
}, [onModeChange]);
|
||||
const handleSessionsPress = useCallback(() => {
|
||||
onModeChange("sessions");
|
||||
}, [onModeChange]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.pillGroup}>
|
||||
<TogglePill
|
||||
label="Workspaces"
|
||||
icon={Folders}
|
||||
active={!sessionsActive}
|
||||
onPress={handleWorkspacesPress}
|
||||
testID="sidebar-toggle-workspaces"
|
||||
/>
|
||||
<TogglePill
|
||||
label="Sessions"
|
||||
icon={MessagesSquare}
|
||||
active={sessionsActive}
|
||||
onPress={handleSessionsPress}
|
||||
testID="sidebar-toggle-sessions"
|
||||
/>
|
||||
</View>
|
||||
{sessionsActive ? (
|
||||
<SidebarSessionsFilterMenu
|
||||
serverId={serverId}
|
||||
filter={filter}
|
||||
projects={projects}
|
||||
groupByProject={groupByProject}
|
||||
onFilterChange={onFilterChange}
|
||||
onGroupByProjectChange={onGroupByProjectChange}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function SidebarSessionsFilterMenu({
|
||||
serverId,
|
||||
filter,
|
||||
projects,
|
||||
groupByProject,
|
||||
onFilterChange,
|
||||
onGroupByProjectChange,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
filter: SidebarSessionFilter;
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
groupByProject: boolean;
|
||||
onFilterChange: (filter: SidebarSessionFilter) => void;
|
||||
onGroupByProjectChange: (next: boolean) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const filterActive = filter.type !== "all";
|
||||
const visibleFilterProjects = useVisibleSidebarSessionFilterProjects({ serverId, projects });
|
||||
const newSessionIntent = useMemo(
|
||||
() => ({ kind: "new-workspace", headerTitle: "New session" }) as const,
|
||||
[],
|
||||
);
|
||||
const openNewSessionPicker = useOpenProjectPicker(serverId, newSessionIntent);
|
||||
|
||||
const handleAllSelect = useCallback(() => {
|
||||
onFilterChange({ type: "all" });
|
||||
}, [onFilterChange]);
|
||||
|
||||
const handleNewSessionPress = useCallback(() => {
|
||||
void openNewSessionPicker();
|
||||
}, [openNewSessionPicker]);
|
||||
|
||||
const newSessionButtonStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterButton,
|
||||
(hovered || pressed) && styles.filterButtonHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ hovered = false, open = false }: PressableStateCallbackType & { open?: boolean }) => [
|
||||
styles.filterButton,
|
||||
(hovered || open || filterActive) && styles.filterButtonHovered,
|
||||
],
|
||||
[filterActive],
|
||||
);
|
||||
|
||||
const filterIcon = useMemo(
|
||||
() => (
|
||||
<ListFilter
|
||||
size={theme.iconSize.sm}
|
||||
color={filterActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
),
|
||||
[filterActive, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
);
|
||||
const handleGroupByProjectPress = useCallback(() => {
|
||||
onGroupByProjectChange(!groupByProject);
|
||||
}, [groupByProject, onGroupByProjectChange]);
|
||||
const groupByProjectStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterButton,
|
||||
(hovered || groupByProject) && styles.filterButtonHovered,
|
||||
],
|
||||
[groupByProject],
|
||||
);
|
||||
const groupByProjectIcon = useMemo(
|
||||
() => (
|
||||
<LayoutList
|
||||
size={theme.iconSize.sm}
|
||||
color={groupByProject ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
),
|
||||
[groupByProject, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.sessionActions}>
|
||||
<Pressable
|
||||
accessibilityLabel="New session"
|
||||
accessibilityRole="button"
|
||||
onPress={handleNewSessionPress}
|
||||
style={newSessionButtonStyle}
|
||||
testID="sidebar-new-session-button"
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.sm}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel="Filter sessions"
|
||||
accessibilityRole="button"
|
||||
style={filterTriggerStyle}
|
||||
testID="sidebar-sessions-filter-trigger"
|
||||
>
|
||||
{filterIcon}
|
||||
{filterActive ? <View style={styles.filterBadge} /> : null}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260} scrollable maxHeight={420}>
|
||||
<DropdownMenuItem selected={filter.type === "all"} onSelect={handleAllSelect}>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
{visibleFilterProjects.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<FilterSectionHeading>Project</FilterSectionHeading>
|
||||
{visibleFilterProjects.map((project) => (
|
||||
<ProjectFilterItem
|
||||
key={project.projectKey}
|
||||
selected={filter.type === "project" && filter.projectKey === project.projectKey}
|
||||
label={project.projectName}
|
||||
projectKey={project.projectKey}
|
||||
serverId={serverId}
|
||||
iconWorkingDir={project.iconWorkingDir}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Pressable
|
||||
accessibilityLabel="Group by project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleGroupByProjectPress}
|
||||
style={groupByProjectStyle}
|
||||
testID="sidebar-toggle-group-by-project"
|
||||
>
|
||||
{groupByProjectIcon}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TogglePill({
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const pillStyleActive = useMemo(() => [styles.pill, styles.pillActive], []);
|
||||
const pillStyleHovered = useMemo(() => [styles.pill, styles.pillHovered], []);
|
||||
const renderInner = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
let pillStyle;
|
||||
if (active) pillStyle = pillStyleActive;
|
||||
else if (hovered) pillStyle = pillStyleHovered;
|
||||
else pillStyle = styles.pill;
|
||||
return (
|
||||
<Animated.View layout={PILL_TRANSITION} style={pillStyle}>
|
||||
<Icon size={theme.iconSize.sm} color={iconColor} />
|
||||
{active ? (
|
||||
<Animated.Text
|
||||
entering={LABEL_FADE.in}
|
||||
exiting={LABEL_FADE.out}
|
||||
style={styles.pillTextActive}
|
||||
>
|
||||
{label}
|
||||
</Animated.Text>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
);
|
||||
},
|
||||
[Icon, active, iconColor, label, pillStyleActive, pillStyleHovered, theme.iconSize.sm],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
{renderInner}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSectionHeading({ children }: { children: string }) {
|
||||
return (
|
||||
<View style={styles.filterSectionHeading}>
|
||||
<Text style={styles.filterSectionHeadingText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectFilterItem({
|
||||
selected,
|
||||
label,
|
||||
projectKey,
|
||||
serverId,
|
||||
iconWorkingDir,
|
||||
onFilterChange,
|
||||
}: {
|
||||
selected: boolean;
|
||||
label: string;
|
||||
projectKey: string;
|
||||
serverId: string | null;
|
||||
iconWorkingDir: string;
|
||||
onFilterChange: (filter: SidebarSessionFilter) => void;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onFilterChange({ type: "project", projectKey });
|
||||
}, [onFilterChange, projectKey]);
|
||||
|
||||
const leading = useMemo(
|
||||
() => <ProjectIconLeading serverId={serverId} cwd={iconWorkingDir} />,
|
||||
[serverId, iconWorkingDir],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} leading={leading} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectIconLeading({ serverId, cwd }: { serverId: string | null; cwd: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd });
|
||||
const dataUri = useMemo(() => {
|
||||
if (!icon || !icon.mimeType || !icon.data) {
|
||||
return null;
|
||||
}
|
||||
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||
}, [icon]);
|
||||
const imageSource = useMemo(() => (dataUri ? { uri: dataUri } : null), [dataUri]);
|
||||
|
||||
return (
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
|
||||
{imageSource ? (
|
||||
<Image source={imageSource} style={styles.projectIconImage} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Folder size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
pillGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
},
|
||||
sessionActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
pill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
pillActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
paddingRight: theme.spacing[3],
|
||||
},
|
||||
pillHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
pillTextActive: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
filterButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
position: "relative",
|
||||
},
|
||||
filterButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
filterBadge: {
|
||||
position: "absolute",
|
||||
top: 5,
|
||||
right: 5,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foreground,
|
||||
},
|
||||
filterSectionHeading: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
filterSectionHeadingText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
projectIconImage: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectIconFallback: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
|
||||
export type SidebarSessionViewMode = "workspaces" | "sessions";
|
||||
|
||||
export type SidebarSessionFilter = { type: "all" } | { type: "project"; projectKey: string };
|
||||
|
||||
export interface SidebarSessionWorkspace {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceKey: string;
|
||||
workspaceName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectIconKey: string | null;
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
export type ResolvedSidebarSessionProject = Pick<
|
||||
SidebarSessionWorkspace,
|
||||
"projectKey" | "projectName" | "projectIconKey"
|
||||
>;
|
||||
|
||||
export type SidebarSessionAgent = Pick<AggregatedAgent, "id" | "serverId" | "cwd" | "archivedAt">;
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
|
||||
|
||||
const WORKSPACE: WorkspaceDescriptor = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
projectRootPath: "/abs/path/to/repo",
|
||||
workspaceDirectory: "/abs/path/to/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "feat-foo",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
|
||||
const AGENT: Agent = {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: "/abs/path/to/repo",
|
||||
model: null,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
const PROJECTS: SidebarProjectEntry[] = [
|
||||
{
|
||||
projectKey: "project-a",
|
||||
projectName: "Project A",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/abs/path/to/repo",
|
||||
workspaces: [
|
||||
{
|
||||
workspaceKey: "server-1:workspace-1",
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectKey: "project-a",
|
||||
projectRootPath: "/abs/path/to/repo",
|
||||
workspaceDirectory: "/abs/path/to/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "/abs/path/to/repo",
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
});
|
||||
|
||||
describe("useVisibleSidebarSessionFilterProjects", () => {
|
||||
it("returns projects with mapped active sessions", () => {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces("server-1", new Map([["workspace-1", WORKSPACE]]));
|
||||
useSessionStore.getState().setAgents("server-1", new Map([["agent-1", AGENT]]));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useVisibleSidebarSessionFilterProjects({
|
||||
serverId: "server-1",
|
||||
projects: PROJECTS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((project) => project.projectKey)).toEqual(["project-a"]);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { SidebarSessionWorkspace } from "./types";
|
||||
import {
|
||||
createSidebarSessionWorkspaceLookup,
|
||||
deriveSidebarSessionFilterAvailability,
|
||||
deriveSidebarSessionFilterProjects,
|
||||
} from "./session-filtering";
|
||||
|
||||
const EMPTY_WORKSPACES: SidebarSessionWorkspace[] = [];
|
||||
const EMPTY_AVAILABILITY = { projectKeys: [] } as const;
|
||||
|
||||
export function useSidebarSessionWorkspaces(input: {
|
||||
serverId: string | null;
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
}): SidebarSessionWorkspace[] {
|
||||
const workspaceFieldsById = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
if (!input.serverId) {
|
||||
return {};
|
||||
}
|
||||
const workspaces = state.sessions[input.serverId]?.workspaces;
|
||||
if (!workspaces || workspaces.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const fieldsById: Record<string, { name: string; workspaceDirectory: string | undefined }> =
|
||||
{};
|
||||
for (const workspace of workspaces.values()) {
|
||||
const entry = createSidebarWorkspaceEntry({ serverId: input.serverId, workspace });
|
||||
fieldsById[entry.workspaceId] = {
|
||||
name: entry.name,
|
||||
workspaceDirectory: entry.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
return fieldsById;
|
||||
},
|
||||
equal,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!input.serverId || input.projects.length === 0) {
|
||||
return EMPTY_WORKSPACES;
|
||||
}
|
||||
|
||||
const result: SidebarSessionWorkspace[] = [];
|
||||
for (const project of input.projects) {
|
||||
for (const workspace of project.workspaces) {
|
||||
const workspaceFields = workspaceFieldsById[workspace.workspaceId];
|
||||
const workspaceDirectory = workspaceFields?.workspaceDirectory;
|
||||
if (!workspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKey: workspace.workspaceKey,
|
||||
workspaceName: workspaceFields.name,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectIconKey: project.iconWorkingDir || null,
|
||||
workspaceDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [input.projects, input.serverId, workspaceFieldsById]);
|
||||
}
|
||||
|
||||
export function useVisibleSidebarSessionFilterProjects(input: {
|
||||
serverId: string | null;
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
}): SidebarProjectEntry[] {
|
||||
const workspaces = useSidebarSessionWorkspaces(input);
|
||||
const lookup = useMemo(() => createSidebarSessionWorkspaceLookup(workspaces), [workspaces]);
|
||||
const availability = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
if (!input.serverId || lookup.workspaceByExecutionKey.size === 0) {
|
||||
return EMPTY_AVAILABILITY;
|
||||
}
|
||||
const serverId = input.serverId;
|
||||
const agents = state.sessions[serverId]?.agents;
|
||||
if (!agents || agents.size === 0) {
|
||||
return EMPTY_AVAILABILITY;
|
||||
}
|
||||
return deriveSidebarSessionFilterAvailability({
|
||||
agents: Array.from(agents.values(), (agent) => ({
|
||||
id: agent.id,
|
||||
serverId,
|
||||
cwd: agent.cwd,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
})),
|
||||
lookup,
|
||||
});
|
||||
},
|
||||
equal,
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
deriveSidebarSessionFilterProjects({
|
||||
projects: input.projects,
|
||||
availability,
|
||||
}),
|
||||
[availability, input.projects],
|
||||
);
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
import React, { memo, useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type ListRenderItem,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton";
|
||||
import { sidebarProjectChildIndentStyles } from "@/components/sidebar/sidebar-collapsible-project-section";
|
||||
import {
|
||||
SidebarProjectsDraggableList,
|
||||
type SidebarProjectDragInfo,
|
||||
} from "@/components/sidebar/sidebar-projects-draggable-list";
|
||||
import {
|
||||
type AggregatedAgentIdEntry,
|
||||
useAggregatedAgentIds,
|
||||
useAggregatedAgentsInitialLoad,
|
||||
} from "@/hooks/use-aggregated-agents";
|
||||
import {
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabPresentationResolver,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import {
|
||||
createSidebarSessionWorkspaceLookup,
|
||||
resolveSidebarSessionWorkspaceId,
|
||||
resolveSidebarSessionWorkspace,
|
||||
shouldIncludeSidebarSessionAgent,
|
||||
type SidebarSessionGroup,
|
||||
} from "./session-filtering";
|
||||
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
|
||||
import type { ResolvedSidebarSessionProject, SidebarSessionFilter } from "./types";
|
||||
import { useSidebarSessionWorkspaces } from "./use-sidebar-session-workspaces";
|
||||
import {
|
||||
GROUPED_SESSION_LIMIT,
|
||||
SidebarSessionGroupFooter,
|
||||
SidebarSessionGroupHeader,
|
||||
useGroupedSidebarSessionGroups,
|
||||
useOrderedAgentProjectShape,
|
||||
} from "./grouped-view";
|
||||
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
|
||||
|
||||
interface SidebarSessionsViewProps {
|
||||
serverId: string | null;
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
filter: SidebarSessionFilter;
|
||||
groupByProject: boolean;
|
||||
previewExpandedProjects: ReadonlySet<string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onProjectPreviewExpandedToggle: (projectKey: string) => void;
|
||||
onProjectCollapsedToggle: (projectKey: string) => void;
|
||||
}
|
||||
|
||||
export function SidebarSessionsView({
|
||||
serverId,
|
||||
projects,
|
||||
filter,
|
||||
groupByProject,
|
||||
previewExpandedProjects,
|
||||
collapsedProjectKeys,
|
||||
onProjectPreviewExpandedToggle,
|
||||
onProjectCollapsedToggle,
|
||||
}: SidebarSessionsViewProps): ReactElement {
|
||||
const isInitialLoad = useAggregatedAgentsInitialLoad();
|
||||
const workspaces = useSidebarSessionWorkspaces({ serverId, projects });
|
||||
const lookup = useMemo(() => createSidebarSessionWorkspaceLookup(workspaces), [workspaces]);
|
||||
const filterAgent = useCallback(
|
||||
(agent: AggregatedAgentIdEntry) =>
|
||||
shouldIncludeSidebarSessionAgent({
|
||||
agent,
|
||||
filter,
|
||||
lookup,
|
||||
}),
|
||||
[filter, lookup],
|
||||
);
|
||||
const sessionIds = useAggregatedAgentIds({
|
||||
filter: filterAgent,
|
||||
sort: "createdAt-desc-stable",
|
||||
});
|
||||
const resolveCwdToProject = useCallback(
|
||||
(cwd: string) => {
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
const workspace = resolveSidebarSessionWorkspace(lookup, {
|
||||
id: "",
|
||||
serverId,
|
||||
cwd,
|
||||
archivedAt: null,
|
||||
});
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
projectKey: workspace.projectKey,
|
||||
projectName: workspace.projectName,
|
||||
projectIconKey: workspace.projectIconKey,
|
||||
};
|
||||
},
|
||||
[lookup, serverId],
|
||||
);
|
||||
|
||||
if (isInitialLoad) {
|
||||
return <SidebarAgentListSkeleton />;
|
||||
}
|
||||
|
||||
if (groupByProject) {
|
||||
return (
|
||||
<GroupedSidebarSessionsList
|
||||
serverId={serverId}
|
||||
sessionIds={sessionIds}
|
||||
projects={projects}
|
||||
resolveCwdToProject={resolveCwdToProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle}
|
||||
onProjectCollapsedToggle={onProjectCollapsedToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <FlatSidebarSessionsList serverId={serverId} sessionIds={sessionIds} />;
|
||||
}
|
||||
|
||||
const FlatSidebarSessionsList = memo(function FlatSidebarSessionsList({
|
||||
serverId,
|
||||
sessionIds,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
sessionIds: readonly string[];
|
||||
}): ReactElement {
|
||||
const renderItem: ListRenderItem<string> = useCallback(
|
||||
({ item }) => (serverId ? <SidebarSessionRow id={item} serverId={serverId} /> : null),
|
||||
[serverId],
|
||||
);
|
||||
const keyExtractor = useCallback((id: string) => `${serverId ?? ""}:${id}`, [serverId]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={sessionIds}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListEmptyComponent={EmptySessions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({
|
||||
serverId,
|
||||
sessionIds,
|
||||
projects,
|
||||
resolveCwdToProject,
|
||||
previewExpandedProjects,
|
||||
collapsedProjectKeys,
|
||||
onProjectPreviewExpandedToggle,
|
||||
onProjectCollapsedToggle,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
sessionIds: readonly string[];
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
resolveCwdToProject: (cwd: string) => ResolvedSidebarSessionProject | null;
|
||||
previewExpandedProjects: ReadonlySet<string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onProjectPreviewExpandedToggle: (projectKey: string) => void;
|
||||
onProjectCollapsedToggle: (projectKey: string) => void;
|
||||
}): ReactElement {
|
||||
const agentsWithProjects = useOrderedAgentProjectShape({
|
||||
orderedIds: sessionIds,
|
||||
serverId: serverId ?? "",
|
||||
resolveCwdToProject,
|
||||
});
|
||||
const groups = useGroupedSidebarSessionGroups({
|
||||
agentsWithProjects,
|
||||
projects,
|
||||
previewExpandedProjects,
|
||||
collapsedProjectKeys,
|
||||
});
|
||||
|
||||
const groupsByKey = useMemo(() => {
|
||||
const map = new Map<string, SidebarSessionGroup>();
|
||||
for (const group of groups) {
|
||||
map.set(group.projectKey, group);
|
||||
}
|
||||
return map;
|
||||
}, [groups]);
|
||||
|
||||
const projectsWithSessions = useMemo(
|
||||
() => projects.filter((project) => groupsByKey.has(project.projectKey)),
|
||||
[groupsByKey, projects],
|
||||
);
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ project, drag, isDragging, dragHandleProps }: SidebarProjectDragInfo) => {
|
||||
const group = groupsByKey.get(project.projectKey);
|
||||
if (!group) {
|
||||
return <View />;
|
||||
}
|
||||
return (
|
||||
<SidebarSessionGroupSection
|
||||
serverId={serverId}
|
||||
project={project}
|
||||
group={group}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onProjectCollapsedToggle={onProjectCollapsedToggle}
|
||||
onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[groupsByKey, onProjectCollapsedToggle, onProjectPreviewExpandedToggle, serverId],
|
||||
);
|
||||
|
||||
if (projectsWithSessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
<View style={styles.listContent}>
|
||||
<EmptySessions />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const draggableList = (
|
||||
<SidebarProjectsDraggableList
|
||||
testID="sidebar-session-group-list"
|
||||
projects={projectsWithSessions}
|
||||
serverId={serverId}
|
||||
renderProject={renderProject}
|
||||
nestable={platformIsNative}
|
||||
/>
|
||||
);
|
||||
|
||||
return platformIsNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-session-group-scroll"
|
||||
>
|
||||
{draggableList}
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-session-group-scroll"
|
||||
>
|
||||
{draggableList}
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
const SidebarSessionGroupSection = memo(function SidebarSessionGroupSection({
|
||||
serverId,
|
||||
project,
|
||||
group,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
onProjectCollapsedToggle,
|
||||
onProjectPreviewExpandedToggle,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
project: SidebarProjectEntry;
|
||||
group: SidebarSessionGroup;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: SidebarProjectDragInfo["dragHandleProps"];
|
||||
onProjectCollapsedToggle: (projectKey: string) => void;
|
||||
onProjectPreviewExpandedToggle: (projectKey: string) => void;
|
||||
}): ReactElement {
|
||||
const showFooter = !group.isCollapsed && group.totalCount > GROUPED_SESSION_LIMIT;
|
||||
const footer = useMemo(
|
||||
() =>
|
||||
showFooter ? (
|
||||
<SidebarSessionGroupFooter
|
||||
projectKey={group.projectKey}
|
||||
hiddenCount={group.hiddenCount}
|
||||
isExpanded={group.isExpanded}
|
||||
onPress={onProjectPreviewExpandedToggle}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
group.hiddenCount,
|
||||
group.isExpanded,
|
||||
group.projectKey,
|
||||
onProjectPreviewExpandedToggle,
|
||||
showFooter,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<SidebarSessionGroupHeader
|
||||
serverId={serverId}
|
||||
projectKey={group.projectKey}
|
||||
projectName={group.projectName}
|
||||
projectIconKey={group.projectIconKey}
|
||||
workspaces={project.workspaces}
|
||||
isCollapsed={group.isCollapsed}
|
||||
isDragging={isDragging}
|
||||
drag={drag}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onToggleCollapsed={onProjectCollapsedToggle}
|
||||
footer={footer}
|
||||
>
|
||||
{serverId
|
||||
? group.visibleIds.map((id) => (
|
||||
<SidebarSessionRow key={id} id={id} serverId={serverId} indented />
|
||||
))
|
||||
: null}
|
||||
</SidebarSessionGroupHeader>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const SidebarSessionRow = memo(function SidebarSessionRow({
|
||||
id,
|
||||
serverId,
|
||||
indented = false,
|
||||
}: {
|
||||
id: string;
|
||||
serverId: string;
|
||||
indented?: boolean;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => selectSidebarSessionSlice(state, serverId, id)),
|
||||
);
|
||||
const agentCwd = agent?.cwd ?? null;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!agentCwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId = resolveSidebarSessionWorkspaceId({
|
||||
agent: {
|
||||
id,
|
||||
serverId,
|
||||
cwd: agentCwd,
|
||||
archivedAt: null,
|
||||
},
|
||||
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||
});
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = { kind: "agent" as const, agentId: id };
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
});
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
});
|
||||
}, [agentCwd, id, serverId]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.row,
|
||||
indented && sidebarProjectChildIndentStyles.childRow,
|
||||
isHovered && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[indented, isHovered],
|
||||
);
|
||||
|
||||
const titleStyle = useMemo(() => [styles.title, isHovered && styles.titleHovered], [isHovered]);
|
||||
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
key: `agent_${id}`,
|
||||
tabId: `agent_${id}`,
|
||||
kind: "agent",
|
||||
target: { kind: "agent", agentId: id },
|
||||
}),
|
||||
[id],
|
||||
);
|
||||
|
||||
if (!agent || agent.archivedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={agent.title || "New session"}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
testID={`sidebar-session-row-${serverId}-${id}`}
|
||||
>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tabDescriptor}
|
||||
serverId={serverId}
|
||||
workspaceId="sidebar-sessions"
|
||||
>
|
||||
{(presentation) => (
|
||||
<WorkspaceTabIcon
|
||||
presentation={presentation}
|
||||
active={isHovered}
|
||||
size={theme.iconSize.sm}
|
||||
statusDotBorderColor={isHovered ? theme.colors.surfaceSidebarHover : undefined}
|
||||
/>
|
||||
)}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
</View>
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
<Text style={styles.timeAgo} numberOfLines={1}>
|
||||
{formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
<SidebarSessionRowKebabMenu serverId={serverId} agentId={id} isHovered={isHovered} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function EmptySessions(): ReactElement {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>No sessions</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
row: {
|
||||
minHeight: 36,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.76,
|
||||
},
|
||||
titleHovered: {
|
||||
opacity: 1,
|
||||
},
|
||||
timeAgo: {
|
||||
flexShrink: 0,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyState: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -5,7 +5,6 @@ beforeEach(() => {
|
||||
useKeyboardShortcutsStore.setState({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
projectPickerIntent: { kind: "open-project" },
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
@@ -26,23 +25,4 @@ describe("keyboard-shortcuts-store", () => {
|
||||
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
|
||||
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
|
||||
});
|
||||
|
||||
it("stores and resets the project picker intent", () => {
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setProjectPickerOpen(true, { kind: "new-workspace", headerTitle: "New session" });
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(true);
|
||||
expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
|
||||
kind: "new-workspace",
|
||||
headerTitle: "New session",
|
||||
});
|
||||
|
||||
useKeyboardShortcutsStore.getState().setProjectPickerOpen(false);
|
||||
|
||||
expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(false);
|
||||
expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
|
||||
kind: "open-project",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,9 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||
|
||||
const SHORTCUT_BADGE_DELAY_MS = 150;
|
||||
|
||||
export type ProjectPickerIntent =
|
||||
| { kind: "open-project" }
|
||||
| { kind: "new-workspace"; headerTitle: string };
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
projectPickerOpen: boolean;
|
||||
projectPickerIntent: ProjectPickerIntent;
|
||||
shortcutsDialogOpen: boolean;
|
||||
capturingShortcut: boolean;
|
||||
altDown: boolean;
|
||||
@@ -20,7 +15,7 @@ interface KeyboardShortcutsState {
|
||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setProjectPickerOpen: (open: boolean, intent?: ProjectPickerIntent) => void;
|
||||
setProjectPickerOpen: (open: boolean) => void;
|
||||
setShortcutsDialogOpen: (open: boolean) => void;
|
||||
setCapturingShortcut: (capturing: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
@@ -55,7 +50,6 @@ function updateBadgeTimer(
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
projectPickerIntent: { kind: "open-project" },
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
@@ -64,11 +58,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setProjectPickerOpen: (open, intent) =>
|
||||
set({
|
||||
projectPickerOpen: open,
|
||||
projectPickerIntent: open ? (intent ?? { kind: "open-project" }) : { kind: "open-project" },
|
||||
}),
|
||||
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
|
||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
|
||||
setAltDown: (down) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -161,14 +160,3 @@ describe("projects settings routes", () => {
|
||||
expect(decodeURIComponent(segment)).toBe(projectKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("new workspace route", () => {
|
||||
it("can carry a header title separately from the project display name", () => {
|
||||
expect(
|
||||
buildHostNewWorkspaceRoute("server-1", "/repo", {
|
||||
displayName: "Project",
|
||||
headerTitle: "New session",
|
||||
}),
|
||||
).toBe("/h/server-1/new?dir=%2Frepo&name=Project&title=New+session");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
|
||||
export function buildHostNewWorkspaceRoute(
|
||||
serverId: string,
|
||||
sourceDirectory: string,
|
||||
options?: { displayName?: string; headerTitle?: string },
|
||||
options?: { displayName?: string },
|
||||
) {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
@@ -369,9 +369,6 @@ export function buildHostNewWorkspaceRoute(
|
||||
if (options?.displayName) {
|
||||
params.set("name", options.displayName);
|
||||
}
|
||||
if (options?.headerTitle) {
|
||||
params.set("title", options.headerTitle);
|
||||
}
|
||||
return `${base}/new?${params.toString()}` as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.70-beta.1",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -60,11 +60,6 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.6 1M",
|
||||
descriptionFragment: "1M context window",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6[1m]",
|
||||
model: "Sonnet 4.6 1M",
|
||||
descriptionFragment: "1M context window",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
model: "Sonnet 4.6",
|
||||
|
||||
@@ -4,12 +4,16 @@ setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
||||
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
||||
set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe"
|
||||
if not exist "%APP_EXECUTABLE%" (
|
||||
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CLI_EXECUTABLE%" (
|
||||
set "CLI_EXECUTABLE=%APP_EXECUTABLE%"
|
||||
)
|
||||
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
set "PASEO_NODE_ENV=production"
|
||||
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
"%CLI_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -4,6 +4,9 @@ const path = require("path");
|
||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
const WINDOWS_CLI_EXECUTABLE_NAME = "PaseoCli";
|
||||
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
|
||||
const PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER = 0x5c;
|
||||
|
||||
// electron-builder arch enum → Node.js arch string
|
||||
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
|
||||
@@ -53,22 +56,10 @@ function pruneOnnxRuntime(nodeModules, platform, arch) {
|
||||
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
|
||||
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
|
||||
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
|
||||
if (keepName) {
|
||||
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
|
||||
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
|
||||
}
|
||||
if (!keepName) return;
|
||||
|
||||
// SDK ≥0.2.113 ships per-platform Claude Code binaries via optionalDependencies
|
||||
// (~210 MB each). Paseo requires user-installed `claude` on PATH, matching how
|
||||
// Codex/OpenCode are integrated, so drop every bundled copy.
|
||||
const anthropicDir = path.join(nodeModules, "@anthropic-ai");
|
||||
if (fs.existsSync(anthropicDir)) {
|
||||
for (const entry of fs.readdirSync(anthropicDir)) {
|
||||
if (entry.startsWith("claude-agent-sdk-")) {
|
||||
rmSafe(path.join(anthropicDir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
|
||||
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
|
||||
}
|
||||
|
||||
function pruneNodePty(nodeModules, platform, arch) {
|
||||
@@ -133,12 +124,50 @@ function fmtMB(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function setWindowsExecutableSubsystem(filePath, subsystem) {
|
||||
const fd = fs.openSync(filePath, "r+");
|
||||
try {
|
||||
const dosHeader = Buffer.alloc(64);
|
||||
fs.readSync(fd, dosHeader, 0, dosHeader.length, 0);
|
||||
if (dosHeader.toString("ascii", 0, 2) !== "MZ") {
|
||||
throw new Error(`Invalid Windows executable DOS header: ${filePath}`);
|
||||
}
|
||||
|
||||
const peHeaderOffset = dosHeader.readUInt32LE(0x3c);
|
||||
const peSignature = Buffer.alloc(4);
|
||||
fs.readSync(fd, peSignature, 0, peSignature.length, peHeaderOffset);
|
||||
if (peSignature.toString("ascii") !== "PE\u0000\u0000") {
|
||||
throw new Error(`Invalid Windows executable PE header: ${filePath}`);
|
||||
}
|
||||
|
||||
const subsystemOffset = peHeaderOffset + PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER;
|
||||
const subsystemBuffer = Buffer.alloc(2);
|
||||
subsystemBuffer.writeUInt16LE(subsystem);
|
||||
fs.writeSync(fd, subsystemBuffer, 0, subsystemBuffer.length, subsystemOffset);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindowsCliExecutable(appOutDir) {
|
||||
const appExecutable = path.join(appOutDir, `${EXECUTABLE_NAME}.exe`);
|
||||
const cliExecutable = path.join(appOutDir, `${WINDOWS_CLI_EXECUTABLE_NAME}.exe`);
|
||||
|
||||
fs.copyFileSync(appExecutable, cliExecutable);
|
||||
setWindowsExecutableSubsystem(cliExecutable, IMAGE_SUBSYSTEM_WINDOWS_CUI);
|
||||
console.log(`Created Windows CLI executable: ${cliExecutable}`);
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const platform = context.electronPlatformName;
|
||||
const arch = ARCH_MAP[context.arch] || process.arch;
|
||||
|
||||
pruneNativeModules(context.appOutDir, platform, arch);
|
||||
|
||||
if (platform === "win32") {
|
||||
createWindowsCliExecutable(context.appOutDir);
|
||||
}
|
||||
|
||||
if (platform === "linux" || platform === "win32") {
|
||||
if (arch !== process.arch) {
|
||||
console.log(
|
||||
@@ -159,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
|
||||
appPath: appOutDir,
|
||||
});
|
||||
}
|
||||
|
||||
exports.createWindowsCliExecutable = createWindowsCliExecutable;
|
||||
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
|
||||
|
||||
@@ -17,13 +17,12 @@ import {
|
||||
downloadAndInstallUpdate,
|
||||
type AppReleaseChannel,
|
||||
} from "../features/auto-updater.js";
|
||||
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
|
||||
import {
|
||||
getSkillsStatus,
|
||||
installCli,
|
||||
getCliInstallStatus,
|
||||
installSkills,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "../integrations/skills/index.js";
|
||||
getSkillsInstallStatus,
|
||||
} from "../integrations/integrations-manager.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
@@ -532,10 +531,8 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||
install_cli: () => installCli(),
|
||||
get_cli_install_status: () => getCliInstallStatus(),
|
||||
get_skills_status: () => getSkillsStatus(),
|
||||
install_skills: () => installSkills(),
|
||||
update_skills: () => updateSkills(),
|
||||
uninstall_skills: () => uninstallSkills(),
|
||||
get_skills_install_status: () => getSkillsInstallStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
describe("desktop packaging", () => {
|
||||
it("unpacks server zsh shell integration files for external shells", () => {
|
||||
@@ -33,4 +36,38 @@ describe("desktop packaging", () => {
|
||||
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a console-subsystem executable for the bundled Windows CLI shim", () => {
|
||||
const cmd = readFileSync(join(packageRoot, "bin", "paseo.cmd"), "utf8");
|
||||
|
||||
expect(cmd).toContain("PaseoCli.exe");
|
||||
expect(cmd).toContain('"%CLI_EXECUTABLE%"');
|
||||
});
|
||||
|
||||
it("can mark the Windows CLI executable as console subsystem", () => {
|
||||
const { setWindowsExecutableSubsystem } = require(
|
||||
join(packageRoot, "scripts", "after-pack.js"),
|
||||
) as {
|
||||
setWindowsExecutableSubsystem: (filePath: string, subsystem: number) => void;
|
||||
};
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "paseo-pe-subsystem-"));
|
||||
const exePath = join(tempDir, "probe.exe");
|
||||
const peHeaderOffset = 0x80;
|
||||
const subsystemOffset = peHeaderOffset + 0x5c;
|
||||
const bytes = Buffer.alloc(subsystemOffset + 2);
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt32LE(peHeaderOffset, 0x3c);
|
||||
bytes.write("PE\u0000\u0000", peHeaderOffset, "ascii");
|
||||
bytes.writeUInt16LE(2, subsystemOffset);
|
||||
writeFileSync(exePath, bytes);
|
||||
|
||||
try {
|
||||
setWindowsExecutableSubsystem(exePath, 3);
|
||||
|
||||
const patched = readFileSync(exePath);
|
||||
expect(patched.readUInt16LE(subsystemOffset)).toBe(3);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,9 +196,16 @@ function buildCheckResult(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
|
||||
// Use a short delay to allow the renderer to receive the response.
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,7 +314,7 @@ export async function downloadAndInstallUpdate(
|
||||
|
||||
const readyVersion = cachedUpdateInfo.version;
|
||||
if (isReadyToInstallVersion(readyVersion)) {
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
return {
|
||||
installed: true,
|
||||
version: readyVersion,
|
||||
@@ -329,7 +336,7 @@ export async function downloadAndInstallUpdate(
|
||||
await autoUpdater.downloadUpdate();
|
||||
downloadedUpdateVersion = readyVersion;
|
||||
downloading = false;
|
||||
await performQuitAndInstall(onBeforeQuit);
|
||||
scheduleQuitAndInstall(onBeforeQuit);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliInstallSourcePath } from "./path";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path";
|
||||
|
||||
describe("cli-install-path", () => {
|
||||
it("uses the bundled shim for packaged macOS installs", () => {
|
||||
@@ -1 +0,0 @@
|
||||
export { getCliInstallStatus, installCli } from "./install.js";
|
||||
@@ -1,71 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolveCliInstallSourcePath } from "./path.js";
|
||||
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
|
||||
import { ensurePathInShellRc } from "./shell-rc.js";
|
||||
|
||||
interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
const shimPath = getBundledCliShimPath();
|
||||
const installSourcePath = resolveCliInstallSourcePath({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: app.getPath("exe"),
|
||||
shimPath,
|
||||
appImagePath: process.env.APPIMAGE,
|
||||
});
|
||||
const binDir = getLocalBinDir();
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
||||
// Only the app install path is baked in — internal details (asar layout,
|
||||
// entrypoint scripts) live in the bundled shim and update with the app.
|
||||
const cmdContent = [
|
||||
"@echo off",
|
||||
`set "BUNDLED_CLI=${shimPath}"`,
|
||||
`if not exist "%BUNDLED_CLI%" (`,
|
||||
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
||||
` exit /b 1`,
|
||||
`)`,
|
||||
`call "%BUNDLED_CLI%" %*`,
|
||||
`exit /b %errorlevel%`,
|
||||
].join("\r\n");
|
||||
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
||||
} else {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.symlink(installSourcePath, targetPath);
|
||||
}
|
||||
|
||||
const { shellUpdated } = await ensurePathInShellRc();
|
||||
if (shellUpdated) {
|
||||
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
||||
}
|
||||
|
||||
return getCliInstallStatus();
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
return { installed: await pathOrSymlinkExists(targetPath) };
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
|
||||
export function getLocalBinDir(): string {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
export function getCliTargetPath(): string {
|
||||
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
return path.join(getLocalBinDir(), filename);
|
||||
}
|
||||
|
||||
export function getBundledCliShimPath(): string {
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
// Linux
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import log from "electron-log/main";
|
||||
|
||||
export interface ShellRcInfo {
|
||||
shell: string;
|
||||
rcFile: string;
|
||||
pathCheckPattern: RegExp;
|
||||
exportLine: string;
|
||||
}
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectShellRcInfo(): ShellRcInfo | null {
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
const shell = process.env.SHELL;
|
||||
if (!shell) return null;
|
||||
|
||||
const shellName = path.basename(shell);
|
||||
|
||||
if (shellName === "zsh") {
|
||||
return {
|
||||
shell: "zsh",
|
||||
rcFile: path.join(os.homedir(), ".zshrc"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "bash") {
|
||||
const rcFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(os.homedir(), ".bash_profile")
|
||||
: path.join(os.homedir(), ".bashrc");
|
||||
return {
|
||||
shell: "bash",
|
||||
rcFile,
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "fish") {
|
||||
return {
|
||||
shell: "fish",
|
||||
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: "fish_add_path $HOME/.local/bin",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pathAlreadyContainsLocalBin(): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const localBin = path.join(os.homedir(), ".local", "bin");
|
||||
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
||||
}
|
||||
|
||||
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
||||
if (pathAlreadyContainsLocalBin()) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
const info = detectShellRcInfo();
|
||||
if (!info) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await pathOrSymlinkExists(info.rcFile);
|
||||
if (exists) {
|
||||
const content = await fs.readFile(info.rcFile, "utf-8");
|
||||
if (info.pathCheckPattern.test(content)) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
||||
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
||||
|
||||
return { shellUpdated: true };
|
||||
} catch (err) {
|
||||
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
|
||||
import { syncSkills } from "./skill-sync.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKILL_NAMES = [
|
||||
"paseo",
|
||||
"paseo-advisor",
|
||||
"paseo-committee",
|
||||
"paseo-epic",
|
||||
"paseo-handoff",
|
||||
"paseo-loop",
|
||||
"paseo-orchestrate",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getLocalBinDir(): string {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
function getCliTargetPath(): string {
|
||||
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
return path.join(getLocalBinDir(), filename);
|
||||
}
|
||||
|
||||
function getBundledCliShimPath(): string {
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
// Linux
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
function getBundledSkillsDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "skills");
|
||||
}
|
||||
return path.join(__dirname, "..", "..", "..", "..", "skills");
|
||||
}
|
||||
|
||||
function getAgentsSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
function getClaudeSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
function getCodexSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".codex", "skills");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell PATH helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ShellRcInfo {
|
||||
shell: string;
|
||||
rcFile: string;
|
||||
pathCheckPattern: RegExp;
|
||||
exportLine: string;
|
||||
}
|
||||
|
||||
function detectShellRcInfo(): ShellRcInfo | null {
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
const shell = process.env.SHELL;
|
||||
if (!shell) return null;
|
||||
|
||||
const shellName = path.basename(shell);
|
||||
|
||||
if (shellName === "zsh") {
|
||||
return {
|
||||
shell: "zsh",
|
||||
rcFile: path.join(os.homedir(), ".zshrc"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "bash") {
|
||||
const rcFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(os.homedir(), ".bash_profile")
|
||||
: path.join(os.homedir(), ".bashrc");
|
||||
return {
|
||||
shell: "bash",
|
||||
rcFile,
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "fish") {
|
||||
return {
|
||||
shell: "fish",
|
||||
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: "fish_add_path $HOME/.local/bin",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathAlreadyContainsLocalBin(): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const localBin = path.join(os.homedir(), ".local", "bin");
|
||||
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
||||
}
|
||||
|
||||
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
||||
if (pathAlreadyContainsLocalBin()) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
const info = detectShellRcInfo();
|
||||
if (!info) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await pathOrSymlinkExists(info.rcFile);
|
||||
if (exists) {
|
||||
const content = await fs.readFile(info.rcFile, "utf-8");
|
||||
if (info.pathCheckPattern.test(content)) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
||||
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
||||
|
||||
return { shellUpdated: true };
|
||||
} catch (err) {
|
||||
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
const shimPath = getBundledCliShimPath();
|
||||
const installSourcePath = resolveCliInstallSourcePath({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: app.getPath("exe"),
|
||||
shimPath,
|
||||
appImagePath: process.env.APPIMAGE,
|
||||
});
|
||||
const binDir = getLocalBinDir();
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
||||
// Only the app install path is baked in — internal details (asar layout,
|
||||
// entrypoint scripts) live in the bundled shim and update with the app.
|
||||
const cmdContent = [
|
||||
"@echo off",
|
||||
`set "BUNDLED_CLI=${shimPath}"`,
|
||||
`if not exist "%BUNDLED_CLI%" (`,
|
||||
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
||||
` exit /b 1`,
|
||||
`)`,
|
||||
`call "%BUNDLED_CLI%" %*`,
|
||||
`exit /b %errorlevel%`,
|
||||
].join("\r\n");
|
||||
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
||||
} else {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.symlink(installSourcePath, targetPath);
|
||||
}
|
||||
|
||||
const { shellUpdated } = await ensurePathInShellRc();
|
||||
if (shellUpdated) {
|
||||
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
||||
}
|
||||
|
||||
return getCliInstallStatus();
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
return { installed: await pathOrSymlinkExists(targetPath) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getSkillSyncTargets(): {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
} {
|
||||
return {
|
||||
sourceDir: getBundledSkillsDir(),
|
||||
agentsDir: getAgentsSkillsDir(),
|
||||
claudeDir: getClaudeSkillsDir(),
|
||||
codexDir: getCodexSkillsDir(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
const targets = getSkillSyncTargets();
|
||||
log.info("[integrations] installSkills", targets);
|
||||
|
||||
const result = await syncSkills({
|
||||
...targets,
|
||||
skillNames: SKILL_NAMES,
|
||||
onSkillError: (skillName, error) => {
|
||||
log.warn("[integrations] skill install failed", { skillName, error });
|
||||
},
|
||||
});
|
||||
|
||||
log.info("[integrations] installSkills done", result);
|
||||
return getSkillsInstallStatus();
|
||||
}
|
||||
|
||||
export async function autoUpdateSkillsIfInstalled(): Promise<{
|
||||
ran: boolean;
|
||||
changedFiles: number;
|
||||
processedSkills: number;
|
||||
}> {
|
||||
const targets = getSkillSyncTargets();
|
||||
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
|
||||
|
||||
try {
|
||||
await fs.access(installedMarker);
|
||||
} catch {
|
||||
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncSkills({
|
||||
...targets,
|
||||
skillNames: SKILL_NAMES,
|
||||
onSkillError: (skillName, error) => {
|
||||
log.warn("[integrations] skill auto-update failed", { skillName, error });
|
||||
},
|
||||
});
|
||||
if (result.changedFiles > 0) {
|
||||
log.info("[integrations] auto-updated paseo skills", result);
|
||||
} else {
|
||||
log.info("[integrations] paseo skills already up to date", result);
|
||||
}
|
||||
return { ran: true, ...result };
|
||||
} catch (error) {
|
||||
log.warn("[integrations] auto-update skills aborted", { error });
|
||||
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
const claudeDir = getClaudeSkillsDir();
|
||||
const accessResults = await Promise.all(
|
||||
SKILL_NAMES.map((skillName) =>
|
||||
fs
|
||||
.access(path.join(claudeDir, skillName, "SKILL.md"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
),
|
||||
);
|
||||
return { installed: accessResults.every(Boolean) };
|
||||
}
|
||||
+5
-53
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { removeSkill, syncSkills } from "./sync";
|
||||
import { syncSkills } from "./skill-sync";
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
@@ -70,11 +70,6 @@ describe("syncSkills", () => {
|
||||
"utf-8",
|
||||
);
|
||||
expect(agentsContent).toBe("new paseo content");
|
||||
const claudeContent = await fs.readFile(
|
||||
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(claudeContent).toBe("new paseo content");
|
||||
const codexContent = await fs.readFile(
|
||||
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
@@ -112,10 +107,10 @@ describe("syncSkills", () => {
|
||||
),
|
||||
).toBe("roles content");
|
||||
|
||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
|
||||
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
const lstat = await fs.lstat(claudeLink);
|
||||
expect(lstat.isSymbolicLink()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
|
||||
"roles content",
|
||||
);
|
||||
});
|
||||
@@ -259,46 +254,3 @@ describe("syncSkills", () => {
|
||||
expect(result.processedSkills).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSkill", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes the skill from all three targets when present", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
await removeSkill("paseo", {
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
});
|
||||
|
||||
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when targets are missing", async () => {
|
||||
await expect(
|
||||
removeSkill("does-not-exist", {
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
+35
-19
@@ -7,6 +7,7 @@ export interface SkillSyncOptions {
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
skillNames: readonly string[];
|
||||
platform?: NodeJS.Platform;
|
||||
onSkillError?: (skillName: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -24,7 +25,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
@@ -52,24 +53,38 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
|
||||
return changed;
|
||||
}
|
||||
|
||||
export interface RemoveSkillTargets {
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
async function ensureClaudeSkillLink(
|
||||
skillName: string,
|
||||
agentsDir: string,
|
||||
claudeDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<number> {
|
||||
await fs.mkdir(claudeDir, { recursive: true });
|
||||
const target = path.join(agentsDir, skillName);
|
||||
const linkPath = path.join(claudeDir, skillName);
|
||||
|
||||
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
|
||||
const paths = [
|
||||
path.join(targets.agentsDir, skillName),
|
||||
path.join(targets.claudeDir, skillName),
|
||||
path.join(targets.codexDir, skillName),
|
||||
];
|
||||
for (const p of paths) {
|
||||
await fs.rm(p, { recursive: true, force: true });
|
||||
// Always rebuild the link rather than diffing it. fs.rm with force: true is
|
||||
// a no-op when nothing is there, and matches existing install behavior.
|
||||
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
|
||||
// is preserved.
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
|
||||
if (platform === "win32") {
|
||||
try {
|
||||
// Junctions don't require Developer Mode / admin like regular symlinks do.
|
||||
await fs.symlink(target, linkPath, "junction");
|
||||
return 0;
|
||||
} catch {
|
||||
return await syncDirectoryFiles(target, linkPath);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.symlink(target, linkPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
let changedFiles = 0;
|
||||
let processedSkills = 0;
|
||||
|
||||
@@ -85,9 +100,11 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
||||
path.join(options.agentsDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.claudeDir, skillName),
|
||||
changedFiles += await ensureClaudeSkillLink(
|
||||
skillName,
|
||||
options.agentsDir,
|
||||
options.claudeDir,
|
||||
platform,
|
||||
);
|
||||
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
@@ -97,8 +114,7 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
|
||||
|
||||
processedSkills++;
|
||||
} catch (error) {
|
||||
if (!options.onSkillError) throw error;
|
||||
options.onSkillError(skillName, error);
|
||||
options.onSkillError?.(skillName, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export {
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
type SkillOp,
|
||||
type SkillsState,
|
||||
type SkillsStatus,
|
||||
type SkillTargets,
|
||||
} from "./operations.js";
|
||||
@@ -1,302 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
||||
isPackaged: false,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
PASEO_SKILL_NAMES,
|
||||
type SkillTargets,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "./operations";
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
targets: SkillTargets;
|
||||
}
|
||||
|
||||
async function makeSandbox(): Promise<Sandbox> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
|
||||
const targets: SkillTargets = {
|
||||
sourceDir: path.join(root, "bundle"),
|
||||
agentsDir: path.join(root, "home", ".agents", "skills"),
|
||||
claudeDir: path.join(root, "home", ".claude", "skills"),
|
||||
codexDir: path.join(root, "home", ".codex", "skills"),
|
||||
};
|
||||
await fs.mkdir(targets.sourceDir, { recursive: true });
|
||||
return { root, targets };
|
||||
}
|
||||
|
||||
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(rootDir, rel);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeBundleSkill(
|
||||
sourceDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await writeFiles(path.join(sourceDir, name), files);
|
||||
}
|
||||
|
||||
async function writeOnDiskSkill(
|
||||
agentsDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await writeFiles(path.join(agentsDir, name), files);
|
||||
}
|
||||
|
||||
async function writeCurrentBundle(sourceDir: string): Promise<void> {
|
||||
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
return fs
|
||||
.access(p)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
describe("getSkillsStatus", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
||||
}
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns up-to-date when every bundled skill matches on disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
|
||||
it("returns drift with a single update op when one bundled file diverges", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
|
||||
});
|
||||
|
||||
it("returns drift with add ops for the bundled skills missing from disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
|
||||
});
|
||||
|
||||
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
|
||||
});
|
||||
|
||||
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "update", name: "paseo" },
|
||||
{ kind: "delete", name: "paseo-chat" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installSkills / updateSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
|
||||
|
||||
const status = await installSkills(sandbox.targets);
|
||||
|
||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||
for (const name of ["paseo", "paseo-loop"]) {
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
||||
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
|
||||
}
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
|
||||
).toBe("user-unslop");
|
||||
});
|
||||
|
||||
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await updateSkills(sandbox.targets);
|
||||
|
||||
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
|
||||
).toBe("paseo-v1");
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
|
||||
).toBe("loop-v1");
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent — running install twice keeps state at up-to-date", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const first = await installSkills(sandbox.targets);
|
||||
const second = await installSkills(sandbox.targets);
|
||||
|
||||
expect(first).toEqual({ state: "up-to-date", ops: [] });
|
||||
expect(second).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstallSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await installSkills(sandbox.targets);
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
||||
}
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
|
||||
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
|
||||
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
|
||||
}
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(`user-${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op when nothing is installed", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
}
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
getAgentsSkillsDir,
|
||||
getBundledSkillsDir,
|
||||
getClaudeSkillsDir,
|
||||
getCodexSkillsDir,
|
||||
} from "./paths.js";
|
||||
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
|
||||
|
||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||
|
||||
export type SkillOp =
|
||||
| { kind: "add"; name: string }
|
||||
| { kind: "update"; name: string }
|
||||
| { kind: "delete"; name: string };
|
||||
|
||||
export interface SkillsStatus {
|
||||
state: SkillsState;
|
||||
ops: SkillOp[];
|
||||
}
|
||||
|
||||
export interface SkillTargets {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
export const PASEO_SKILL_NAMES = [
|
||||
"paseo",
|
||||
"paseo-advisor",
|
||||
"paseo-chat",
|
||||
"paseo-committee",
|
||||
"paseo-epic",
|
||||
"paseo-handoff",
|
||||
"paseo-loop",
|
||||
"paseo-orchestrate",
|
||||
"paseo-orchestrator",
|
||||
] as const;
|
||||
|
||||
type SkillFiles = Map<string, string>;
|
||||
|
||||
function resolveSkillTargets(): SkillTargets {
|
||||
return {
|
||||
sourceDir: getBundledSkillsDir(),
|
||||
agentsDir: getAgentsSkillsDir(),
|
||||
claudeDir: getClaudeSkillsDir(),
|
||||
codexDir: getCodexSkillsDir(),
|
||||
};
|
||||
}
|
||||
|
||||
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
|
||||
const stat = await fs.stat(skillDir).catch(() => null);
|
||||
if (!stat?.isDirectory()) return null;
|
||||
|
||||
const rels = await listFilesRecursive(skillDir);
|
||||
const files: SkillFiles = new Map();
|
||||
for (const rel of rels) {
|
||||
const buf = await fs.readFile(path.join(skillDir, rel));
|
||||
const sha = createHash("sha256").update(buf).digest("hex");
|
||||
files.set(toPosix(rel), sha);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
|
||||
const out = new Map<string, SkillFiles>();
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
const files = await hashSkillDir(path.join(rootDir, name));
|
||||
if (files !== null) out.set(name, files);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
|
||||
const ops: SkillOp[] = [];
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
const b = bundle.get(name);
|
||||
const d = disk.get(name);
|
||||
if (b && !d) ops.push({ kind: "add", name });
|
||||
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
|
||||
else if (!b && d) ops.push({ kind: "delete", name });
|
||||
}
|
||||
ops.sort((a, b) => compareStrings(a.name, b.name));
|
||||
return ops;
|
||||
}
|
||||
|
||||
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [rel, sha] of a) {
|
||||
if (b.get(rel) !== sha) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
const t = targets ?? resolveSkillTargets();
|
||||
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
|
||||
const ops = diff(bundle, disk);
|
||||
|
||||
if (disk.size === 0) return { state: "not-installed", ops };
|
||||
if (ops.length === 0) return { state: "up-to-date", ops };
|
||||
return { state: "drift", ops };
|
||||
}
|
||||
|
||||
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
|
||||
const status = await getSkillsStatus(targets);
|
||||
|
||||
const writes = status.ops
|
||||
.filter((op) => op.kind === "add" || op.kind === "update")
|
||||
.map((op) => op.name);
|
||||
if (writes.length > 0) {
|
||||
await syncSkills({
|
||||
sourceDir: targets.sourceDir,
|
||||
agentsDir: targets.agentsDir,
|
||||
claudeDir: targets.claudeDir,
|
||||
codexDir: targets.codexDir,
|
||||
skillNames: writes,
|
||||
});
|
||||
}
|
||||
|
||||
for (const op of status.ops) {
|
||||
if (op.kind !== "delete") continue;
|
||||
await removeSkill(op.name, {
|
||||
agentsDir: targets.agentsDir,
|
||||
claudeDir: targets.claudeDir,
|
||||
codexDir: targets.codexDir,
|
||||
});
|
||||
}
|
||||
|
||||
return getSkillsStatus(targets);
|
||||
}
|
||||
|
||||
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
return applySkills(targets ?? resolveSkillTargets());
|
||||
}
|
||||
|
||||
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
return applySkills(targets ?? resolveSkillTargets());
|
||||
}
|
||||
|
||||
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||
const t = targets ?? resolveSkillTargets();
|
||||
for (const name of PASEO_SKILL_NAMES) {
|
||||
await removeSkill(name, {
|
||||
agentsDir: t.agentsDir,
|
||||
claudeDir: t.claudeDir,
|
||||
codexDir: t.codexDir,
|
||||
});
|
||||
}
|
||||
return getSkillsStatus(t);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
|
||||
export function getBundledSkillsDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "skills");
|
||||
}
|
||||
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
|
||||
}
|
||||
|
||||
export function getAgentsSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
export function getClaudeSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
export function getCodexSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".codex", "skills");
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
createBeforeQuitHandler,
|
||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||
} from "./daemon/quit-lifecycle.js";
|
||||
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
|
||||
import { runDesktopStartup } from "./desktop-startup.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
@@ -660,6 +661,10 @@ async function bootstrap(): Promise<void> {
|
||||
registerNotificationHandlers();
|
||||
registerOpenerHandlers();
|
||||
|
||||
void autoUpdateSkillsIfInstalled().catch((error) => {
|
||||
log.warn("[integrations] auto-update skills failed", error);
|
||||
});
|
||||
|
||||
await createMainWindow();
|
||||
|
||||
app.on("activate", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.70-beta.1",
|
||||
"version": "0.1.69",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -57,9 +57,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.70-beta.1",
|
||||
"@getpaseo/relay": "0.1.70-beta.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isPlatform } from "../src/test-utils/platform.js";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||
@@ -117,21 +116,17 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.log).toContain("raw stderr line\n");
|
||||
});
|
||||
|
||||
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"logs worker signal exits even when the worker cannot log",
|
||||
async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
test("logs worker signal exits even when the worker cannot log", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
process.kill(process.pid, "SIGKILL");
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker exited"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
},
|
||||
);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker exited"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execSync } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -25,9 +24,6 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
const TARGET_CWD = resolvePath("/tmp/target");
|
||||
|
||||
interface LooseSafeParseResult {
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
@@ -46,7 +42,7 @@ interface LooseStructuredContent {
|
||||
|
||||
interface RegisteredMcpTool {
|
||||
inputSchema: LooseInputSchema;
|
||||
handler: (input: unknown) => Promise<{
|
||||
callback: (input: unknown) => Promise<{
|
||||
structuredContent: LooseStructuredContent;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
}>;
|
||||
@@ -387,7 +383,7 @@ describe("terminal MCP tools", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "capture_terminal");
|
||||
|
||||
const response = await tool.handler({
|
||||
const response = await tool.callback({
|
||||
terminalId: "term-1",
|
||||
scrollback: true,
|
||||
stripAnsi: false,
|
||||
@@ -511,7 +507,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(providerWithEmptyProvider.success).toBe(false);
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
tool.callback({
|
||||
cwd: existingCwd,
|
||||
mode: "default",
|
||||
title: "Short title",
|
||||
@@ -574,7 +570,7 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
tool.callback({
|
||||
cwd: "/path/that/does/not/exist",
|
||||
title: "Short title",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -587,7 +583,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-123",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -596,7 +592,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: " Fix auth bug ",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -617,7 +613,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-456",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -626,7 +622,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: " Fix auth ",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -646,7 +642,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-789",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -655,7 +651,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: "Config test",
|
||||
mode: "auto",
|
||||
@@ -689,20 +685,14 @@ describe("create_agent MCP tool", () => {
|
||||
const startedAgentSetupIds: string[] = [];
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-with-worktree",
|
||||
@@ -727,7 +717,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: repoDir,
|
||||
title: "Worktree agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -767,20 +757,14 @@ describe("create_agent MCP tool", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-auto-named-worktree",
|
||||
@@ -803,7 +787,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: repoDir,
|
||||
title: "Worktree agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -814,10 +798,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
expect(initialBranch).not.toBe("");
|
||||
@@ -843,28 +824,19 @@ describe("create_agent MCP tool", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "-b", "existing-feature"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-checkout-worktree",
|
||||
@@ -887,7 +859,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: repoDir,
|
||||
title: "Checkout agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -899,9 +871,7 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim(),
|
||||
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
||||
).toBe("existing-feature");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
@@ -931,7 +901,7 @@ describe("create_agent MCP tool", () => {
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/pr-123",
|
||||
projectId: REPO_CWD,
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/worktrees/pr-123",
|
||||
kind: "worktree" as const,
|
||||
displayName: "pr-123",
|
||||
@@ -939,7 +909,7 @@ describe("create_agent MCP tool", () => {
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
repoRoot: REPO_CWD,
|
||||
repoRoot: "/tmp/repo",
|
||||
created: true,
|
||||
...(options?.setupContinuation?.kind === "agent"
|
||||
? {
|
||||
@@ -978,8 +948,8 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
cwd: REPO_CWD,
|
||||
await tool.callback({
|
||||
cwd: "/tmp/repo",
|
||||
title: "PR agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Rename this PR branch from prompt",
|
||||
@@ -1015,20 +985,14 @@ describe("create_agent MCP tool", () => {
|
||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
};
|
||||
@@ -1049,7 +1013,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const response = await tool.handler({
|
||||
const response = await tool.callback({
|
||||
cwd: repoDir,
|
||||
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
|
||||
});
|
||||
@@ -1067,27 +1031,19 @@ describe("create_agent MCP tool", () => {
|
||||
|
||||
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = realpathSync.native(
|
||||
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
|
||||
);
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
@@ -1116,13 +1072,13 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
const createTool = registeredTool(server, "create_worktree");
|
||||
const archiveTool = registeredTool(server, "archive_worktree");
|
||||
const created = await createTool.handler({
|
||||
const created = await createTool.callback({
|
||||
cwd: repoDir,
|
||||
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
|
||||
});
|
||||
workspaceGitService.getSnapshot.mockClear();
|
||||
|
||||
await archiveTool.handler({
|
||||
await archiveTool.callback({
|
||||
cwd: repoDir,
|
||||
worktreePath: created.structuredContent.worktreePath,
|
||||
});
|
||||
@@ -1170,9 +1126,9 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_worktrees");
|
||||
|
||||
const response = await tool.handler({ cwd: REPO_CWD });
|
||||
const response = await tool.callback({ cwd: "/tmp/repo" });
|
||||
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
|
||||
reason: "mcp:list-worktrees",
|
||||
});
|
||||
expect(response.structuredContent.worktrees).toEqual([
|
||||
@@ -1232,7 +1188,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: "subdir",
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -1258,7 +1214,7 @@ describe("create_agent MCP tool", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-injected-123",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -1271,7 +1227,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: "Injected config test",
|
||||
mode: "auto",
|
||||
@@ -1295,7 +1251,7 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: "Bad mode",
|
||||
provider: "opencode/gpt-5.4",
|
||||
@@ -1332,7 +1288,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
title: "Child",
|
||||
provider: "claude/claude-sonnet-4-20250514",
|
||||
initialPrompt: "Do work",
|
||||
@@ -1363,7 +1319,7 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
tool.callback({
|
||||
title: "Child",
|
||||
provider: "opencode/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
@@ -1398,7 +1354,7 @@ describe("create_agent MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
title: "Child",
|
||||
provider: "opencode/gpt-5.4",
|
||||
mode: "build",
|
||||
@@ -1428,7 +1384,7 @@ describe("create_schedule MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
tool.callback({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Default schedule",
|
||||
@@ -1448,12 +1404,12 @@ describe("create_schedule MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
provider: "codex",
|
||||
});
|
||||
await tool.handler({
|
||||
await tool.callback({
|
||||
prompt: "say hello again",
|
||||
every: "10m",
|
||||
provider: "codex/gpt-5.4",
|
||||
@@ -1514,7 +1470,7 @@ describe("provider listing MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.handler({});
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
@@ -1562,7 +1518,7 @@ describe("provider listing MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.handler({});
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
@@ -1599,7 +1555,7 @@ describe("provider listing MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
|
||||
await tool.handler({});
|
||||
await tool.callback({});
|
||||
|
||||
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
|
||||
expect(isAvailable).toHaveBeenCalledTimes(1);
|
||||
@@ -1635,7 +1591,7 @@ describe("model listing MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_models");
|
||||
|
||||
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
|
||||
await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
@@ -1659,7 +1615,7 @@ describe("speak MCP tool", () => {
|
||||
const tool = registeredTool(server, "speak");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
await tool.handler({ text: "Hello from voice agent." });
|
||||
await tool.callback({ text: "Hello from voice agent." });
|
||||
expect(speak).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "Hello from voice agent.",
|
||||
@@ -1679,7 +1635,7 @@ describe("speak MCP tool", () => {
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "speak");
|
||||
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
|
||||
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
|
||||
"No speak handler registered for caller agent",
|
||||
);
|
||||
});
|
||||
@@ -1706,7 +1662,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
createManagedAgent({
|
||||
id: "agent-compact",
|
||||
provider: "codex",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
||||
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
|
||||
labels: { role: "researcher" },
|
||||
@@ -1715,7 +1671,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.handler({});
|
||||
const response = await tool.callback({});
|
||||
const structured = z
|
||||
.object({ agents: z.array(z.record(z.unknown())) })
|
||||
.parse(response.structuredContent);
|
||||
@@ -1731,7 +1687,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
thinkingOptionId: "high",
|
||||
effectiveThinkingOptionId: "high",
|
||||
status: "idle",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
lastUserMessageAt: null,
|
||||
@@ -1769,7 +1725,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.handler({ agentId: "archived-agent" });
|
||||
const response = await tool.callback({ agentId: "archived-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
status: "closed",
|
||||
@@ -1825,7 +1781,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.handler({ agentId: "full-detail-agent" });
|
||||
const response = await tool.callback({ agentId: "full-detail-agent" });
|
||||
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
|
||||
|
||||
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
|
||||
@@ -1901,7 +1857,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
|
||||
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow(
|
||||
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
|
||||
"Agent internal-agent not found",
|
||||
);
|
||||
});
|
||||
@@ -1945,7 +1901,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
callerAgentId: "caller-agent",
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.handler({});
|
||||
const response = await tool.callback({});
|
||||
|
||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||
expect(agentIds).toHaveLength(3);
|
||||
@@ -1960,32 +1916,28 @@ describe("agent snapshot MCP serialization", () => {
|
||||
spies.agentManager.listAgents.mockReturnValue([
|
||||
createManagedAgent({
|
||||
id: "running-target",
|
||||
cwd: TARGET_CWD,
|
||||
cwd: "/tmp/target",
|
||||
lifecycle: "running",
|
||||
updatedAt: new Date(recent),
|
||||
}),
|
||||
createManagedAgent({
|
||||
id: "idle-target",
|
||||
cwd: TARGET_CWD,
|
||||
cwd: "/tmp/target",
|
||||
lifecycle: "idle",
|
||||
updatedAt: new Date(recent),
|
||||
}),
|
||||
createManagedAgent({
|
||||
id: "old-running-target",
|
||||
cwd: TARGET_CWD,
|
||||
cwd: "/tmp/target",
|
||||
lifecycle: "running",
|
||||
createdAt: new Date(old),
|
||||
updatedAt: new Date(old),
|
||||
}),
|
||||
]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
|
||||
createStoredRecord({
|
||||
id: "recent-other-cwd",
|
||||
cwd: resolvePath("/tmp/other"),
|
||||
archivedAt: recent,
|
||||
}),
|
||||
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
|
||||
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1997,8 +1949,8 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.handler({
|
||||
cwd: TARGET_CWD,
|
||||
const response = await tool.callback({
|
||||
cwd: "/tmp/target",
|
||||
includeArchived: true,
|
||||
sinceHours: 48,
|
||||
statuses: ["running", "closed"],
|
||||
@@ -2038,7 +1990,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.handler({ includeArchived: true });
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
const agentIds = agentsOf(response).map((agent) => agent.id);
|
||||
|
||||
expect(agentIds).toHaveLength(50);
|
||||
@@ -2057,7 +2009,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-archived-compact",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
updatedAt: now,
|
||||
lastActivityAt: now,
|
||||
archivedAt: now,
|
||||
@@ -2081,7 +2033,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true });
|
||||
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
|
||||
const item = agentsOf(response)[0];
|
||||
|
||||
expect(item).toEqual({
|
||||
@@ -2093,7 +2045,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
status: "closed",
|
||||
cwd: REPO_CWD,
|
||||
cwd: "/tmp/repo",
|
||||
createdAt: "2026-04-11T00:00:00.000Z",
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: null,
|
||||
@@ -2154,7 +2106,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.handler({});
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
|
||||
"idle-attention-oldest",
|
||||
@@ -2189,7 +2141,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.handler({ includeArchived: true });
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
|
||||
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
|
||||
if (!parsed.success) {
|
||||
@@ -2229,7 +2181,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.handler({ agentId: "archived-activity-agent" });
|
||||
const response = await tool.callback({ agentId: "archived-activity-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -2267,7 +2219,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 });
|
||||
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
|
||||
|
||||
const content = String(response.structuredContent.content);
|
||||
expect(content).toContain("Hello world. How are you?");
|
||||
@@ -2298,7 +2250,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
},
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 });
|
||||
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
|
||||
|
||||
const content = String(response.structuredContent.content);
|
||||
expect(content).toContain("[User] u3");
|
||||
|
||||
+1
-8
@@ -74,14 +74,7 @@ async function createSession(params?: {
|
||||
|
||||
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
|
||||
await handle.session.close().catch(() => undefined);
|
||||
try {
|
||||
rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
rmSync(handle.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function startTurnAndCollectEvents(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
PermissionOption,
|
||||
PromptResponse,
|
||||
@@ -25,7 +23,6 @@ import { transformPiModels } from "./pi-direct-agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
import * as spawnUtils from "../../../utils/spawn.js";
|
||||
|
||||
interface ACPSessionInternals {
|
||||
sessionId: string | null;
|
||||
@@ -117,14 +114,6 @@ function createSessionWithConfig(
|
||||
);
|
||||
}
|
||||
|
||||
function createTerminalChildStub(): ChildProcess {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
child.stdout = new EventEmitter() as ChildProcess["stdout"];
|
||||
child.stderr = new EventEmitter() as ChildProcess["stderr"];
|
||||
child.kill = vi.fn(() => true) as ChildProcess["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
function selectConfigOption(
|
||||
category: "mode" | "model" | "thought_level",
|
||||
values: string[],
|
||||
@@ -314,78 +303,6 @@ describe("createLoggedNdJsonStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession terminal tools", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("runs single-string terminal commands through the platform shell", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
const shell = spawnUtils.platformShell();
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "git -C /repo status --short",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
shell.command,
|
||||
[...shell.flag, "git -C /repo status --short"],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves explicit terminal argv", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["status", "--short"],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "missing-command",
|
||||
});
|
||||
child.emit("error", new Error("spawn missing-command ENOENT"));
|
||||
|
||||
await expect(
|
||||
session.waitForTerminalExit({
|
||||
sessionId: "session-1",
|
||||
terminalId: terminal.terminalId,
|
||||
}),
|
||||
).rejects.toThrow("spawn missing-command ENOENT");
|
||||
await expect(
|
||||
session.terminalOutput({
|
||||
sessionId: "session-1",
|
||||
terminalId: terminal.terminalId,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
output: "spawn missing-command ENOENT\n",
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapACPUsage", () => {
|
||||
test("maps ACP usage fields into Paseo usage", () => {
|
||||
expect(
|
||||
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
@@ -106,22 +106,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveTerminalCommand(
|
||||
command: string,
|
||||
args?: string[],
|
||||
): { command: string; args: string[] } {
|
||||
if (args && args.length > 0) {
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
if (!/\s/.test(command.trim())) {
|
||||
return { command, args: [] };
|
||||
}
|
||||
|
||||
const shell = platformShell();
|
||||
return { command: shell.command, args: [...shell.flag, command] };
|
||||
}
|
||||
|
||||
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -1480,8 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const env = Object.fromEntries(
|
||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||
);
|
||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
||||
const child = spawnProcess(params.command, params.args ?? [], {
|
||||
cwd: params.cwd ?? this.config.cwd,
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
@@ -1496,7 +1479,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
resolveExit = resolve;
|
||||
rejectExit = reject;
|
||||
});
|
||||
waitForExit.catch(() => undefined);
|
||||
|
||||
const entry: TerminalEntry = {
|
||||
id: terminalId,
|
||||
@@ -1516,11 +1498,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.stderr!.on("data", (chunk: Buffer | string) =>
|
||||
appendTerminalOutput(entry, chunk.toString()),
|
||||
);
|
||||
child.once("error", (error) => {
|
||||
const spawnError = error instanceof Error ? error : new Error(String(error));
|
||||
appendTerminalOutput(entry, `${spawnError.message}\n`);
|
||||
rejectExit(spawnError);
|
||||
});
|
||||
child.once("error", (error) =>
|
||||
rejectExit(error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
child.once("exit", (code, signal) => {
|
||||
const exit = { exitCode: code, signal };
|
||||
entry.exit = exit;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user