Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c2eb85340 | ||
|
|
7c7b6555f1 | ||
|
|
303cebec06 | ||
|
|
fcda2f1cf0 | ||
|
|
f7a0e2cbea | ||
|
|
2a9e86b542 | ||
|
|
9ed4f1069b |
@@ -1 +1 @@
|
||||
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
|
||||
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
findAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openProviderCatalog(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
}
|
||||
|
||||
async function expectProviderArtwork(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
id: "devin" | "hermes" | "kiro",
|
||||
name: string,
|
||||
) {
|
||||
await findAcpCatalogProvider(page, name);
|
||||
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
|
||||
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
|
||||
await expect(icon).toBeVisible();
|
||||
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
|
||||
await expect.soft(icon).toHaveAttribute("width", "24");
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
}, ACP_PROVIDER_ICON_SVGS[id]);
|
||||
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
|
||||
for (const [index, path] of expectedPaths.entries()) {
|
||||
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
|
||||
}
|
||||
|
||||
test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openProviderCatalog(page);
|
||||
await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
|
||||
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
|
||||
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
expectProjectWorkspaceCountForHost,
|
||||
expectSeparateProjects,
|
||||
openGroupedProjectSettings,
|
||||
openGroupedProjectWorkspace,
|
||||
expectWorkspaceHost,
|
||||
openProjectsForSettingsHost,
|
||||
openProjectDirectory,
|
||||
openProjectDirectoryWithHosts,
|
||||
@@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => {
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
test("creates grouped project workspaces on the active workspace host", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: PRIMARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Android input delay diagnostic
|
||||
|
||||
Diagnostic evidence for https://github.com/getpaseo/paseo/issues/4408, not a fix or a CI test.
|
||||
|
||||
Observed with the published Android 0.11.4 APK on an Android 16 emulator and a main
|
||||
daemon at 7c7b6555f17a88cffddf484129cd04ad79704f59. The fixture has 400 active
|
||||
workspaces sharing one checkout, plus a synthetic 110-turn conversation and a
|
||||
five-minute mock-provider stream. Clean/dirty transitions emitted 400 workspace
|
||||
updates each. The JavaScript thread reached 96–100% CPU, and tapping Open menu
|
||||
did not expose Close sidebar within five seconds. The same process stayed alive.
|
||||
|
||||
This combines the many-workspace and streaming reports. It does not isolate which
|
||||
work causes the delay, reproduce an allocation crash, or establish that the
|
||||
contributor's byte-counting optimization fixes it. An emulator is not the affected
|
||||
physical phone. No forced memory pressure, forced garbage collection, or fake
|
||||
client responses are used. The daemon's existing mock provider supplies synthetic
|
||||
agent output through the normal transport.
|
||||
|
||||
## Run
|
||||
|
||||
Use a disposable emulator with the published app installed and agent-device
|
||||
0.20.3 or later. Preserve its previous state before changing its app installation.
|
||||
Build the repository's server dependencies first. All commands below run from
|
||||
the repository root in a disposable worktree.
|
||||
|
||||
1. Start `npx tsx packages/app/e2e/mobile/workspace-update-freeze/fixture.ts`.
|
||||
It writes an isolated home under `.dev/workspace-update-freeze`, starts an
|
||||
isolated daemon, and exposes it on local TCP port 45151. Do not use an existing
|
||||
daemon or point this script at real data.
|
||||
2. Forward that port to the emulator, open the app with an agent-device session,
|
||||
and add a direct connection to that isolated daemon. Open the sidebar and
|
||||
verify that the Android reproduction project and workspace rows are visible.
|
||||
3. Start `npx tsx packages/app/e2e/mobile/workspace-update-freeze/stream.ts`.
|
||||
Wait for `HISTORY_READY`. The script writes the agent ID to
|
||||
`.dev/workspace-update-freeze/history-ready` and starts the five-minute stream.
|
||||
4. Open `paseo://h/diag515a/agent/<agent-id>` with agent-device, using the generated
|
||||
ID. Dismiss the notification prompt if present. Verify that the agent's text
|
||||
and Stop agent button are visible, then scroll the conversation.
|
||||
5. Start `node packages/app/e2e/mobile/workspace-update-freeze/toggle.mjs` in a
|
||||
separate terminal. It toggles the fixture's tracked file every three seconds
|
||||
for 90 seconds. `events.json` records workspace updates observed by an actual
|
||||
subscribed daemon client; initial subscription updates are included.
|
||||
6. While the toggles and stream are active, run:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/workspace-update-freeze/android.mjs <device-session>
|
||||
```
|
||||
|
||||
It taps Open menu and requires Close sidebar within five seconds. The observed
|
||||
failure was a timeout for Close sidebar while Open menu remained visible.
|
||||
This asserts an input delay, not an application crash or its root cause.
|
||||
|
||||
Stop the fixture with SIGTERM to close its client, proxy, and isolated daemon.
|
||||
Close the agent-device session and restore the emulator's prior state afterward.
|
||||
Do not interpret a passing run as disproving the physical-device reports.
|
||||
@@ -0,0 +1,16 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const session = process.argv[2];
|
||||
if (!session) throw new Error("Pass the agent-device session connected to the Android fixture");
|
||||
|
||||
function device(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
device("wait", 'label="Open menu"', "5000");
|
||||
device("press", 'label="Open menu"', "--settle");
|
||||
device("wait", 'label="Close sidebar"', "5000");
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { startIsolatedHostDaemon } from "../../support/helpers/isolated-host-daemon";
|
||||
import { connectDaemonClient } from "../../support/helpers/daemon-client-loader";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
|
||||
interface FixtureClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
fetchWorkspaces(options: {
|
||||
page: { limit: number };
|
||||
subscribe: Record<string, never>;
|
||||
}): Promise<{ entries: unknown[] }>;
|
||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
process.env.E2E_METRO_PORT = "8098";
|
||||
const root = path.resolve(".dev/workspace-update-freeze");
|
||||
const cwd = path.join(root, "repo");
|
||||
const home = path.join(root, "home");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await mkdir(home + "/projects", { recursive: true });
|
||||
for (const args of [
|
||||
["init", "-b", "main"],
|
||||
["config", "user.name", "Repro"],
|
||||
["config", "user.email", "repro@example.com"],
|
||||
])
|
||||
execFileSync("git", args, { cwd });
|
||||
await writeFile(cwd + "/README.md", "initial\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd });
|
||||
execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { cwd });
|
||||
const date = "2026-03-01T00:00:00.000Z";
|
||||
const project = {
|
||||
projectId: "prj_diag515a",
|
||||
rootPath: cwd,
|
||||
kind: "git",
|
||||
displayName: "Android reproduction",
|
||||
projectKey: null,
|
||||
customName: null,
|
||||
customIconRevision: null,
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
archivedAt: null,
|
||||
};
|
||||
const workspaces = Array.from({ length: 400 }, (_, i) => ({
|
||||
workspaceId: `wks_diag515a_${i}`,
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: `Workspace ${String(i).padStart(3, "0")}`,
|
||||
title: null,
|
||||
branch: null,
|
||||
worktreeRoot: null,
|
||||
baseBranch: "main",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: cwd,
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
archivedAt: null,
|
||||
autoArchivedChangeRequestUrl: null,
|
||||
pinnedAt: null,
|
||||
}));
|
||||
await writeFile(home + "/projects/projects.json", JSON.stringify([project]));
|
||||
await writeFile(home + "/projects/workspaces.json", JSON.stringify(workspaces));
|
||||
const daemon = await startIsolatedHostDaemon("diag515a", { paseoHome: home, preserveHome: true });
|
||||
const client = await connectDaemonClient<FixtureClient>({
|
||||
clientIdPrefix: "diag515a",
|
||||
port: daemon.port,
|
||||
});
|
||||
const events: { at: number; payload: unknown }[] = [];
|
||||
client.subscribeRawMessages((m) => {
|
||||
if (m.type === "workspace_update") events.push({ at: Date.now(), payload: m.payload });
|
||||
});
|
||||
const initial = await client.fetchWorkspaces({ page: { limit: 200 }, subscribe: {} });
|
||||
console.log("INITIAL", initial.entries.length);
|
||||
const proxy = net.createServer((socket) => {
|
||||
const up = net.connect(daemon.port, "127.0.0.1");
|
||||
socket.pipe(up);
|
||||
up.pipe(socket);
|
||||
socket.on("error", () => up.destroy());
|
||||
up.on("error", () => socket.destroy());
|
||||
});
|
||||
await new Promise<void>((resolve) => proxy.listen(45151, "127.0.0.1", resolve));
|
||||
await writeFile(
|
||||
root + "/ready.json",
|
||||
JSON.stringify({ port: daemon.port, cwd, pid: daemon.getPid() }),
|
||||
);
|
||||
console.log("READY", daemon.port);
|
||||
const timer = setInterval(async () => {
|
||||
await writeFile(root + "/events.json", JSON.stringify(events));
|
||||
}, 1000);
|
||||
process.on("SIGTERM", async () => {
|
||||
clearInterval(timer);
|
||||
proxy.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
process.exit(0);
|
||||
});
|
||||
await new Promise(() => {});
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SeedDaemonClient } from "../../support/helpers/seed-client";
|
||||
import { connectDaemonClient } from "../../support/helpers/daemon-client-loader";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
async function main() {
|
||||
const { port, cwd } = JSON.parse(
|
||||
await readFile(".dev/workspace-update-freeze/ready.json", "utf8"),
|
||||
);
|
||||
const client = await connectDaemonClient<SeedDaemonClient>({
|
||||
clientIdPrefix: "stream515a",
|
||||
port,
|
||||
});
|
||||
try {
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd,
|
||||
workspaceId: "wks_diag515a_0",
|
||||
title: "Android stream reproduction",
|
||||
modeId: "load-test",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
await writeFile(".dev/workspace-update-freeze/agent.json", JSON.stringify(agent));
|
||||
for (let i = 0; i < 110; i++) {
|
||||
await client.sendAgentMessage(
|
||||
agent.id,
|
||||
`history ${i}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
await client.waitForFinish(agent.id, 15000);
|
||||
}
|
||||
console.log("HISTORY_READY", agent.id);
|
||||
await writeFile(".dev/workspace-update-freeze/history-ready", agent.id);
|
||||
await client.sendAgentMessage(agent.id, "Stream a realistic long answer with code blocks.");
|
||||
await client.waitForFinish(agent.id, 360000);
|
||||
console.log("FINISHED");
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(".dev/workspace-update-freeze");
|
||||
const { cwd } = JSON.parse(await readFile(path.join(root, "ready.json"), "utf8"));
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
await writeFile(path.join(cwd, "README.md"), index % 2 ? "changed first\n" : "initial\n");
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
@@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGroupedProjectWorkspace(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
workspaceName: string,
|
||||
): Promise<void> {
|
||||
await projectGroup(page, projectName)
|
||||
.getByRole("button")
|
||||
.filter({ hasText: workspaceName })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHost(page: Page, hostName: string): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z" fill="currentColor"></path><path d="M15.943 10.607a1.596 1.596 0 011.599 0l1.276.74c.041.025.085.04.13.055l.028.008c.043.01.088.016.133.018h.005c.005 0 .01-.002.014-.003a.474.474 0 00.122-.016l.021-.005a.616.616 0 00.126-.052c.004-.002.009-.002.013-.005l2.554-1.482a.597.597 0 00.297-.516V6.383a.596.596 0 00-.297-.515l-2.552-1.483a.587.587 0 00-.592 0l-2.553 1.482-.011.008a.61.61 0 00-.108.084l-.014.016a.637.637 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.029a.612.612 0 00-.018.14v1.482c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.584.584 0 00-.13-.053l-.028-.008a.62.62 0 00-.133-.018h-.02a.587.587 0 00-.269.074l-.012.004L9.15 10a.596.596 0 00-.296.516v2.966c0 .212.112.409.296.515l2.554 1.483s.008.002.012.005c.04.022.082.04.126.052l.02.004a.57.57 0 00.123.017l.014.002h.006c.054 0 .108-.01.16-.025a.587.587 0 00.13-.054l1.277-.741a1.597 1.597 0 012.398 1.392v1.482c0 .049.007.095.019.14l.007.028a.619.619 0 00.051.125c.004.006.008.01.01.016a.6.6 0 00.076.1l.014.015c.033.032.069.06.108.084.004.002.006.006.011.008l2.554 1.483a.59.59 0 00.593 0l2.554-1.483a.597.597 0 00.296-.516v-2.965a.595.595 0 00-.296-.516l-2.554-1.483s-.008-.002-.012-.005a.54.54 0 00-.126-.051c-.007-.003-.013-.003-.02-.005a.635.635 0 00-.125-.017h-.018a.557.557 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.595 1.595 0 01-1.598 0 1.615 1.615 0 010-2.785l-.005-.001z" fill="currentColor"></path><path d="M14.848 18.265l-2.554-1.482-.012-.005a.526.526 0 00-.126-.052c-.007-.002-.014-.002-.02-.005a.64.64 0 00-.124-.017h-.02a.56.56 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.594 1.594 0 01-1.598 0c-.493-.286-.8-.82-.8-1.393V14.65a.563.563 0 00-.018-.14l-.008-.028a.604.604 0 00-.051-.124l-.01-.017a.603.603 0 00-.076-.1l-.014-.015a.596.596 0 00-.109-.084c-.003-.002-.005-.006-.01-.008L5.178 12.65a.587.587 0 00-.591 0l-2.554 1.483a.596.596 0 00-.297.516v2.965c0 .213.113.41.297.516l2.554 1.483.012.004a.618.618 0 00.267.074l.016.002h.007a.55.55 0 00.16-.026.584.584 0 00.129-.053l1.277-.742a1.597 1.597 0 012.398 1.393v1.482c0 .05.007.095.019.14l.007.028c.013.044.03.085.051.125l.01.016c.022.036.047.07.076.1l.014.015c.032.032.069.06.109.084l.01.008 2.554 1.483a.587.587 0 00.593 0l2.554-1.483a.596.596 0 00.296-.515v-2.966a.596.596 0 00-.296-.516h-.002z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">
|
||||
<path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>
|
||||
<path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 975 B |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18.8 0H5.2A5.2 5.2 0 000 5.2v13.6A5.2 5.2 0 005.2 24h13.6a5.2 5.2 0 005.2-5.2V5.2A5.2 5.2 0 0018.8 0z" fill="#9046FF" /> <path d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z" fill="#fff" /> <path d="M12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z" fill="#000" /></svg>
|
||||
|
After Width: | Height: | Size: 895 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="currentColor" fill-rule="nonzero"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,17 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AntigravityIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path
|
||||
transform="translate(12 12) scale(0.24) translate(-55.738 -56.945)"
|
||||
d="M89.6992 93.695C94.3659 97.195 101.366 94.8617 94.9492 88.445C75.6992 69.7783 79.7825 18.445 55.8659 18.445C31.9492 18.445 36.0325 69.7783 16.7825 88.445C9.78251 95.445 17.3658 97.195 22.0325 93.695C40.1159 81.445 38.9492 59.8617 55.8659 59.8617C72.7825 59.8617 71.6159 81.445 89.6992 93.695Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface KiroIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001zM12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => {
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||
expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" });
|
||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
|
||||
expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" });
|
||||
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
@@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) {
|
||||
}
|
||||
|
||||
describe("useProviderIcon", () => {
|
||||
it.each(["devin", "hermes", "kiro", "antigravity"])(
|
||||
"#7087: %s has a provider mark before a host snapshot arrives",
|
||||
(provider) => {
|
||||
const Icon = renderHook(() => useProviderIcon(provider)).result.current;
|
||||
expect(Icon).not.toBe(Bot);
|
||||
expect(Icon).not.toBe(PackagePlus);
|
||||
expect(typeof Icon).toBe("function");
|
||||
},
|
||||
);
|
||||
|
||||
it("renders registered snapshot SVG metadata with the requested size and color", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { providerSnapshotIcons } from "@/data/provider-icons";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { createElement, useCallback, type ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names";
|
||||
import { AntigravityIcon } from "@/components/icons/antigravity-icon";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { KiroIcon } from "@/components/icons/kiro-icon";
|
||||
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||
@@ -23,10 +25,11 @@ export interface ProviderIconProps {
|
||||
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
|
||||
|
||||
const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
antigravity: AntigravityIcon as unknown as ProviderIconComponent,
|
||||
claude: ClaudeIcon as unknown as ProviderIconComponent,
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
kiro: KiroIcon as unknown as ProviderIconComponent,
|
||||
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
|
||||
@@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHosts,
|
||||
useHostRuntimeConnectionStatus,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import {
|
||||
useSidebarWorkspacePinController,
|
||||
@@ -1596,14 +1600,18 @@ function ProjectBlock({
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces);
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
|
||||
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
project,
|
||||
collapsed,
|
||||
supportsMultiplicityByServerId,
|
||||
preferredServerId,
|
||||
}),
|
||||
[collapsed, project, supportsMultiplicityByServerId],
|
||||
[collapsed, preferredServerId, project, supportsMultiplicityByServerId],
|
||||
);
|
||||
|
||||
// Collapsed rows hide their workspace rows, so the project row carries the most urgent
|
||||
@@ -1848,6 +1856,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
|
||||
areProjectBlockSelectionsEqual(previous, next)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAllByRole } from "@testing-library/dom";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
import { SidebarProjectEmptyState } from "./empty-states";
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
interface Mounted {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
await testI18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
async function mountEmptyState(language: string, width: number): Promise<HTMLElement> {
|
||||
await testI18n.changeLanguage(language);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.width = `${width}px`;
|
||||
// Match the workspace list's horizontal inset around the card.
|
||||
container.style.paddingInline = "6px";
|
||||
container.style.boxSizing = "border-box";
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<SidebarProjectEmptyState />));
|
||||
mounted.push({ root, container });
|
||||
|
||||
const card = container.querySelector('[data-testid="sidebar-project-empty-state"]');
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
throw new Error("SidebarProjectEmptyState did not render its card");
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function expectActionsInsideCard(card: HTMLElement) {
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const buttons = getAllByRole(card, "button");
|
||||
|
||||
expect(buttons).toHaveLength(2);
|
||||
for (const button of buttons) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
expect(rect.left).toBeGreaterThanOrEqual(cardRect.left);
|
||||
expect(rect.right).toBeLessThanOrEqual(cardRect.right);
|
||||
|
||||
// Containing the button must also keep the whole label readable, without clipping.
|
||||
const label = document.createRange();
|
||||
label.selectNodeContents(button);
|
||||
for (const fragment of Array.from(label.getClientRects())) {
|
||||
expect(fragment.left).toBeGreaterThanOrEqual(rect.left);
|
||||
expect(fragment.right).toBeLessThanOrEqual(rect.right);
|
||||
expect(fragment.top).toBeGreaterThanOrEqual(rect.top);
|
||||
expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("SidebarProjectEmptyState", () => {
|
||||
const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) =>
|
||||
[MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })),
|
||||
);
|
||||
it.each(cases)(
|
||||
"keeps every action inside the card ($language, $width px sidebar)",
|
||||
async ({ language, width }) => {
|
||||
expectActionsInsideCard(await mountEmptyState(language, width));
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps English actions on one row when they fit", async () => {
|
||||
const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH);
|
||||
const [addProject, importSession] = getAllByRole(card, "button");
|
||||
expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top);
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,13 @@ export function SidebarFilterEmptyState() {
|
||||
title={t("sidebar.filterEmpty.title")}
|
||||
description={t("sidebar.filterEmpty.description")}
|
||||
>
|
||||
<Button variant="secondary" size="xs" onPress={clearFilters}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
style={styles.action}
|
||||
textStyle={styles.actionText}
|
||||
onPress={clearFilters}
|
||||
>
|
||||
{t("sidebar.filterEmpty.clear")}
|
||||
</Button>
|
||||
</SidebarEmptyStateCard>
|
||||
@@ -76,10 +82,24 @@ export function SidebarProjectEmptyState({
|
||||
title={t("sidebar.project.empty.title")}
|
||||
description={t("sidebar.project.empty.description")}
|
||||
>
|
||||
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
leftIcon={Plus}
|
||||
style={styles.action}
|
||||
textStyle={styles.actionText}
|
||||
onPress={onAddProject}
|
||||
>
|
||||
{t("sidebar.actions.addProject")}
|
||||
</Button>
|
||||
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
leftIcon={Import}
|
||||
style={styles.action}
|
||||
textStyle={styles.actionText}
|
||||
onPress={onImportSession}
|
||||
>
|
||||
{t("importSession.title")}
|
||||
</Button>
|
||||
</SidebarEmptyStateCard>
|
||||
@@ -99,9 +119,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
action: {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
actionText: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -133,7 +133,7 @@ const CATALOG_DATA = [
|
||||
title: "Devin CLI",
|
||||
description: "Cognition's Devin for Terminal via Agent Client Protocol",
|
||||
version: "manual",
|
||||
iconId: null,
|
||||
iconId: "devin",
|
||||
installLink: "https://cli.devin.ai/docs",
|
||||
command: ["devin", "acp"],
|
||||
},
|
||||
@@ -235,7 +235,7 @@ const CATALOG_DATA = [
|
||||
title: "Hermes",
|
||||
description: "Nous Research self-improving AI agent",
|
||||
version: "manual",
|
||||
iconId: null,
|
||||
iconId: "hermes",
|
||||
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
command: ["hermes", "acp"],
|
||||
},
|
||||
@@ -272,7 +272,7 @@ const CATALOG_DATA = [
|
||||
title: "Kiro CLI",
|
||||
description: "Amazon's AI coding agent with native ACP support",
|
||||
version: "manual",
|
||||
iconId: null,
|
||||
iconId: "kiro",
|
||||
installLink: "https://kiro.dev/docs/cli/acp/",
|
||||
command: ["kiro-cli", "acp"],
|
||||
},
|
||||
|
||||
@@ -11,6 +11,13 @@ function findProvider(id: string) {
|
||||
}
|
||||
|
||||
describe("ACP provider catalog", () => {
|
||||
it.each(["devin", "hermes", "kiro"])(
|
||||
"#7087: %s supplies its provider mark to the catalog picker",
|
||||
(provider) => {
|
||||
expect(findProvider(provider).iconSvg).toContain("<svg");
|
||||
},
|
||||
);
|
||||
|
||||
it("vendors provider entries with unique ids and concrete commands", () => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
|
||||
@@ -65,6 +65,45 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
|
||||
}
|
||||
|
||||
describe("buildSidebarProjectRowModel", () => {
|
||||
it("prefers the active host when the grouped project can create there", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" },
|
||||
],
|
||||
}),
|
||||
collapsed: false,
|
||||
preferredServerId: "host-b",
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "host-b", projectId: "project-host-b", iconWorkingDir: "/repo/b" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["host-b", "another-host"])(
|
||||
"keeps the first eligible placement when preferred host %s cannot create here",
|
||||
(preferredServerId) => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "unsupported" },
|
||||
],
|
||||
}),
|
||||
collapsed: false,
|
||||
preferredServerId,
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "host-a", projectId: "project-host-a", iconWorkingDir: "/repo/a" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("renders a non-git single-workspace project as an expandable section", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
|
||||
@@ -85,7 +85,9 @@ export function resolveSidebarProjectLocalPath(
|
||||
function resolveNewWorkspaceTarget(
|
||||
project: SidebarProjectEntry,
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
preferredServerId: string | null | undefined,
|
||||
): SidebarProjectHostTarget | null {
|
||||
let fallbackTarget: SidebarProjectHostTarget | null = null;
|
||||
for (const host of project.hosts) {
|
||||
if (
|
||||
host.worktreeSupport === "unsupported" &&
|
||||
@@ -94,22 +96,30 @@ function resolveNewWorkspaceTarget(
|
||||
continue;
|
||||
}
|
||||
const target = hostTarget(host);
|
||||
if (target) return target;
|
||||
if (!target) continue;
|
||||
if (host.serverId === preferredServerId) return target;
|
||||
fallbackTarget ??= target;
|
||||
}
|
||||
return null;
|
||||
return fallbackTarget;
|
||||
}
|
||||
|
||||
function projectTrailingAction(
|
||||
project: SidebarProjectEntry,
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
preferredServerId: string | null | undefined,
|
||||
): SidebarProjectTrailingAction {
|
||||
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
|
||||
const target = resolveNewWorkspaceTarget(
|
||||
project,
|
||||
supportsMultiplicityByServerId,
|
||||
preferredServerId,
|
||||
);
|
||||
return target ? { kind: "new_workspace", target } : { kind: "none" };
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
preferredServerId?: string | null;
|
||||
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
|
||||
}): SidebarProjectRowModel {
|
||||
return {
|
||||
@@ -118,6 +128,7 @@ export function buildSidebarProjectRowModel(input: {
|
||||
trailingAction: projectTrailingAction(
|
||||
input.project,
|
||||
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
|
||||
input.preferredServerId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const BUILTIN_PROVIDER_ICON_NAMES = [
|
||||
"antigravity",
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
@@ -22,6 +23,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
|
||||
"crow-cli",
|
||||
"cursor",
|
||||
"deepagents",
|
||||
"devin",
|
||||
"dimcode",
|
||||
"dirac",
|
||||
"factory-droid",
|
||||
@@ -31,6 +33,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
|
||||
"glm-acp-agent",
|
||||
"goose",
|
||||
"grok",
|
||||
"hermes",
|
||||
"hoplite",
|
||||
"junie",
|
||||
"kilo",
|
||||
|
||||
@@ -285,10 +285,15 @@ describe("OMP MCP host tools", () => {
|
||||
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
|
||||
omp.processExit("crashed");
|
||||
await omp.startTurn("again");
|
||||
await vi.waitFor(() => {
|
||||
expect(omp.runtimeSessions()).toHaveLength(2);
|
||||
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
|
||||
});
|
||||
// The relaunch spawns a new runtime and MCP child; on a loaded Windows
|
||||
// runner that has taken longer than vi.waitFor's default one second.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(omp.runtimeSessions()).toHaveLength(2);
|
||||
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
} finally {
|
||||
await omp.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { connect, createServer as createTcpServer, type AddressInfo, type Socket } from "node:net";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { HubRelationshipController } from "./relationship-controller.js";
|
||||
import { DirectHubRelationshipRemote } from "./relationship-remote.js";
|
||||
|
||||
test("a silently lost Hub connection leaves connected state and reconnects when the path recovers", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-hub-liveness-"));
|
||||
const hub = createHttpServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
const enrollment = JSON.parse(body) as { daemonId: string };
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
daemonId: enrollment.daemonId,
|
||||
permissions: ["hub.execute"],
|
||||
webSocketUrl: `ws://127.0.0.1:${proxyPort}/api/daemons/socket`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const webSockets = new WebSocketServer({ server: hub });
|
||||
const peers = new Set<WebSocket>();
|
||||
let connections = 0;
|
||||
let pongs = 0;
|
||||
webSockets.on("connection", (peer) => {
|
||||
connections += 1;
|
||||
peers.add(peer);
|
||||
peer.on("close", () => peers.delete(peer));
|
||||
});
|
||||
await listen(hub);
|
||||
const hubPort = (hub.address() as AddressInfo).port;
|
||||
const streams = new Set<Socket>();
|
||||
let dropped = false;
|
||||
const proxy = createTcpServer((client) => {
|
||||
const upstream = connect(hubPort, "127.0.0.1");
|
||||
streams.add(client);
|
||||
streams.add(upstream);
|
||||
client.on("data", (data) => {
|
||||
if (!dropped) upstream.write(data);
|
||||
});
|
||||
upstream.on("data", (data) => {
|
||||
if (!dropped) client.write(data);
|
||||
});
|
||||
client.on("end", () => {
|
||||
if (!dropped) upstream.end();
|
||||
});
|
||||
upstream.on("end", () => {
|
||||
if (!dropped) client.end();
|
||||
});
|
||||
for (const socket of [client, upstream]) {
|
||||
socket.on("error", () => socket.destroy());
|
||||
socket.on("close", () => streams.delete(socket));
|
||||
}
|
||||
});
|
||||
await listen(proxy);
|
||||
const proxyPort = (proxy.address() as AddressInfo).port;
|
||||
let retry: (() => void) | undefined;
|
||||
const controller = new HubRelationshipController({
|
||||
paseoHome: home,
|
||||
hostname: "test-daemon",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
logger: pino({ level: "silent" }),
|
||||
remote: new DirectHubRelationshipRemote(),
|
||||
clock: {
|
||||
now: () => new Date(),
|
||||
schedule: (_delay, task) => {
|
||||
retry = task;
|
||||
return { cancel: () => (retry = undefined) };
|
||||
},
|
||||
},
|
||||
attachSocket: async (socket) => {
|
||||
if (!(socket instanceof WebSocket)) throw new Error("Expected a real WebSocket");
|
||||
socket.on("pong", () => (pongs += 1));
|
||||
},
|
||||
updateAttachedPermissions: () => undefined,
|
||||
createExecutionAgents: () => ({
|
||||
create: async () => {
|
||||
throw new Error("Unexpected agent creation");
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
invalidateAuthority: async () => undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
// Keep TCP and WebSocket I/O real; accelerate only the keepalive interval and its clock.
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
|
||||
await controller.connect({
|
||||
hubUrl: `http://127.0.0.1:${proxyPort}`,
|
||||
token: "test-token",
|
||||
permissions: ["hub.execute"],
|
||||
});
|
||||
await expect.poll(() => controller.status().state).toBe("connected");
|
||||
const connectedAt = controller.status().connectedAt;
|
||||
|
||||
expect(connections).toBe(1);
|
||||
|
||||
// Model the reported proxy request ending without a close reaching the daemon.
|
||||
dropped = true;
|
||||
for (const peer of peers) peer.terminate();
|
||||
await expect.poll(() => peers.size).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await expect.poll(() => controller.status().state).toBe("reconnecting");
|
||||
expect(controller.status().lastError).toBe("Hub WebSocket heartbeat timed out");
|
||||
expect(retry).toBeTypeOf("function");
|
||||
|
||||
dropped = false;
|
||||
retry?.();
|
||||
await expect.poll(() => controller.status().state).toBe("connected");
|
||||
expect(connections).toBe(2);
|
||||
expect(controller.status().connectedAt).not.toBe(connectedAt);
|
||||
expect(controller.status().lastError).toBeNull();
|
||||
|
||||
// A healthy idle connection survives multiple liveness windows.
|
||||
for (let tick = 0; tick < 12; tick += 1) {
|
||||
const before = pongs;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect.poll(() => pongs).toBeGreaterThan(before);
|
||||
}
|
||||
expect(controller.status().state).toBe("connected");
|
||||
expect(connections).toBe(2);
|
||||
|
||||
await controller.stop();
|
||||
await expect.poll(() => peers.size).toBe(0);
|
||||
await expect.poll(() => vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
await controller.stop();
|
||||
vi.useRealTimers();
|
||||
for (const socket of streams) socket.destroy();
|
||||
for (const peer of peers) peer.terminate();
|
||||
webSockets.close();
|
||||
await Promise.all([close(proxy), close(hub)]);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function listen(
|
||||
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
|
||||
) {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
}
|
||||
|
||||
async function close(
|
||||
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { WebSocket } from "ws";
|
||||
import { z } from "zod";
|
||||
import type { WebSocketLike } from "../websocket-server.js";
|
||||
|
||||
const SOCKET_PING_INTERVAL_MS = 10_000;
|
||||
const SOCKET_PONG_TIMEOUT_MS = 45_000;
|
||||
|
||||
export interface HubEnrollment {
|
||||
daemonId: string;
|
||||
idempotencyKey: string;
|
||||
@@ -171,13 +174,42 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
||||
},
|
||||
});
|
||||
let settled = false;
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
const stopHeartbeat = () => {
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = undefined;
|
||||
};
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stopHeartbeat();
|
||||
socket.terminate();
|
||||
events.failed(error);
|
||||
};
|
||||
let lastPongAt = 0;
|
||||
socket.on("pong", () => {
|
||||
lastPongAt = Date.now();
|
||||
});
|
||||
socket.once("upgrade", (response) => {
|
||||
if (response.headers["x-paseo-session-protocol"] === "1") {
|
||||
sessionProtocol = "session-v1";
|
||||
}
|
||||
});
|
||||
socket.once("open", () => {
|
||||
if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
|
||||
if (settled) return;
|
||||
lastPongAt = Date.now();
|
||||
heartbeat = setInterval(() => {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
if (Date.now() - lastPongAt >= SOCKET_PONG_TIMEOUT_MS) {
|
||||
fail(new Error("Hub WebSocket heartbeat timed out"));
|
||||
return;
|
||||
}
|
||||
socket.ping(undefined, undefined, (error) => {
|
||||
if (error) fail(error);
|
||||
});
|
||||
}, SOCKET_PING_INTERVAL_MS);
|
||||
heartbeat.unref?.();
|
||||
events.connected(socket as WebSocketLike, sessionProtocol);
|
||||
});
|
||||
socket.once("unexpected-response", (_request, response) => {
|
||||
if (settled) {
|
||||
@@ -194,16 +226,12 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
||||
events.closed(1006);
|
||||
});
|
||||
socket.once("close", (code) => {
|
||||
stopHeartbeat();
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
events.closed(code);
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.terminate();
|
||||
events.failed(error);
|
||||
});
|
||||
socket.once("error", fail);
|
||||
return socket;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test, type Page } from "playwright/test";
|
||||
|
||||
test("keeps unrelated agent artwork unchanged while adding the Hermes mark", async ({ page }) => {
|
||||
await openSupportedAgents(page);
|
||||
await expectGenericAgentMark(page, "CodeWhale");
|
||||
await expectVendoredAgentMark(page, "Hermes Agent");
|
||||
});
|
||||
|
||||
async function openSupportedAgents(page: Page) {
|
||||
await page.goto("/agents");
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Supported agents" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectGenericAgentMark(page: Page, name: string) {
|
||||
const card = page.getByRole("link", { name, exact: true });
|
||||
await expect(card).toBeVisible();
|
||||
// Decorative SVGs have no accessible role; the fallback is Lucide's bot mark.
|
||||
await expect(card.locator("svg.lucide-bot")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectVendoredAgentMark(page: Page, name: string) {
|
||||
const card = page.getByRole("link", { name, exact: true });
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator("svg")).toBeVisible();
|
||||
await expect(card.locator("svg.lucide-bot")).toHaveCount(0);
|
||||
}
|
||||
|
After Width: | Height: | Size: 19 KiB |